From 91fce5c4bf1428518cce24d9433407b02fd03f2f Mon Sep 17 00:00:00 2001 From: "venbatechnologies@gmail.com" Date: Mon, 5 Jul 2021 13:49:40 +0530 Subject: [PATCH] Initial commit --- .babelrc | 14 + .bra.toml | 19 + .browserslistrc | 4 + .circleci/config.yml | 60 + .dockerignore | 18 + .drone.star | 9 + .drone.yml | 3328 ++ .editorconfig | 30 + .eslintignore | 8 + .eslintrc | 23 + .gitattributes | 1 + .github/CODEOWNERS | 76 + .github/ISSUE_TEMPLATE/1-bug_report.md | 31 + .github/ISSUE_TEMPLATE/2-feature_request.md | 11 + .github/ISSUE_TEMPLATE/3-accessibility.md | 26 + .../ISSUE_TEMPLATE/4-grafana_ui_component.md | 39 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/PULL_REQUEST_TEMPLATE.md | 34 + .github/bot.md | 30 + .github/commands.json | 53 + .github/dependabot.yml | 10 + .github/metrics-collector.json | 32 + .github/stale.yml | 47 + .github/workflows/backport.yml | 26 + .github/workflows/bump-version.yml | 27 + .github/workflows/codeql-analysis.yml | 71 + .github/workflows/commands.yml | 25 + .github/workflows/github-release.yml | 24 + .github/workflows/metrics-collector.yml | 35 + .github/workflows/publish.yml | 44 + .github/workflows/update-changelog.yml | 24 + .gitignore | 146 + .prettierignore | 11 + .prettierrc.js | 3 + CHANGELOG.md | 2744 ++ CHANGELOG_ARCHIVE.md | 2257 ++ CODE_OF_CONDUCT.md | 46 + CONTRIBUTING.md | 79 + Dockerfile | 85 + Dockerfile.ubuntu | 78 + GOVERNANCE.md | 205 + ISSUE_TRIAGE.md | 348 + LICENSE | 202 + MAINTAINERS.md | 8 + Makefile | 160 + NOTICE.md | 6 + PLUGIN_DEV.md | 33 + README.md | 46 + ROADMAP.md | 5 + SECURITY.md | 20 + SUPPORT.md | 10 + UPGRADING_DEPENDENCIES.md | 108 + WORKFLOW.md | 77 + api-extractor.json | 38 + build.go | 482 + conf/defaults.ini | 928 + conf/ldap.toml | 64 + conf/ldap_multiple.toml | 53 + conf/provisioning/dashboards/sample.yaml | 11 + conf/provisioning/datasources/sample.yaml | 54 + conf/provisioning/notifiers/sample.yaml | 25 + conf/provisioning/plugins/sample.yaml | 11 + conf/sample.ini | 915 + contribute/README.md | 16 + contribute/architecture/README.md | 13 + contribute/architecture/communication.md | 123 + contribute/architecture/database.md | 123 + .../architecture/frontend-data-requests.md | 41 + contribute/architecture/services.md | 69 + contribute/create-pull-request.md | 107 + contribute/developer-guide.md | 253 + contribute/documentation.md | 42 + contribute/engineering/terminology.md | 17 + contribute/style-guides/backend.md | 100 + contribute/style-guides/code-comments.md | 209 + .../documentation-markdown-guide.md | 151 + .../style-guides/documentation-style-guide.md | 316 + contribute/style-guides/e2e-core.md | 25 + contribute/style-guides/e2e-plugins.md | 28 + contribute/style-guides/e2e.md | 162 + contribute/style-guides/frontend.md | 349 + contribute/style-guides/redux.md | 82 + contribute/style-guides/storybook.md | 209 + contribute/style-guides/styling.md | 119 + contribute/style-guides/themes.md | 152 + contribute/templates/README.md | 57 + contribute/templates/doc-concept-template.md | 42 + .../templates/doc-reference-template.md | 75 + contribute/templates/doc-task-template.md | 57 + contribute/triage-issues.md | 32 + dashboard-schemas/Dashboard.cue | 68 + dashboard-schemas/README.md | 86 + dashboard-schemas/cue.mod/module.cue | 1 + dashboard-schemas/go.mod | 5 + dashboard-schemas/go.sum | 185 + dashboard-schemas/main.go | 58 + dashboard-schemas/main_test.go | 47 + dashboard-schemas/panels/Gauge.cue | 74 + dashboard-schemas/panels/Graph.cue | 192 + dashboard-schemas/panels/Row.cue | 24 + dashboard-schemas/panels/gridPos.cue | 12 + dashboard-schemas/panels/link.cue | 14 + dashboard-schemas/panels/mapping.cue | 11 + dashboard-schemas/panels/override.cue | 12 + dashboard-schemas/panels/panel.cue | 24 + dashboard-schemas/panels/thresholds.cue | 11 + dashboard-schemas/targets/Prometheus.cue | 19 + .../transformations/CalculateField.cue | 39 + .../transformations/Organize.cue | 16 + dashboard-schemas/variables/Custom.cue | 9 + dashboard-schemas/variables/Datasource.cue | 18 + dashboard-schemas/variables/Query.cue | 30 + dashboard-schemas/variables/variable.cue | 33 + devenv/README.md | 62 + devenv/benchmarks/ab/ab_test.sh | 4 + devenv/bulk-dashboards/bulk-dashboards.yaml | 9 + devenv/bulk-dashboards/bulkdash.jsonnet | 1140 + .../bulk_alerting_dashboards.yaml | 9 + .../dashboard.libsonnet | 170 + .../datasources.jsonnet | 14 + devenv/create_docker_compose.sh | 62 + devenv/dashboards.yaml | 11 + devenv/datasources.yaml | 271 + devenv/datasources_docker.yaml | 245 + .../panel_tests_graph.json | 1650 + .../panel_tests_graph_time_regions.json | 510 + .../panel_tests_polystat.json | 3314 ++ .../alerting/testdata_alerts.json | 746 + .../elasticsearch_compare.json | 11761 +++++++ .../elasticsearch_v2.json | 736 + .../elasticsearch_v5.json | 737 + .../elasticsearch_v6.json | 737 + .../elasticsearch_v6_filebeat.json | 241 + .../elasticsearch_v7.json | 736 + .../elasticsearch_v7_filebeat.json | 241 + .../datasource-influxdb/influxdb-logs.json | 191 + .../influxdb-templated.json | 323 + .../datasource-mssql/mssql_fakedata.json | 547 + .../datasource-mssql/mssql_unittest.json | 2842 ++ .../datasource-mysql/mysql_fakedata.json | 552 + .../datasource-mysql/mysql_unittest.json | 2654 ++ .../datasource-opentsdb/opentsdb.json | 217 + .../postgres_fakedata.json | 588 + .../postgres_unittest.json | 2632 ++ .../datasource-testdata/bar-gauge-demo2.json | 491 + .../datasource-testdata/demo1.json | 1103 + .../new_features_in_v62.json | 1337 + .../new_features_in_v74.json | 2190 ++ .../global-variables-and-interpolation.json | 110 + ...plating-dashboard-links-and-variables.json | 132 + .../templating-textbox-e2e-scenarios.json | 116 + .../testdata-datalinks.json | 510 + .../testdata-nested-variables-drilldown.json | 307 + .../testdata-nested-variables.json | 534 + .../testdata-repeating.json | 1365 + .../testdata-variables-textbox.json | 235 + ...-variables-that-update-on-time-change.json | 195 + devenv/dev-dashboards/home.json | 149 + .../panel-bargauge/bar_gauge_demo.json | 665 + .../panel-bargauge/panel_tests_bar_gauge.json | 829 + .../panel_tests_bar_gauge2.json | 530 + .../panel-common/auto_decimals.json | 1163 + .../panel-common/color_modes.json | 338 + .../panel-common/lazy_loading.json | 2213 ++ .../panel-common/linked-viz.json | 1194 + .../panel-common/panels_without_title.json | 904 + .../panel-common/shared_queries.json | 322 + .../panel-gauge/gauge-multi-series.json | 265 + .../panel-gauge/gauge_tests.json | 1330 + .../graph-gradient-area-fills.json | 383 + .../panel-graph/graph-ng-gradient-area.json | 579 + .../panel-graph/graph-ng-hue-gradients.json | 900 + .../panel-graph/graph-ng-nulls.json | 1046 + .../panel-graph/graph-ng-soft-limits.json | 2240 ++ .../panel-graph/graph-ng-time.json | 470 + .../panel-graph/graph-ng-yaxis-ticks.json | 760 + .../dev-dashboards/panel-graph/graph-ng.json | 3423 ++ .../panel-graph/graph-time-regions.json | 580 + .../panel-graph/graph_tests.json | 1651 + .../panel-graph/graph_y_axis.json | 810 + .../panel-library/panel-library.json | 178 + .../panel-piechart/panel_test_piechart.json | 518 + .../panel-polystat/polystat_test.json | 3315 ++ .../panel-singlestat/singlestat_test.json | 757 + .../panel-stat/panel-stat-tests.json | 790 + .../panel-table/table_tests.json | 452 + .../panel-table/table_tests_new.json | 639 + .../slow_queries_and_annotations.json | 1143 + .../scenarios/time_zone_support.json | 695 + .../blocks/alert_webhook_listener/Dockerfile | 7 + .../docker-compose.yaml | 5 + .../blocks/alert_webhook_listener/main.go | 27 + devenv/docker/blocks/apache_proxy/Dockerfile | 4 + .../blocks/apache_proxy/docker-compose.yaml | 9 + devenv/docker/blocks/apache_proxy/ports.conf | 1 + devenv/docker/blocks/apache_proxy/proxy.conf | 4 + .../docker/blocks/apache_proxy_mac/Dockerfile | 4 + .../apache_proxy_mac/docker-compose.yaml | 10 + .../docker/blocks/apache_proxy_mac/ports.conf | 1 + .../docker/blocks/apache_proxy_mac/proxy.conf | 4 + devenv/docker/blocks/collectd/Dockerfile | 16 + devenv/docker/blocks/collectd/README.md | 37 + .../docker/blocks/collectd/collectd.conf.tpl | 106 + .../blocks/collectd/docker-compose.yaml | 11 + devenv/docker/blocks/collectd/etc_mtab | 1 + devenv/docker/blocks/collectd/start_container | 5 + .../docker/blocks/elastic/docker-compose.yaml | 15 + .../docker/blocks/elastic/elasticsearch.yml | 2 + .../blocks/elastic1/docker-compose.yaml | 8 + .../docker/blocks/elastic1/elasticsearch.yml | 2 + .../blocks/elastic5/docker-compose.yaml | 15 + .../docker/blocks/elastic5/elasticsearch.yml | 2 + .../blocks/elastic6/docker-compose.yaml | 25 + .../docker/blocks/elastic6/elasticsearch.yml | 2 + devenv/docker/blocks/elastic6/filebeat.yml | 1978 ++ .../blocks/elastic7/docker-compose.yaml | 42 + .../docker/blocks/elastic7/elasticsearch.yml | 2 + devenv/docker/blocks/elastic7/filebeat.yml | 1978 ++ devenv/docker/blocks/elastic7/metricbeat.yml | 38 + .../docker/blocks/freeipa/docker-compose.yaml | 54 + .../docker/blocks/freeipa/ldap_freeipa.toml | 74 + devenv/docker/blocks/freeipa/notes.md | 32 + devenv/docker/blocks/grafana/.env | 1 + .../docker/blocks/grafana/docker-compose.yaml | 14 + devenv/docker/blocks/graphite/Dockerfile | 52 + .../blocks/graphite/docker-compose.yaml | 16 + .../docker/blocks/graphite/files/carbon.conf | 76 + .../blocks/graphite/files/events_views.py | 102 + .../blocks/graphite/files/initial_data.json | 20 + .../blocks/graphite/files/local_settings.py | 42 + .../docker/blocks/graphite/files/my_htpasswd | 1 + .../docker/blocks/graphite/files/nginx.conf | 70 + .../blocks/graphite/files/statsd_config.js | 8 + .../graphite/files/storage-aggregation.conf | 19 + .../graphite/files/storage-schemas.conf | 16 + .../blocks/graphite/files/supervisord.conf | 26 + devenv/docker/blocks/graphite1/Dockerfile | 124 + .../blocks/graphite1/big-dashboard.json | 1161 + .../conf/etc/logrotate.d/graphite-statsd | 11 + .../conf/etc/my_init.d/01_conf_init.sh | 36 + .../graphite1/conf/etc/nginx/nginx.conf | 96 + .../nginx/sites-enabled/graphite-statsd.conf | 31 + .../conf/etc/service/carbon-aggregator/run | 4 + .../graphite1/conf/etc/service/carbon/run | 4 + .../graphite1/conf/etc/service/graphite/run | 3 + .../graphite1/conf/etc/service/nginx/run | 4 + .../graphite1/conf/etc/service/statsd/run | 4 + .../opt/graphite/conf/aggregation-rules.conf | 35 + .../conf/opt/graphite/conf/blacklist.conf | 5 + .../conf/opt/graphite/conf/carbon.amqp.conf | 75 + .../conf/opt/graphite/conf/carbon.conf | 594 + .../conf/opt/graphite/conf/dashboard.conf | 57 + .../opt/graphite/conf/graphTemplates.conf | 38 + .../conf/opt/graphite/conf/relay-rules.conf | 21 + .../conf/opt/graphite/conf/rewrite-rules.conf | 18 + .../graphite/conf/storage-aggregation.conf | 42 + .../opt/graphite/conf/storage-schemas.conf | 36 + .../conf/opt/graphite/conf/whitelist.conf | 6 + .../graphite/webapp/graphite/app_settings.py | 94 + .../webapp/graphite/local_settings.py | 215 + .../graphite1/conf/opt/statsd/config.js | 6 + .../conf/usr/local/bin/django_admin_init.exp | 26 + .../graphite1/conf/usr/local/bin/manage.sh | 3 + .../blocks/graphite1/docker-compose.yaml | 21 + .../blocks/graphite11/big-dashboard.json | 1179 + .../blocks/graphite11/docker-compose.yaml | 18 + devenv/docker/blocks/influxdb/.env | 1 + .../blocks/influxdb/docker-compose.yaml | 31 + devenv/docker/blocks/influxdb/influxdb.conf | 93 + devenv/docker/blocks/influxdb/telegraf.conf | 5265 +++ .../blocks/influxdb2/docker-compose.yaml | 41 + devenv/docker/blocks/influxdb2/influxdb.conf | 93 + devenv/docker/blocks/influxdb2/telegraf.conf | 5268 +++ .../docker/blocks/jaeger/docker-compose.yaml | 32 + devenv/docker/blocks/loki/config.yaml | 25 + devenv/docker/blocks/loki/docker-compose.yaml | 14 + .../blocks/memcached/docker-compose.yaml | 5 + devenv/docker/blocks/mssql/build/Dockerfile | 12 + .../docker/blocks/mssql/build/entrypoint.sh | 2 + devenv/docker/blocks/mssql/build/setup.sh | 13 + .../blocks/mssql/build/setup.sql.template | 26 + .../docker/blocks/mssql/docker-compose.yaml | 19 + .../blocks/mssql_tests/docker-compose.yaml | 12 + .../admins-ldap-server.Dockerfile | 30 + .../admins-ldap-server/modules/memberof.ldif | 33 + .../prepopulate/1_units.ldif | 9 + .../prepopulate/2_users.ldif | 20 + .../prepopulate/3_groups.ldif | 6 + .../multiple-openldap/docker-compose.yaml | 23 + .../blocks/multiple-openldap/entrypoint.sh | 98 + .../multiple-openldap/ldap-server.Dockerfile | 30 + .../ldap-server/modules/memberof.ldif | 33 + .../ldap-server/prepopulate/1_units.ldif | 9 + .../ldap-server/prepopulate/2_users.ldif | 59 + .../ldap-server/prepopulate/3_groups.ldif | 23 + .../blocks/multiple-openldap/ldap_dev.toml | 59 + .../docker/blocks/multiple-openldap/notes.md | 38 + .../blocks/multiple-openldap/prepopulate.sh | 14 + devenv/docker/blocks/mysql/.env | 1 + devenv/docker/blocks/mysql/config | 6 + .../docker/blocks/mysql/docker-compose.yaml | 18 + .../docker/blocks/mysql_opendata/Dockerfile | 20 + .../blocks/mysql_opendata/docker-compose.yaml | 9 + .../blocks/mysql_opendata/import_csv.sql | 80 + devenv/docker/blocks/mysql_tests/.env | 1 + devenv/docker/blocks/mysql_tests/Dockerfile | 4 + .../blocks/mysql_tests/docker-compose.yaml | 13 + devenv/docker/blocks/mysql_tests/setup.sql | 2 + devenv/docker/blocks/nginx_proxy/Dockerfile | 4 + .../blocks/nginx_proxy/docker-compose.yaml | 9 + devenv/docker/blocks/nginx_proxy/htpasswd | 3 + devenv/docker/blocks/nginx_proxy/nginx.conf | 38 + .../blocks/nginx_proxy/nginx_login_only.conf | 42 + .../docker/blocks/nginx_proxy_mac/Dockerfile | 4 + .../nginx_proxy_mac/docker-compose.yaml | 11 + devenv/docker/blocks/nginx_proxy_mac/htpasswd | 3 + .../docker/blocks/nginx_proxy_mac/nginx.conf | 39 + .../nginx_proxy_mac/nginx_login_only.conf | 42 + devenv/docker/blocks/openldap/Dockerfile | 32 + .../blocks/openldap/docker-compose.yaml | 11 + devenv/docker/blocks/openldap/entrypoint.sh | 94 + devenv/docker/blocks/openldap/ldap.conf | 16 + devenv/docker/blocks/openldap/ldap_dev.toml | 54 + .../blocks/openldap/ldap_posix_dev.toml | 57 + .../blocks/openldap/modules/memberof.ldif | 33 + devenv/docker/blocks/openldap/notes.md | 50 + devenv/docker/blocks/openldap/prepopulate.sh | 14 + .../blocks/openldap/prepopulate/1_units.ldif | 9 + .../blocks/openldap/prepopulate/2_users.ldif | 108 + .../blocks/openldap/prepopulate/3_groups.ldif | 43 + .../blocks/opentsdb/docker-compose.yaml | 11 + devenv/docker/blocks/postgres/.env | 1 + .../blocks/postgres/docker-compose.yaml | 24 + devenv/docker/blocks/postgres_tests/.env | 1 + .../docker/blocks/postgres_tests/Dockerfile | 4 + .../blocks/postgres_tests/docker-compose.yaml | 11 + devenv/docker/blocks/postgres_tests/setup.sql | 3 + devenv/docker/blocks/prometheus/Dockerfile | 3 + devenv/docker/blocks/prometheus/alert.rules | 10 + .../blocks/prometheus/docker-compose.yaml | 26 + .../docker/blocks/prometheus/prometheus.yml | 39 + devenv/docker/blocks/prometheus2/Dockerfile | 4 + devenv/docker/blocks/prometheus2/alert.yml | 11 + .../blocks/prometheus2/docker-compose.yaml | 28 + .../docker/blocks/prometheus2/prometheus.yml | 39 + .../docker/blocks/prometheus2/recording.yml | 16 + .../prometheus_basic_auth_proxy/Dockerfile | 4 + .../docker-compose.yaml | 7 + .../prometheus_basic_auth_proxy/htpasswd | 1 + .../prometheus_basic_auth_proxy/nginx.conf | 35 + .../blocks/prometheus_random_data/Dockerfile | 18 + .../docker/blocks/redis/docker-compose.yaml | 5 + devenv/docker/blocks/saml/docker-compose.yaml | 16 + devenv/docker/blocks/saml/notes.md | 16 + devenv/docker/blocks/saml/users.php | 21 + devenv/docker/blocks/sensugo/backend.yml | 63 + .../docker/blocks/sensugo/docker-compose.yaml | 18 + devenv/docker/blocks/sensugo/notes.md | 39 + devenv/docker/blocks/slow_proxy/Dockerfile | 7 + .../blocks/slow_proxy/docker-compose.yaml | 7 + devenv/docker/blocks/slow_proxy/main.go | 31 + .../docker/blocks/slow_proxy_mac/Dockerfile | 7 + .../blocks/slow_proxy_mac/docker-compose.yaml | 6 + devenv/docker/blocks/slow_proxy_mac/main.go | 31 + devenv/docker/blocks/smtp/Dockerfile | 13 + devenv/docker/blocks/smtp/bootstrap.sh | 27 + devenv/docker/blocks/smtp/docker-compose.yaml | 4 + .../traefik/configs/subpath_no_rewrite.yml | 19 + .../docker/blocks/traefik/docker-compose.yml | 19 + devenv/docker/blocks/traefik/traefik.yml | 18 + .../docker/blocks/zipkin/docker-compose.yaml | 22 + devenv/docker/buildcontainer/Dockerfile | 20 + devenv/docker/buildcontainer/build.sh | 10 + devenv/docker/buildcontainer/build_circle.sh | 14 + devenv/docker/buildcontainer/run_circle.sh | 5 + devenv/docker/compose_header.yml | 2 + devenv/docker/debtest/Dockerfile | 6 + devenv/docker/debtest/build.sh | 10 + devenv/docker/ha_test/.gitignore | 1 + devenv/docker/ha_test/README.md | 137 + devenv/docker/ha_test/alerts.sh | 156 + devenv/docker/ha_test/docker-compose.yaml | 115 + devenv/docker/ha_test/fluentd/fluentd.conf | 73 + .../grafana/provisioning/alerts.jsonnet | 203 + .../dashboards/alerts/overview.json | 172 + .../provisioning/dashboards/dashboards.yaml | 14 + .../dashboards/mysql/overview.json | 5397 ++++ .../provisioning/datasources/datasources.yaml | 16 + .../docker/ha_test/prometheus/prometheus.yml | 47 + devenv/docker/loadtest/README.md | 109 + .../loadtest/annotations_by_tag_test.js | 76 + devenv/docker/loadtest/auth_key_test.js | 81 + devenv/docker/loadtest/auth_proxy_test.js | 56 + .../docker/loadtest/auth_token_slow_test.js | 78 + devenv/docker/loadtest/auth_token_test.js | 77 + devenv/docker/loadtest/modules/client.js | 213 + devenv/docker/loadtest/modules/util.js | 41 + devenv/docker/loadtest/run.sh | 45 + devenv/docker/rpmtest/build.sh | 10 + devenv/e2e-api-tests/clearState.test.ts | 7 + devenv/e2e-api-tests/client.ts | 30 + devenv/e2e-api-tests/dashboard.test.ts | 45 + devenv/e2e-api-tests/folder.test.ts | 78 + devenv/e2e-api-tests/jest.js | 15 + devenv/e2e-api-tests/search.test.ts | 27 + devenv/e2e-api-tests/setup.ts | 123 + devenv/e2e-api-tests/tsconfig.json | 21 + devenv/e2e-api-tests/user.test.ts | 22 + devenv/local-npm/conf/nginx/Dockerfile | 3 + .../conf/nginx/sites-enabled/verdaccio-conf | 14 + devenv/local-npm/docker-compose.yaml | 26 + devenv/setup.sh | 88 + docs/.gitignore | 1 + docs/Makefile | 24 + docs/README.md | 49 + docs/logo-horizontal.png | Bin 0 -> 27303 bytes docs/sources/_index.md | 121 + docs/sources/administration/_index.md | 14 + docs/sources/administration/cli.md | 213 + docs/sources/administration/configuration.md | 1579 + .../administration/configure-docker.md | 96 + .../sources/administration/image_rendering.md | 212 + .../administration/jaeger-instrumentation.md | 16 + .../administration/preferences/_index.md | 20 + .../preferences/change-grafana-name.md | 53 + .../preferences/change-grafana-theme.md | 66 + .../preferences/change-grafana-timezone.md | 44 + .../preferences/change-home-dashboard.md | 72 + docs/sources/administration/provisioning.md | 576 + docs/sources/administration/security.md | 46 + .../set-up-for-high-availability.md | 47 + .../administration/view-server/_index.md | 8 + .../view-server/internal-metrics.md | 92 + .../view-server/view-server-settings.md | 22 + .../view-server/view-server-stats.md | 49 + docs/sources/alerting/_index.md | 54 + .../alerting/add-notification-template.md | 23 + docs/sources/alerting/create-alerts.md | 126 + docs/sources/alerting/notifications.md | 270 + docs/sources/alerting/pause-an-alert-rule.md | 14 + docs/sources/alerting/troubleshoot-alerts.md | 41 + docs/sources/alerting/view-alerts.md | 19 + docs/sources/auth/_index.md | 25 + docs/sources/auth/auth-proxy.md | 304 + docs/sources/auth/azuread.md | 139 + docs/sources/auth/enhanced_ldap.md | 12 + docs/sources/auth/generic-oauth.md | 240 + docs/sources/auth/github.md | 111 + docs/sources/auth/gitlab.md | 123 + docs/sources/auth/google.md | 53 + docs/sources/auth/grafana.md | 120 + docs/sources/auth/jwt.md | 92 + docs/sources/auth/ldap.md | 342 + docs/sources/auth/okta.md | 84 + docs/sources/auth/overview.md | 140 + docs/sources/auth/saml.md | 13 + docs/sources/auth/team-sync.md | 22 + docs/sources/basics/_index.md | 14 + docs/sources/basics/glossary.md | 119 + docs/sources/basics/intro-histograms.md | 55 + docs/sources/basics/timeseries-dimensions.md | 85 + docs/sources/basics/timeseries.md | 123 + docs/sources/best-practices/_index.md | 14 + .../best-practices-for-creating-dashboards.md | 53 + .../best-practices-for-managing-dashboards.md | 37 + .../common-observability-strategies.md | 55 + .../dashboard-management-maturity-levels.md | 71 + docs/sources/dashboards/_index.md | 61 + docs/sources/dashboards/annotations.md | 67 + docs/sources/dashboards/dashboard_folders.md | 51 + docs/sources/dashboards/dashboard_history.md | 37 + docs/sources/dashboards/export-import.md | 109 + docs/sources/dashboards/json-model.md | 234 + docs/sources/dashboards/playlist.md | 149 + docs/sources/dashboards/reporting.md | 15 + .../sources/dashboards/scripted-dashboards.md | 55 + docs/sources/dashboards/search.md | 43 + docs/sources/dashboards/shortcuts.md | 15 + .../sources/dashboards/time-range-controls.md | 118 + docs/sources/datasources/_index.md | 48 + docs/sources/datasources/add-a-data-source.md | 38 + docs/sources/datasources/azuremonitor.md | 415 + docs/sources/datasources/cloudwatch.md | 448 + docs/sources/datasources/elasticsearch.md | 262 + .../google-cloud-monitoring/_index.md | 348 + .../preconfig-cloud-monitoring-dashboards.md | 24 + docs/sources/datasources/graphite.md | 221 + docs/sources/datasources/influxdb.md | 328 + docs/sources/datasources/influxdb/_index.md | 149 + .../datasources/influxdb/influxdb-flux.md | 77 + .../influxdb/influxdb-templates.md | 65 + .../influxdb/provision-influxdb.md | 67 + docs/sources/datasources/jaeger.md | 88 + docs/sources/datasources/loki.md | 219 + docs/sources/datasources/mssql.md | 609 + docs/sources/datasources/mysql.md | 407 + docs/sources/datasources/opentsdb.md | 100 + docs/sources/datasources/postgres.md | 417 + docs/sources/datasources/prometheus.md | 224 + docs/sources/datasources/tempo.md | 46 + docs/sources/datasources/testdata.md | 50 + docs/sources/datasources/zipkin.md | 60 + docs/sources/developers/_index.md | 9 + docs/sources/developers/cla.md | 98 + docs/sources/developers/contribute.md | 42 + docs/sources/developers/plugins/_index.md | 87 + ...-authentication-for-data-source-plugins.md | 172 + .../plugins/add-query-editor-help.md | 76 + .../plugins/add-support-for-annotations.md | 33 + .../add-support-for-explore-queries.md | 104 + .../plugins/add-support-for-variables.md | 201 + .../developers/plugins/backend/_index.md | 68 + .../backend/grafana-plugin-sdk-for-go.md | 19 + .../plugins/backend/plugin-protocol.md | 27 + .../build-a-logs-data-source-plugin.md | 141 + .../build-a-streaming-data-source-plugin.md | 136 + .../plugins/custom-panel-option-editors.md | 120 + .../sources/developers/plugins/data-frames.md | 180 + .../developers/plugins/error-handling.md | 63 + .../developers/plugins/legacy/_index.md | 127 + .../sources/developers/plugins/legacy/apps.md | 54 + .../developers/plugins/legacy/data-sources.md | 188 + .../legacy/defaults-and-editor-mode.md | 124 + .../developers/plugins/legacy/panels.md | 27 + .../plugins/legacy/review-guidelines.md | 173 + .../plugins/legacy/snapshot-mode.md | 75 + .../developers/plugins/legacy/style-guide.md | 192 + docs/sources/developers/plugins/metadata.md | 244 + .../developers/plugins/metadata.md.tpl | 10 + .../developers/plugins/migration-guide.md | 167 + .../developers/plugins/package-a-plugin.md | 47 + .../developers/plugins/plugin.schema.json | 441 + .../developers/plugins/sign-a-plugin.md | 112 + .../plugins/working-with-data-frames.md | 122 + docs/sources/enterprise/_index.md | 76 + docs/sources/enterprise/auditing.md | 183 + .../enterprise/datasource_permissions.md | 63 + docs/sources/enterprise/enhanced_ldap.md | 58 + .../enterprise/enterprise-configuration.md | 363 + docs/sources/enterprise/export-pdf.md | 17 + docs/sources/enterprise/license/_index.md | 15 + .../enterprise/license/activate-license.md | 82 + .../enterprise/license/license-expiration.md | 79 + .../license/license-restrictions.md | 65 + docs/sources/enterprise/query-caching.md | 28 + docs/sources/enterprise/reporting.md | 190 + docs/sources/enterprise/request-security.md | 57 + docs/sources/enterprise/saml.md | 298 + docs/sources/enterprise/team-sync.md | 49 + .../enterprise/usage-insights/_index.md | 23 + .../dashboard-datasource-insights.md | 43 + .../enterprise/usage-insights/export-logs.md | 79 + .../usage-insights/improved-search.md | 21 + .../usage-insights/presence-indicator.md | 25 + docs/sources/enterprise/vault.md | 135 + docs/sources/enterprise/white-labeling.md | 95 + docs/sources/explore/_index.md | 65 + docs/sources/explore/logs-integration.md | 124 + docs/sources/explore/query-management.md | 105 + docs/sources/explore/trace-integration.md | 100 + docs/sources/getting-started/_index.md | 77 + .../getting-started-influxdb.md | 110 + .../getting-started-prometheus.md | 64 + .../getting-started/getting-started-sql.md | 59 + .../getting-started/getting-started.md | 59 + docs/sources/getting-started/glossary.md | 119 + .../getting-started/intro-histograms.md | 54 + .../getting-started/timeseries-dimensions.md | 85 + docs/sources/getting-started/timeseries.md | 123 + docs/sources/http_api/_index.md | 44 + docs/sources/http_api/admin.md | 518 + docs/sources/http_api/alerting.md | 154 + .../alerting_notification_channels.md | 407 + docs/sources/http_api/annotations.md | 253 + docs/sources/http_api/auth.md | 170 + .../http_api/create-api-tokens-for-org.md | 73 + docs/sources/http_api/curl-examples.md | 21 + docs/sources/http_api/dashboard.md | 539 + .../sources/http_api/dashboard_permissions.md | 146 + docs/sources/http_api/dashboard_versions.md | 317 + docs/sources/http_api/data_source.md | 606 + .../http_api/datasource_permissions.md | 245 + docs/sources/http_api/external_group_sync.md | 107 + docs/sources/http_api/folder.md | 313 + .../http_api/folder_dashboard_search.md | 95 + docs/sources/http_api/folder_permissions.md | 145 + docs/sources/http_api/licensing.md | 130 + docs/sources/http_api/org.md | 513 + docs/sources/http_api/other.md | 106 + docs/sources/http_api/playlist.md | 282 + docs/sources/http_api/preferences.md | 117 + docs/sources/http_api/reporting.md | 66 + docs/sources/http_api/short_url.md | 51 + docs/sources/http_api/snapshot.md | 219 + docs/sources/http_api/team.md | 378 + docs/sources/http_api/user.md | 605 + docs/sources/installation/_index.md | 24 + docs/sources/installation/debian.md | 175 + docs/sources/installation/docker.md | 243 + docs/sources/installation/kubernetes.md | 241 + docs/sources/installation/mac.md | 67 + docs/sources/installation/requirements.md | 64 + docs/sources/installation/restart-grafana.md | 128 + docs/sources/installation/rpm.md | 204 + docs/sources/installation/upgrading.md | 322 + docs/sources/installation/windows.md | 49 + docs/sources/linking/_index.md | 30 + docs/sources/linking/dashboard-links.md | 68 + docs/sources/linking/data-link-variables.md | 54 + docs/sources/linking/data-links.md | 48 + docs/sources/linking/linking-overview.md | 31 + docs/sources/linking/panel-links.md | 44 + docs/sources/manage-users/_index.md | 56 + .../manage-users/manage-teams/index.md | 89 + docs/sources/manage-users/org-admin/index.md | 94 + .../manage-users/server-admin/_index.md | 14 + .../server-admin/server-admin-manage-orgs.md | 94 + .../server-admin/server-admin-manage-users.md | 224 + .../sources/manage-users/user-admin/_index.md | 10 + .../user-admin/change-your-password.md | 21 + .../manage-users/user-admin/switch-org.md | 12 + .../manage-users/user-admin/user-profile.md | 42 + docs/sources/menu.yaml | 0 docs/sources/panels/_index.md | 26 + docs/sources/panels/add-a-panel.md | 60 + docs/sources/panels/calculations-list.md | 29 + docs/sources/panels/expressions.md | 153 + docs/sources/panels/field-options/_index.md | 36 + .../field-options/configure-all-fields.md | 51 + .../configure-specific-fields.md | 64 + .../field-options/standard-field-options.md | 129 + docs/sources/panels/inspect-panel.md | 89 + docs/sources/panels/panel-editor.md | 74 + docs/sources/panels/queries.md | 132 + docs/sources/panels/share-query-results.md | 25 + docs/sources/panels/thresholds.md | 73 + docs/sources/panels/transformations/_index.md | 35 + .../transformations/apply-transformations.md | 34 + .../panels/transformations/types-options.md | 452 + docs/sources/panels/visualizations/_index.md | 12 + .../panels/visualizations/alert-list-panel.md | 48 + .../panels/visualizations/bar-gauge-panel.md | 41 + .../visualizations/dashboard-list-panel.md | 36 + .../panels/visualizations/gauge-panel.md | 35 + .../panels/visualizations/graph-panel.md | 163 + docs/sources/panels/visualizations/heatmap.md | 97 + .../panels/visualizations/logs-panel.md | 31 + .../panels/visualizations/news-panel.md | 11 + .../panels/visualizations/node-graph.md | 95 + .../panels/visualizations/panel-legend.md | 35 + .../panels/visualizations/pie-chart-panel.md | 83 + .../panels/visualizations/stat-panel.md | 70 + .../panels/visualizations/table/_index.md | 36 + .../table/filter-table-columns.md | 38 + .../table/table-field-options.md | 81 + .../panels/visualizations/text-panel.md | 12 + .../visualizations/time-series/_index.md | 48 + .../time-series/change-axis-display.md | 86 + .../time-series/graph-time-series-as-bars.md | 142 + .../time-series/graph-time-series-as-lines.md | 232 + .../graph-time-series-as-points.md | 44 + docs/sources/permissions/_index.md | 51 + .../dashboard-folder-permissions.md | 50 + .../dashboard_folder_permissions.md | 84 + .../permissions/datasource_permissions.md | 12 + .../sources/permissions/organization_roles.md | 75 + .../sources/permissions/restricting-access.md | 41 + docs/sources/plugins/_index.md | 46 + docs/sources/plugins/installation.md | 32 + docs/sources/plugins/plugin-signatures.md | 53 + docs/sources/release-notes/_index.md | 31 + .../release-notes/release-notes-7-3-0.md | 104 + .../release-notes/release-notes-7-3-1.md | 18 + .../release-notes/release-notes-7-3-10.md | 15 + .../release-notes/release-notes-7-3-2.md | 29 + .../release-notes/release-notes-7-3-3.md | 19 + .../release-notes/release-notes-7-3-4.md | 17 + .../release-notes/release-notes-7-3-5.md | 22 + .../release-notes/release-notes-7-3-6.md | 14 + .../release-notes/release-notes-7-3-7.md | 17 + .../release-notes/release-notes-7-4-0.md | 185 + .../release-notes/release-notes-7-4-1.md | 30 + .../release-notes/release-notes-7-4-2.md | 30 + .../release-notes/release-notes-7-4-3.md | 19 + .../release-notes/release-notes-7-4-5.md | 15 + .../release-notes-7-5-0-beta1.md | 68 + .../release-notes-7-5-0-beta2.md | 47 + .../release-notes/release-notes-7-5-0.md | 22 + .../release-notes/release-notes-7-5-1.md | 14 + .../release-notes/release-notes-7-5-2.md | 22 + .../release-notes/release-notes-7-5-3.md | 26 + .../release-notes/release-notes-7-5-4.md | 22 + .../release-notes/release-notes-v7-4-2.md | 9 + docs/sources/shared/basics/grafana-cloud.md | 9 + .../shared/basics/grafana-enterprise.md | 11 + docs/sources/shared/basics/what-is-grafana.md | 7 + docs/sources/shared/example.md | 41 + .../shared/getting-started/first-step.md | 13 + docs/sources/shared/index.md | 7 + docs/sources/shared/influxdb/intro.md | 5 + .../view-server-org-list-and-edit.md | 8 + .../manage-users/view-server-org-list.md | 6 + .../view-server-user-list-search.md | 8 + .../manage-users/view-server-user-list.md | 6 + .../shared/manage-users/view-team-list.md | 6 + .../navigate-to-the-dashboard-list.md | 6 + .../navigate-user-preferences-list.md | 5 + .../preferences/org-preferences-list.md | 6 + .../preferences/select-home-dashboard-list.md | 6 + .../preferences/select-timezone-list.md | 6 + .../preferences/select-ui-theme-list.md | 6 + .../some-tasks-require-permissions.md | 5 + docs/sources/shared/test.md | 7 + docs/sources/shared/view-server-org-list.md | 6 + .../shared/view-server-user-list-search.md | 7 + .../shared/visualizations/legend-mode.md | 18 + docs/sources/sharing/_index.md | 20 + docs/sources/sharing/playlists.md | 150 + docs/sources/sharing/share-dashboard.md | 48 + docs/sources/sharing/share-panel.md | 73 + docs/sources/troubleshooting/_index.md | 43 + docs/sources/troubleshooting/diagnostics.md | 55 + .../troubleshoot-dashboards.md | 42 + .../troubleshooting/troubleshoot-queries.md | 29 + docs/sources/variables/_index.md | 46 + .../advanced-variable-format-options.md | 161 + .../variables/filter-variables-with-regex.md | 111 + .../formatting-multi-value-variables.md | 30 + docs/sources/variables/inspect-variable.md | 22 + docs/sources/variables/manage-variable.md | 22 + .../variables/repeat-panels-or-rows.md | 50 + docs/sources/variables/syntax.md | 24 + docs/sources/variables/variable-examples.md | 20 + .../variables/variable-selection-options.md | 26 + .../variables/variable-types/_index.md | 20 + .../variable-types/add-ad-hoc-filters.md | 32 + .../variable-types/add-constant-variable.md | 31 + .../variable-types/add-custom-variable.md | 30 + .../add-data-source-variable.md | 29 + .../variable-types/add-interval-variable.md | 46 + .../variable-types/add-query-variable.md | 44 + .../variable-types/add-text-box-variable.md | 27 + .../variable-types/chained-variables.md | 173 + .../variable-types/global-variables.md | 84 + docs/sources/variables/variable-value-tags.md | 34 + docs/sources/whatsnew/_index.md | 36 + docs/sources/whatsnew/whats-new-in-v2-0.md | 178 + docs/sources/whatsnew/whats-new-in-v2-1.md | 133 + docs/sources/whatsnew/whats-new-in-v2-5.md | 108 + docs/sources/whatsnew/whats-new-in-v2-6.md | 127 + docs/sources/whatsnew/whats-new-in-v3-0.md | 225 + docs/sources/whatsnew/whats-new-in-v3-1.md | 60 + docs/sources/whatsnew/whats-new-in-v4-0.md | 176 + docs/sources/whatsnew/whats-new-in-v4-1.md | 66 + docs/sources/whatsnew/whats-new-in-v4-2.md | 86 + docs/sources/whatsnew/whats-new-in-v4-3.md | 102 + docs/sources/whatsnew/whats-new-in-v4-4.md | 47 + docs/sources/whatsnew/whats-new-in-v4-5.md | 68 + docs/sources/whatsnew/whats-new-in-v4-6.md | 72 + docs/sources/whatsnew/whats-new-in-v5-0.md | 148 + docs/sources/whatsnew/whats-new-in-v5-1.md | 123 + docs/sources/whatsnew/whats-new-in-v5-2.md | 99 + docs/sources/whatsnew/whats-new-in-v5-3.md | 90 + docs/sources/whatsnew/whats-new-in-v5-4.md | 81 + docs/sources/whatsnew/whats-new-in-v6-0.md | 174 + docs/sources/whatsnew/whats-new-in-v6-1.md | 57 + docs/sources/whatsnew/whats-new-in-v6-2.md | 94 + docs/sources/whatsnew/whats-new-in-v6-3.md | 144 + docs/sources/whatsnew/whats-new-in-v6-4.md | 146 + docs/sources/whatsnew/whats-new-in-v6-5.md | 209 + docs/sources/whatsnew/whats-new-in-v6-6.md | 218 + docs/sources/whatsnew/whats-new-in-v6-7.md | 94 + docs/sources/whatsnew/whats-new-in-v7-0.md | 229 + docs/sources/whatsnew/whats-new-in-v7-1.md | 112 + docs/sources/whatsnew/whats-new-in-v7-2.md | 162 + docs/sources/whatsnew/whats-new-in-v7-3.md | 161 + docs/sources/whatsnew/whats-new-in-v7-4.md | 243 + docs/sources/whatsnew/whats-new-in-v7-5.md | 162 + e2e/kill-server | 11 + e2e/run-suite | 32 + e2e/shared/smokeTestScenario.ts | 33 + e2e/start-and-run-suite | 13 + e2e/start-server | 59 + e2e/suite1/specs/1-smoketests.spec.ts | 4 + e2e/suite1/specs/bar-gauge.spec.ts | 19 + e2e/suite1/specs/dashboard-templating.spec.ts | 53 + e2e/suite1/specs/dashboard-time-zone.spec.ts | 82 + e2e/suite1/specs/explore.spec.ts | 25 + e2e/suite1/specs/gauge.spec.ts | 21 + e2e/suite1/specs/inspect-drawer.spec.ts | 148 + e2e/suite1/specs/panelEdit_base.spec.ts | 114 + e2e/suite1/specs/panelEdit_queries.spec.ts | 115 + e2e/suite1/specs/panelEdit_transforms.spec.ts | 22 + e2e/suite1/specs/query-editor.spec.ts | 28 + e2e/suite1/specs/select-focus.spec.ts | 31 + e2e/suite1/specs/solo-route.spec.ts | 15 + ...emplating-dashboard-links-and-variables.ts | 60 + e2e/suite1/specs/trace-view-scrolling.spec.ts | 35 + .../specs/variables/load-options-from-url.ts | 156 + .../specs/variables/new-query-variable.ts | 195 + .../specs/variables/set-options-from-ui.ts | 167 + .../specs/variables/textbox-variables.ts | 263 + e2e/suite1/tsconfig.json | 7 + e2e/variables | 10 + e2e/verify-release | 5 + e2e/verify/specs/smoketests.spec.ts | 4 + e2e/verify/tsconfig.json | 7 + e2e/wait-for-grafana | 11 + emails/README.md | 15 + emails/assets/css/ink.css | 688 + emails/assets/css/style.css | 195 + emails/grunt/aliases.yaml | 8 + emails/grunt/assemble.js | 16 + emails/grunt/clean.js | 5 + emails/grunt/premailer.js | 16 + emails/grunt/processhtml.js | 12 + emails/grunt/replace.js | 16 + emails/grunt/uncss.js | 9 + emails/grunt/watch.js | 15 + emails/gruntfile.js | 4 + emails/package.json | 26 + emails/templates/alert_notification.html | 134 + emails/templates/invited_to_org.html | 47 + emails/templates/layouts/default.html | 161 + emails/templates/new_user_invite.html | 49 + emails/templates/reset_password.html | 42 + emails/templates/signup_started.html | 46 + emails/templates/welcome_on_signup.html | 48 + go.mod | 107 + go.sum | 2225 ++ grafana-mixin/.gitignore | 3 + grafana-mixin/Makefile | 13 + grafana-mixin/README.md | 28 + grafana-mixin/alerts/alerts.yaml | 14 + .../dashboards/grafana-overview.json | 512 + grafana-mixin/mixin.libsonnet | 15 + grafana-mixin/rules/rules.yaml | 7 + grafana-mixin/scripts/build.sh | 6 + grafana-mixin/scripts/common.sh | 1 + grafana-mixin/scripts/format.sh | 9 + grafana-mixin/scripts/lint.sh | 13 + jest.config.js | 26 + latest.json | 4 + lerna.json | 8 + package.json | 322 + packages/README.md | 115 + packages/grafana-data/.eslintrc | 13 + packages/grafana-data/CHANGELOG.md | 3 + packages/grafana-data/README.md | 5 + packages/grafana-data/api-extractor.json | 3 + packages/grafana-data/index.js | 7 + packages/grafana-data/package.json | 58 + packages/grafana-data/rollup.config.ts | 38 + .../src/dataframe/ArrayDataFrame.test.ts | 115 + .../src/dataframe/ArrayDataFrame.ts | 112 + .../src/dataframe/ArrowDataFrame.test.ts | 85 + .../src/dataframe/ArrowDataFrame.ts | 227 + .../src/dataframe/CircularDataFrame.ts | 22 + .../src/dataframe/DataFrameView.test.ts | 84 + .../src/dataframe/DataFrameView.ts | 99 + .../src/dataframe/FieldCache.test.ts | 93 + .../grafana-data/src/dataframe/FieldCache.ts | 89 + .../src/dataframe/MutableDataFrame.test.ts | 66 + .../src/dataframe/MutableDataFrame.ts | 245 + .../__snapshots__/ArrowDataFrame.test.ts.snap | 389 + .../__snapshots__/all_types.golden.arrow | Bin 0 -> 10578 bytes .../grafana-data/src/dataframe/dimensions.ts | 39 + .../src/dataframe/frameComparisons.test.ts | 238 + .../src/dataframe/frameComparisons.ts | 118 + packages/grafana-data/src/dataframe/index.ts | 9 + .../src/dataframe/processDataFrame.test.ts | 336 + .../src/dataframe/processDataFrame.ts | 479 + packages/grafana-data/src/dataframe/utils.ts | 12 + packages/grafana-data/src/datetime/common.ts | 53 + .../src/datetime/datemath.test.ts | 138 + .../grafana-data/src/datetime/datemath.ts | 162 + .../grafana-data/src/datetime/formats.test.ts | 19 + packages/grafana-data/src/datetime/formats.ts | 121 + .../src/datetime/formatter.test.ts | 76 + .../grafana-data/src/datetime/formatter.ts | 107 + packages/grafana-data/src/datetime/index.ts | 10 + .../src/datetime/moment_wrapper.ts | 122 + .../grafana-data/src/datetime/parser.test.ts | 25 + packages/grafana-data/src/datetime/parser.ts | 94 + .../src/datetime/rangeutil.test.ts | 43 + .../grafana-data/src/datetime/rangeutil.ts | 419 + .../src/datetime/timezones.test.ts | 28 + .../grafana-data/src/datetime/timezones.ts | 436 + .../grafana-data/src/events/EventBus.test.ts | 173 + packages/grafana-data/src/events/EventBus.ts | 92 + .../grafana-data/src/events/eventFactory.ts | 12 + packages/grafana-data/src/events/index.ts | 3 + packages/grafana-data/src/events/types.ts | 115 + .../src/field/FieldConfigOptionsRegistry.tsx | 4 + .../src/field/displayProcessor.test.ts | 356 + .../src/field/displayProcessor.ts | 138 + .../grafana-data/src/field/fieldColor.test.ts | 91 + packages/grafana-data/src/field/fieldColor.ts | 242 + .../grafana-data/src/field/fieldComparers.ts | 108 + .../src/field/fieldDisplay.test.ts | 248 + .../grafana-data/src/field/fieldDisplay.ts | 324 + .../src/field/fieldOverrides.test.ts | 823 + .../grafana-data/src/field/fieldOverrides.ts | 448 + .../grafana-data/src/field/fieldState.test.ts | 170 + packages/grafana-data/src/field/fieldState.ts | 192 + .../field/getFieldDisplayValuesProxy.test.tsx | 78 + .../src/field/getFieldDisplayValuesProxy.ts | 60 + packages/grafana-data/src/field/index.ts | 18 + .../src/field/overrides/processors.ts | 150 + packages/grafana-data/src/field/scale.test.ts | 29 + packages/grafana-data/src/field/scale.ts | 84 + .../standardFieldConfigEditorRegistry.ts | 27 + .../src/field/templateProxies.test.ts | 32 + .../grafana-data/src/field/templateProxies.ts | 39 + .../grafana-data/src/field/thresholds.test.ts | 93 + packages/grafana-data/src/field/thresholds.ts | 38 + packages/grafana-data/src/index.ts | 22 + .../src/panel/PanelPlugin.test.tsx | 280 + .../grafana-data/src/panel/PanelPlugin.ts | 328 + .../src/panel/registryFactories.ts | 64 + packages/grafana-data/src/text/index.ts | 11 + .../grafana-data/src/text/markdown.test.ts | 13 + packages/grafana-data/src/text/markdown.ts | 28 + packages/grafana-data/src/text/sanitize.ts | 40 + packages/grafana-data/src/text/string.test.ts | 90 + packages/grafana-data/src/text/string.ts | 97 + packages/grafana-data/src/text/text.test.ts | 68 + packages/grafana-data/src/text/text.ts | 84 + .../src/transformations/fieldReducer.test.ts | 165 + .../src/transformations/fieldReducer.ts | 443 + .../grafana-data/src/transformations/index.ts | 14 + .../src/transformations/matchers.ts | 99 + .../matchers/fieldTypeMatcher.test.ts | 23 + .../matchers/fieldTypeMatcher.ts | 59 + .../src/transformations/matchers/ids.ts | 55 + .../transformations/matchers/matchers.test.ts | 11 + .../matchers/nameMatcher.test.ts | 388 + .../transformations/matchers/nameMatcher.ts | 200 + .../matchers/predicates.test.ts | 41 + .../transformations/matchers/predicates.ts | 265 + .../transformations/matchers/refIdMatcher.ts | 27 + .../matchers/simpleFieldMatcher.ts | 42 + .../valueMatchers/equalMatchers.test.ts | 108 + .../matchers/valueMatchers/equalMatchers.ts | 42 + .../valueMatchers/nullMatchers.test.ts | 72 + .../matchers/valueMatchers/nullMatchers.ts | 40 + .../valueMatchers/numericMatchers.test.ts | 180 + .../matchers/valueMatchers/numericMatchers.ts | 91 + .../valueMatchers/rangeMatchers.test.ts | 49 + .../matchers/valueMatchers/rangeMatchers.ts | 26 + .../valueMatchers/regexMatchers.test.ts | 85 + .../matchers/valueMatchers/regexMatchers.ts | 25 + .../matchers/valueMatchers/types.ts | 23 + .../standardTransformersRegistry.ts | 32 + .../src/transformations/transformDataFrame.ts | 68 + .../src/transformations/transformers.ts | 42 + .../transformers/calculateField.test.ts | 222 + .../transformers/calculateField.ts | 238 + .../transformers/concat.test.ts | 136 + .../transformations/transformers/concat.ts | 105 + .../transformers/ensureColumns.test.ts | 144 + .../transformers/ensureColumns.ts | 51 + .../transformers/filter.test.ts | 38 + .../transformations/transformers/filter.ts | 107 + .../transformers/filterByName.test.ts | 198 + .../transformers/filterByName.ts | 51 + .../transformers/filterByRefId.test.ts | 54 + .../transformers/filterByRefId.ts | 38 + .../transformers/filterByValue.test.ts | 219 + .../transformers/filterByValue.ts | 159 + .../transformers/groupBy.test.ts | 263 + .../transformations/transformers/groupBy.ts | 188 + .../src/transformations/transformers/ids.ts | 25 + .../transformers/joinDataFrames.test.ts | 297 + .../transformers/joinDataFrames.ts | 318 + .../transformers/labelsToFields.test.ts | 124 + .../transformers/labelsToFields.ts | 76 + .../transformers/merge.test.ts | 587 + .../src/transformations/transformers/merge.ts | 217 + .../src/transformations/transformers/noop.ts | 20 + .../transformers/order.test.ts | 182 + .../src/transformations/transformers/order.ts | 64 + .../transformers/organize.test.ts | 134 + .../transformations/transformers/organize.ts | 43 + .../transformers/reduce.test.ts | 369 + .../transformations/transformers/reduce.ts | 216 + .../transformers/rename.test.ts | 193 + .../transformations/transformers/rename.ts | 65 + .../transformers/renameByRegex.test.ts | 180 + .../transformers/renameByRegex.ts | 62 + .../transformers/seriesToColumns.test.ts | 488 + .../transformers/seriesToColumns.ts | 36 + .../transformers/seriesToRows.test.ts | 260 + .../transformers/seriesToRows.ts | 99 + .../transformers/sortBy.test.ts | 99 + .../transformations/transformers/sortBy.ts | 65 + .../src/types/OptionsUIRegistryBuilder.ts | 95 + packages/grafana-data/src/types/ScopedVars.ts | 7 + .../grafana-data/src/types/annotations.ts | 91 + packages/grafana-data/src/types/app.ts | 89 + packages/grafana-data/src/types/config.ts | 130 + packages/grafana-data/src/types/data.ts | 168 + packages/grafana-data/src/types/dataFrame.ts | 222 + packages/grafana-data/src/types/dataLink.ts | 91 + packages/grafana-data/src/types/datasource.ts | 639 + .../grafana-data/src/types/displayValue.ts | 45 + packages/grafana-data/src/types/explore.ts | 10 + packages/grafana-data/src/types/fieldColor.ts | 28 + .../grafana-data/src/types/fieldOverrides.ts | 132 + packages/grafana-data/src/types/flot.ts | 8 + packages/grafana-data/src/types/geometry.ts | 14 + packages/grafana-data/src/types/graph.ts | 31 + packages/grafana-data/src/types/index.ts | 34 + .../grafana-data/src/types/legacyEvents.ts | 46 + packages/grafana-data/src/types/live.ts | 213 + packages/grafana-data/src/types/logs.ts | 142 + packages/grafana-data/src/types/navModel.ts | 42 + packages/grafana-data/src/types/options.ts | 55 + packages/grafana-data/src/types/orgs.ts | 11 + packages/grafana-data/src/types/panel.ts | 178 + packages/grafana-data/src/types/plugin.ts | 194 + packages/grafana-data/src/types/select.ts | 11 + .../grafana-data/src/types/templateVars.ts | 7 + packages/grafana-data/src/types/theme.ts | 250 + packages/grafana-data/src/types/thresholds.ts | 31 + packages/grafana-data/src/types/time.ts | 53 + packages/grafana-data/src/types/trace.ts | 42 + .../grafana-data/src/types/transformations.ts | 58 + .../grafana-data/src/types/valueMapping.ts | 21 + packages/grafana-data/src/types/variables.ts | 99 + packages/grafana-data/src/types/vector.ts | 35 + .../src/utils/OptionsUIBuilders.ts | 240 + .../grafana-data/src/utils/Registry.test.ts | 31 + packages/grafana-data/src/utils/Registry.ts | 176 + .../src/utils/__snapshots__/csv.test.ts.snap | 151 + .../grafana-data/src/utils/binaryOperators.ts | 39 + packages/grafana-data/src/utils/csv.test.ts | 162 + packages/grafana-data/src/utils/csv.ts | 320 + .../grafana-data/src/utils/dataLinks.test.ts | 39 + packages/grafana-data/src/utils/dataLinks.ts | 104 + packages/grafana-data/src/utils/datasource.ts | 112 + .../src/utils/deprecationWarning.test.ts | 34 + .../src/utils/deprecationWarning.ts | 17 + packages/grafana-data/src/utils/docs.ts | 9 + .../grafana-data/src/utils/fieldParser.ts | 28 + .../grafana-data/src/utils/flotPairs.test.ts | 71 + packages/grafana-data/src/utils/flotPairs.ts | 67 + packages/grafana-data/src/utils/index.ts | 19 + .../grafana-data/src/utils/labels.test.ts | 78 + packages/grafana-data/src/utils/labels.ts | 90 + .../grafana-data/src/utils/location.test.ts | 21 + packages/grafana-data/src/utils/location.ts | 73 + packages/grafana-data/src/utils/logs.test.ts | 369 + packages/grafana-data/src/utils/logs.ts | 228 + .../src/utils/namedColorsPalette.test.ts | 33 + .../src/utils/namedColorsPalette.ts | 267 + packages/grafana-data/src/utils/object.ts | 8 + .../grafana-data/src/utils/series.test.ts | 47 + packages/grafana-data/src/utils/series.ts | 46 + .../src/utils/testdata/roundtrip.csv | 12 + .../src/utils/testdata/simple.csv | 3 + .../src/utils/testdata/testTheme.ts | 12 + .../src/utils/testdata/withHeaders.csv | 7 + .../src/utils/tests/mockStandardProperties.ts | 170 + .../tests/mockTransformationsRegistry.ts | 16 + packages/grafana-data/src/utils/url.test.ts | 25 + packages/grafana-data/src/utils/url.ts | 140 + .../src/utils/valueMappings.test.ts | 156 + .../grafana-data/src/utils/valueMappings.ts | 104 + .../valueFormats/arithmeticFormatters.test.ts | 41 + .../src/valueFormats/arithmeticFormatters.ts | 45 + .../src/valueFormats/categories.ts | 400 + .../valueFormats/dateTimeFormatters.test.ts | 374 + .../src/valueFormats/dateTimeFormatters.ts | 420 + .../grafana-data/src/valueFormats/index.ts | 1 + .../src/valueFormats/symbolFormatters.test.ts | 55 + .../src/valueFormats/symbolFormatters.ts | 71 + .../src/valueFormats/valueFormats.test.ts | 140 + .../src/valueFormats/valueFormats.ts | 265 + .../src/vector/AppendedVectors.test.ts | 23 + .../src/vector/AppendedVectors.ts | 73 + .../grafana-data/src/vector/ArrayVector.ts | 42 + .../grafana-data/src/vector/AsNumberVector.ts | 21 + .../src/vector/BinaryOperationVector.test.ts | 18 + .../src/vector/BinaryOperationVector.ts | 26 + .../src/vector/CircularVector.test.ts | 131 + .../grafana-data/src/vector/CircularVector.ts | 143 + .../src/vector/ConstantVector.test.ts | 17 + .../grafana-data/src/vector/ConstantVector.ts | 25 + .../src/vector/FormattedVector.ts | 22 + .../src/vector/FunctionalVector.ts | 77 + .../grafana-data/src/vector/IndexVector.ts | 36 + packages/grafana-data/src/vector/RowVector.ts | 28 + .../grafana-data/src/vector/SortedVector.ts | 25 + packages/grafana-data/src/vector/index.ts | 11 + .../grafana-data/src/vector/vectorToArray.ts | 9 + packages/grafana-data/tsconfig.build.json | 4 + packages/grafana-data/tsconfig.json | 16 + packages/grafana-data/typings/jest/index.d.ts | 12 + packages/grafana-e2e-selectors/CHANGELOG.md | 2 + packages/grafana-e2e-selectors/README.md | 3 + .../grafana-e2e-selectors/api-extractor.json | 3 + packages/grafana-e2e-selectors/index.js | 7 + packages/grafana-e2e-selectors/package.json | 49 + .../grafana-e2e-selectors/rollup.config.ts | 25 + packages/grafana-e2e-selectors/src/index.ts | 7 + .../src/selectors/components.ts | 187 + .../src/selectors/index.ts | 8 + .../src/selectors/pages.ts | 148 + .../grafana-e2e-selectors/src/types/index.ts | 1 + .../src/types/selectors.ts | 15 + .../grafana-e2e-selectors/tsconfig.build.json | 4 + packages/grafana-e2e-selectors/tsconfig.json | 11 + packages/grafana-e2e/.gitignore | 3 + packages/grafana-e2e/CHANGELOG.md | 2 + packages/grafana-e2e/README.md | 5 + packages/grafana-e2e/api-extractor.json | 3 + packages/grafana-e2e/bin/grafana-e2e.js | 3 + packages/grafana-e2e/cli.js | 49 + packages/grafana-e2e/cypress.json | 4 + .../grafana-e2e/cypress/fixtures/example.json | 5 + .../cypress/fixtures/long-trace-response.json | 7592 +++++ .../cypress/plugins/compareScreenshots.js | 49 + .../cypress/plugins/extendConfig.js | 79 + packages/grafana-e2e/cypress/plugins/index.js | 19 + .../cypress/plugins/readProvisions.js | 14 + .../cypress/plugins/typescriptPreprocessor.js | 39 + .../grafana-e2e/cypress/support/commands.ts | 25 + .../grafana-e2e/cypress/support/index.d.ts | 9 + packages/grafana-e2e/cypress/support/index.ts | 39 + packages/grafana-e2e/cypress/tsconfig.json | 9 + packages/grafana-e2e/index.js | 7 + packages/grafana-e2e/package.json | 60 + packages/grafana-e2e/rollup.config.ts | 39 + packages/grafana-e2e/src/components/index.ts | 0 .../grafana-e2e/src/flows/addDashboard.ts | 218 + .../grafana-e2e/src/flows/addDataSource.ts | 115 + packages/grafana-e2e/src/flows/addPanel.ts | 14 + .../src/flows/assertSuccessNotification.ts | 5 + .../grafana-e2e/src/flows/configurePanel.ts | 303 + .../grafana-e2e/src/flows/deleteDashboard.ts | 47 + .../grafana-e2e/src/flows/deleteDataSource.ts | 46 + packages/grafana-e2e/src/flows/editPanel.ts | 8 + packages/grafana-e2e/src/flows/explore.ts | 19 + packages/grafana-e2e/src/flows/index.ts | 33 + packages/grafana-e2e/src/flows/login.ts | 22 + .../grafana-e2e/src/flows/openDashboard.ts | 34 + .../src/flows/openPanelMenuItem.ts | 12 + .../grafana-e2e/src/flows/revertAllChanges.ts | 8 + .../grafana-e2e/src/flows/saveDashboard.ts | 9 + .../grafana-e2e/src/flows/selectOption.ts | 39 + .../src/flows/setDashboardTimeRange.ts | 7 + .../grafana-e2e/src/flows/setTimeRange.ts | 29 + packages/grafana-e2e/src/index.ts | 28 + packages/grafana-e2e/src/support/index.ts | 4 + .../grafana-e2e/src/support/localStorage.ts | 23 + packages/grafana-e2e/src/support/scenario.ts | 46 + .../src/support/scenarioContext.ts | 61 + packages/grafana-e2e/src/support/selector.ts | 9 + packages/grafana-e2e/src/support/types.ts | 123 + packages/grafana-e2e/src/support/url.ts | 23 + packages/grafana-e2e/src/typings/index.ts | 1 + packages/grafana-e2e/src/typings/undo.ts | 19 + .../test/cypress/integration/0.cli.ts | 3 + .../test/cypress/integration/1.api.ts | 7 + .../grafana-e2e/test/cypress/tsconfig.json | 8 + packages/grafana-e2e/tsconfig.build.json | 4 + packages/grafana-e2e/tsconfig.json | 12 + packages/grafana-runtime/.eslintrc | 5 + packages/grafana-runtime/CHANGELOG.md | 3 + packages/grafana-runtime/README.md | 5 + packages/grafana-runtime/api-extractor.json | 3 + packages/grafana-runtime/index.js | 7 + packages/grafana-runtime/package.json | 47 + packages/grafana-runtime/rollup.config.ts | 34 + packages/grafana-runtime/src/config.ts | 122 + packages/grafana-runtime/src/index.ts | 14 + .../src/measurement/collector.test.ts | 250 + .../src/measurement/collector.ts | 214 + .../grafana-runtime/src/measurement/index.ts | 3 + .../grafana-runtime/src/measurement/query.ts | 77 + .../grafana-runtime/src/measurement/types.ts | 73 + .../src/services/AngularLoader.ts | 87 + .../grafana-runtime/src/services/EchoSrv.ts | 140 + .../src/services/LocationSrv.ts | 75 + .../src/services/backendSrv.ts | 187 + .../src/services/dataSourceSrv.ts | 79 + .../grafana-runtime/src/services/index.ts | 8 + .../src/services/legacyAngularInjector.ts | 23 + packages/grafana-runtime/src/services/live.ts | 47 + .../src/services/templateSrv.ts | 40 + .../grafana-runtime/src/types/analytics.ts | 72 + packages/grafana-runtime/src/types/index.ts | 1 + .../src/utils/DataSourceWithBackend.test.ts | 76 + .../src/utils/DataSourceWithBackend.ts | 188 + .../grafana-runtime/src/utils/analytics.ts | 14 + packages/grafana-runtime/src/utils/logging.ts | 50 + packages/grafana-runtime/src/utils/plugin.ts | 32 + .../src/utils/queryResponse.test.ts | 309 + .../src/utils/queryResponse.ts | 176 + packages/grafana-runtime/tsconfig.build.json | 4 + packages/grafana-runtime/tsconfig.json | 20 + packages/grafana-toolkit/.eslintrc | 14 + packages/grafana-toolkit/CHANGELOG.md | 40 + packages/grafana-toolkit/README.md | 324 + .../grafana-toolkit/bin/grafana-toolkit.js | 49 + .../config/circleci/config.yml | 295 + .../grafana-plugin-ci-alpine/Dockerfile | 10 + .../docker/grafana-plugin-ci-alpine/README.md | 61 + .../docker/grafana-plugin-ci-alpine/build.sh | 22 + .../docker/grafana-plugin-ci-alpine/common.sh | 7 + .../grafana-plugin-ci-alpine/install/bin/cp | 7 + .../install/bin/ginstall | 73 + .../install/bin/githubRelease.js | 153 + .../scripts/deploy-common.sh | 52 + .../scripts/deploy-user.sh | 3 + .../scripts/deploy.sh | 80 + .../test/docker-compose.yml | 18 + .../grafana-plugin-ci-alpine/test/start.sh | 14 + .../docker/grafana-plugin-ci-e2e/Dockerfile | 9 + .../docker/grafana-plugin-ci-e2e/README.md | 61 + .../docker/grafana-plugin-ci-e2e/build.sh | 24 + .../docker/grafana-plugin-ci-e2e/common.sh | 9 + .../grafana-plugin-ci-e2e/install/ginstall | 73 + .../scripts/deploy-common.sh | 37 + .../scripts/deploy-slim.sh | 27 + .../scripts/deploy-user.sh | 3 + .../grafana-plugin-ci-e2e/scripts/deploy.sh | 65 + .../test/docker-compose.yml | 18 + .../grafana-plugin-ci-e2e/test/start.sh | 14 + .../docker/grafana-plugin-ci/Dockerfile | 8 + .../docker/grafana-plugin-ci/README.md | 61 + .../docker/grafana-plugin-ci/build.sh | 8 + .../docker/grafana-plugin-ci/common.sh | 7 + .../docker/grafana-plugin-ci/install/gget | 63 + .../scripts/deploy-common.sh | 38 + .../grafana-plugin-ci/scripts/deploy-user.sh | 3 + .../grafana-plugin-ci/scripts/deploy.sh | 57 + .../grafana-plugin-ci/test/docker-compose.yml | 10 + .../docker/grafana-plugin-ci/test/start.sh | 10 + packages/grafana-toolkit/package.json | 114 + packages/grafana-toolkit/src/cli/index.d.ts | 1 + packages/grafana-toolkit/src/cli/index.ts | 293 + .../src/cli/tasks/changelog.ts | 148 + .../src/cli/tasks/cherrypick.ts | 95 + .../src/cli/tasks/closeMilestone.ts | 95 + .../src/cli/tasks/component.create.ts | 86 + .../src/cli/tasks/core.start.ts | 43 + .../src/cli/tasks/nodeVersionChecker.ts | 76 + .../src/cli/tasks/package.build.ts | 91 + .../src/cli/tasks/plugin.build.ts | 139 + .../src/cli/tasks/plugin.ci.ts | 275 + .../src/cli/tasks/plugin.create.ts | 50 + .../src/cli/tasks/plugin.dev.ts | 50 + .../src/cli/tasks/plugin.sign.ts | 38 + .../src/cli/tasks/plugin.tests.ts | 8 + .../src/cli/tasks/plugin.update.ts | 22 + .../src/cli/tasks/plugin.utils.ts | 214 + .../src/cli/tasks/plugin/bundle.managed.ts | 33 + .../src/cli/tasks/plugin/bundle.ts | 78 + .../src/cli/tasks/plugin/create.ts | 190 + .../test_exclude_image_suffix_in_manifest.png | 1 + .../src/cli/tasks/plugin/tests.ts | 49 + .../src/cli/tasks/searchTestDataSetup.ts | 116 + .../grafana-toolkit/src/cli/tasks/task.ts | 22 + .../grafana-toolkit/src/cli/tasks/template.ts | 9 + .../src/cli/tasks/toolkit.build.ts | 121 + .../templates/component.test.tsx.template.ts | 12 + .../cli/templates/component.tsx.template.ts | 10 + .../src/cli/templates/docs.mdx.template.ts | 16 + .../src/cli/templates/index.ts | 4 + .../src/cli/templates/story.tsx.template.ts | 22 + .../grafana-toolkit/src/cli/tsconfig.json | 6 + .../grafana-toolkit/src/cli/utils/execTask.ts | 23 + .../src/cli/utils/githubClient.test.ts | 107 + .../src/cli/utils/githubClient.ts | 49 + .../src/cli/utils/githubRelease.test.ts | 10 + .../src/cli/utils/githubRelease.ts | 113 + .../src/cli/utils/pascalCase.ts | 3 + .../grafana-toolkit/src/cli/utils/prompt.ts | 79 + .../grafana-toolkit/src/cli/utils/rmdir.ts | 23 + .../src/cli/utils/useSpinner.ts | 16 + .../src/config/eslint.plugin.json | 6 + packages/grafana-toolkit/src/config/index.ts | 1 + .../src/config/jest.plugin.config.local.js | 8 + .../src/config/jest.plugin.config.test.ts | 34 + .../src/config/jest.plugin.config.ts | 110 + .../mocks/jestSetup/noOverrides/package.json | 1 + .../mocks/jestSetup/overrides/package.json | 11 + .../unsupportedOverrides/package.json | 5 + .../duplicates/src/styles/dark.css | 0 .../duplicates/src/styles/dark.scss | 0 .../duplicates/src/styles/light.css | 0 .../missing-theme-file/src/styles/light.css | 0 .../no-theme-files/src/styles/whatever.css | 0 .../stylesheetsSupport/ok/src/styles/dark.css | 0 .../ok/src/styles/light.css | 0 .../mocks/webpack/noOverride/package.json | 3 + .../mocks/webpack/overrides/package.json | 3 + .../mocks/webpack/overrides/webpack.config.js | 10 + .../webpack/overridesNamedExport/package.json | 3 + .../overridesNamedExport/webpack.config.js | 8 + .../webpack/unsupportedOverride/package.json | 3 + .../unsupportedOverride/webpack.config.js | 5 + .../src/config/prettier.plugin.config.json | 5 + .../src/config/prettier.plugin.rc.js | 3 + .../grafana-toolkit/src/config/styles.mock.js | 4 + .../src/config/tsconfig.plugin.json | 7 + .../src/config/tsconfig.plugin.local.json | 10 + .../src/config/utils/getPluginId.ts | 11 + .../src/config/utils/pluginValidation.test.ts | 15 + .../src/config/utils/pluginValidation.ts | 38 + .../src/config/webpack.plugin.config.test.ts | 76 + .../src/config/webpack.plugin.config.ts | 282 + .../src/config/webpack/loaders.test.ts | 46 + .../src/config/webpack/loaders.ts | 169 + packages/grafana-toolkit/src/plugins/env.ts | 145 + packages/grafana-toolkit/src/plugins/index.ts | 4 + .../grafana-toolkit/src/plugins/manifest.ts | 90 + packages/grafana-toolkit/src/plugins/types.ts | 101 + packages/grafana-toolkit/src/plugins/utils.ts | 130 + .../grafana-toolkit/src/plugins/workflow.ts | 99 + packages/grafana-toolkit/tsconfig.json | 12 + packages/grafana-ui/.eslintrc | 14 + packages/grafana-ui/.storybook/main.ts | 138 + .../grafana-ui/.storybook/manager-head.html | 1 + packages/grafana-ui/.storybook/preview.ts | 61 + .../grafana-ui/.storybook/static/fav32.png | Bin 0 -> 3606 bytes .../.storybook/static/grafana_icon.svg | 57 + .../grafana-ui/.storybook/storybookTheme.ts | 49 + packages/grafana-ui/.storybook/tsconfig.json | 15 + packages/grafana-ui/CHANGELOG.md | 169 + packages/grafana-ui/README.md | 23 + packages/grafana-ui/api-extractor.json | 3 + packages/grafana-ui/index.js | 7 + packages/grafana-ui/package.json | 125 + packages/grafana-ui/rollup.config.ts | 50 + packages/grafana-ui/src/Intro.story.mdx | 47 + .../grafana-ui/src/components/Alert/Alert.mdx | 17 + .../src/components/Alert/Alert.story.tsx | 65 + .../grafana-ui/src/components/Alert/Alert.tsx | 123 + .../components/AlphaNotice/AlphaNotice.mdx | 10 + .../AlphaNotice/AlphaNotice.story.tsx | 20 + .../components/AlphaNotice/AlphaNotice.tsx | 37 + .../grafana-ui/src/components/Badge/Badge.mdx | 5 + .../src/components/Badge/Badge.story.tsx | 30 + .../grafana-ui/src/components/Badge/Badge.tsx | 78 + .../components/BarChart/BarChart.story.tsx | 65 + .../src/components/BarChart/BarChart.tsx | 140 + .../BarChart/__snapshots__/utils.test.ts.snap | 961 + .../src/components/BarChart/bars.ts | 294 + .../src/components/BarChart/distribute.ts | 49 + .../src/components/BarChart/quadtree.ts | 113 + .../src/components/BarChart/types.ts | 52 + .../src/components/BarChart/utils.test.ts | 101 + .../src/components/BarChart/utils.ts | 190 + .../src/components/BarGauge/BarGauge.mdx | 40 + .../components/BarGauge/BarGauge.story.tsx | 124 + .../src/components/BarGauge/BarGauge.test.tsx | 229 + .../src/components/BarGauge/BarGauge.tsx | 625 + .../__snapshots__/BarGauge.test.tsx.snap | 85 + .../src/components/BigValue/BigValue.mdx | 70 + .../components/BigValue/BigValue.story.tsx | 117 + .../src/components/BigValue/BigValue.test.tsx | 41 + .../src/components/BigValue/BigValue.tsx | 94 + .../BigValue/BigValueLayout.test.tsx | 53 + .../components/BigValue/BigValueLayout.tsx | 497 + .../__snapshots__/BigValue.test.tsx.snap | 53 + .../src/components/Button/Button.mdx | 85 + .../src/components/Button/Button.story.tsx | 81 + .../src/components/Button/Button.tsx | 221 + .../src/components/Button/ButtonGroup.tsx | 44 + .../Button/FullWidthButtonContainer.tsx | 33 + .../components/Button/ToolbarButton.story.tsx | 97 + .../src/components/Button/ToolbarButton.tsx | 188 + .../components/Button/ToolbarButtonRow.tsx | 35 + .../grafana-ui/src/components/Button/index.ts | 4 + .../ButtonCascader/ButtonCascader.story.tsx | 50 + .../ButtonCascader/ButtonCascader.tsx | 65 + .../ButtonCascader/_ButtonCascader.scss | 190 + .../CallToActionCard.story.internal.tsx | 54 + .../CallToActionCard.test.tsx | 38 + .../CallToActionCard/CallToActionCard.tsx | 50 + .../CallToActionCard.test.tsx.snap | 52 + .../grafana-ui/src/components/Card/Card.mdx | 342 + .../src/components/Card/Card.story.tsx | 152 + .../src/components/Card/Card.test.tsx | 12 + .../grafana-ui/src/components/Card/Card.tsx | 364 + .../src/components/Cascader/Cascader.mdx | 12 + .../components/Cascader/Cascader.story.tsx | 95 + .../src/components/Cascader/Cascader.test.tsx | 159 + .../src/components/Cascader/Cascader.tsx | 241 + .../src/components/Cascader/optionMappings.ts | 29 + .../src/components/Chart/Tooltip.tsx | 76 + .../src/components/Chart/TooltipContainer.tsx | 115 + .../grafana-ui/src/components/Chart/index.tsx | 7 + .../ClickOutsideWrapper.mdx | 18 + .../ClickOutsideWrapper.story.tsx | 24 + .../ClickOutsideWrapper.tsx | 61 + .../ClipboardButton/ClipboardButton.mdx | 21 + .../ClipboardButton.story.internal.tsx | 58 + .../ClipboardButton/ClipboardButton.tsx | 51 + .../Collapse/CollapsableSection.mdx | 10 + .../Collapse/CollapsableSection.story.tsx | 21 + .../Collapse/CollapsableSection.tsx | 53 + .../src/components/Collapse/Collapse.mdx | 18 + .../components/Collapse/Collapse.story.tsx | 43 + .../src/components/Collapse/Collapse.tsx | 155 + .../src/components/ColorPicker/ColorInput.tsx | 96 + .../components/ColorPicker/ColorPicker.mdx | 14 + .../ColorPicker/ColorPicker.story.tsx | 79 + .../ColorPicker/ColorPicker.test.tsx | 23 + .../components/ColorPicker/ColorPicker.tsx | 121 + .../ColorPicker/ColorPickerPopover.story.tsx | 30 + .../ColorPicker/ColorPickerPopover.test.tsx | 63 + .../ColorPicker/ColorPickerPopover.tsx | 194 + .../ColorPicker/ColorPickerTrigger.tsx | 56 + .../ColorPicker/NamedColorsGroup.tsx | 111 + .../ColorPicker/NamedColorsPalette.story.tsx | 47 + .../ColorPicker/NamedColorsPalette.test.tsx | 37 + .../ColorPicker/NamedColorsPalette.tsx | 39 + .../ColorPicker/SeriesColorPickerPopover.tsx | 103 + .../ColorPicker/SpectrumPalette.story.tsx | 27 + .../ColorPicker/SpectrumPalette.tsx | 101 + .../ColorPicker/SpectrumPalettePointer.tsx | 77 + .../warnAboutColorPickerPropsDeprecation.ts | 9 + .../ConfirmButton/ConfirmButton.mdx | 31 + .../ConfirmButton/ConfirmButton.story.tsx | 90 + .../ConfirmButton/ConfirmButton.test.tsx | 34 + .../ConfirmButton/ConfirmButton.tsx | 178 + .../components/ConfirmButton/DeleteButton.tsx | 27 + .../components/ConfirmModal/ConfirmModal.mdx | 24 + .../ConfirmModal/ConfirmModal.story.tsx | 52 + .../ConfirmModal/ConfirmModal.test.tsx | 52 + .../components/ConfirmModal/ConfirmModal.tsx | 72 + .../components/ContextMenu/ContextMenu.mdx | 31 + .../ContextMenu/ContextMenu.story.tsx | 31 + .../components/ContextMenu/ContextMenu.tsx | 55 + .../ContextMenu/WithContextMenu.tsx | 38 + .../CustomScrollbar/CustomScrollbar.test.tsx | 16 + .../CustomScrollbar/CustomScrollbar.tsx | 176 + .../CustomScrollbar.test.tsx.snap | 82 + .../components/DataLinks/DataLinkButton.tsx | 35 + .../components/DataLinks/DataLinkEditor.tsx | 70 + .../components/DataLinks/DataLinkInput.tsx | 210 + .../DataLinks/DataLinkSuggestions.tsx | 143 + .../DataLinks/DataLinksContextMenu.test.tsx | 77 + .../DataLinks/DataLinksContextMenu.tsx | 53 + .../DataLinkEditorModalContent.tsx | 49 + .../DataLinksInlineEditor.tsx | 116 + .../DataLinksListItem.test.tsx | 104 + .../DataLinksListItem.tsx | 73 + .../components/DataLinks/FieldLinkList.tsx | 71 + .../DataLinks/SelectionReference.ts | 28 + .../DataSourceSettings/BasicAuthSettings.tsx | 61 + .../DataSourceSettings/CertificationKey.tsx | 40 + .../CustomHeadersSettings.test.tsx | 84 + .../CustomHeadersSettings.tsx | 221 + .../DataSourceHttpSettings.mdx | 33 + .../DataSourceHttpSettings.story.tsx | 63 + .../DataSourceHttpSettings.tsx | 235 + .../DataSourceSettings/HttpProxySettings.tsx | 45 + .../DataSourceSettings/SigV4AuthSettings.tsx | 70 + .../DataSourceSettings/TLSAuthSettings.tsx | 112 + .../components/DataSourceSettings/types.ts | 17 + .../src/components/Drawer/Drawer.mdx | 35 + .../src/components/Drawer/Drawer.story.tsx | 195 + .../src/components/Drawer/Drawer.tsx | 148 + .../src/components/Drawer/_Drawer.scss | 2 + .../Dropdown/ButtonSelect.story.internal.tsx | 45 + .../src/components/Dropdown/ButtonSelect.tsx | 90 + .../EmptySearchResult/EmptySearchResult.mdx | 17 + .../EmptySearchResult.story.tsx | 19 + .../EmptySearchResult/EmptySearchResult.tsx | 27 + .../ErrorBoundary/ErrorBoundary.mdx | 56 + .../ErrorBoundary/ErrorBoundary.story.tsx | 58 + .../ErrorBoundary/ErrorBoundary.test.tsx | 36 + .../ErrorBoundary/ErrorBoundary.tsx | 88 + .../ErrorBoundary/ErrorWithStack.tsx | 28 + .../src/components/FileUpload/FileUpload.mdx | 17 + .../FileUpload/FileUpload.story.tsx | 27 + .../components/FileUpload/FileUpload.test.tsx | 32 + .../src/components/FileUpload/FileUpload.tsx | 93 + .../src/components/FilterPill/FilterPill.mdx | 17 + .../FilterPill/FilterPill.story.tsx | 31 + .../src/components/FilterPill/FilterPill.tsx | 60 + .../FormField/FormField.story.internal.tsx | 30 + .../components/FormField/FormField.test.tsx | 37 + .../src/components/FormField/FormField.tsx | 61 + .../__snapshots__/FormField.test.tsx.snap | 37 + .../src/components/FormLabel/FormLabel.tsx | 45 + .../FormattedValueDisplay.mdx | 14 + .../FormattedValueDisplay.story.tsx | 19 + .../FormattedValueDisplay.tsx | 42 + .../src/components/Forms/Checkbox.mdx | 39 + .../src/components/Forms/Checkbox.story.tsx | 36 + .../src/components/Forms/Checkbox.tsx | 127 + .../grafana-ui/src/components/Forms/Field.mdx | 22 + .../src/components/Forms/Field.story.tsx | 62 + .../src/components/Forms/Field.test.tsx | 37 + .../grafana-ui/src/components/Forms/Field.tsx | 97 + .../src/components/Forms/FieldArray.mdx | 40 + .../src/components/Forms/FieldArray.story.tsx | 52 + .../src/components/Forms/FieldArray.tsx | 15 + .../src/components/Forms/FieldSet.mdx | 42 + .../src/components/Forms/FieldSet.story.tsx | 45 + .../src/components/Forms/FieldSet.tsx | 31 + .../Forms/FieldValidationMessage.mdx | 27 + .../Forms/FieldValidationMessage.story.tsx | 32 + .../Forms/FieldValidationMessage.tsx | 76 + .../grafana-ui/src/components/Forms/Form.mdx | 276 + .../src/components/Forms/Form.story.tsx | 214 + .../grafana-ui/src/components/Forms/Form.tsx | 50 + .../src/components/Forms/InlineField.mdx | 17 + .../components/Forms/InlineField.story.tsx | 71 + .../src/components/Forms/InlineField.test.tsx | 37 + .../src/components/Forms/InlineField.tsx | 69 + .../src/components/Forms/InlineFieldRow.mdx | 16 + .../components/Forms/InlineFieldRow.story.tsx | 41 + .../src/components/Forms/InlineFieldRow.tsx | 29 + .../src/components/Forms/InlineLabel.mdx | 17 + .../components/Forms/InlineLabel.story.tsx | 25 + .../src/components/Forms/InlineLabel.tsx | 79 + .../components/Forms/InlineSegmentGroup.tsx | 37 + .../grafana-ui/src/components/Forms/Label.mdx | 14 + .../src/components/Forms/Label.story.tsx | 25 + .../grafana-ui/src/components/Forms/Label.tsx | 75 + .../Legacy/Input/Input.story.internal.tsx | 43 + .../Forms/Legacy/Input/Input.test.tsx | 51 + .../components/Forms/Legacy/Input/Input.tsx | 86 + .../Input/__snapshots__/Input.test.tsx.snap | 15 + .../Legacy/Select/IndicatorsContainer.tsx | 17 + .../Forms/Legacy/Select/NoOptionsMessage.tsx | 24 + .../Legacy/Select/Select.story.internal.tsx | 97 + .../components/Forms/Legacy/Select/Select.tsx | 324 + .../Forms/Legacy/Select/SelectOption.test.tsx | 51 + .../Forms/Legacy/Select/SelectOption.tsx | 34 + .../Forms/Legacy/Select/_Select.scss | 206 + .../__snapshots__/SelectOption.test.tsx.snap | 28 + .../components/Forms/Legacy/Switch/Switch.mdx | 3 + .../Legacy/Switch/Switch.story.internal.tsx | 32 + .../components/Forms/Legacy/Switch/Switch.tsx | 72 + .../src/components/Forms/Legend.mdx | 23 + .../src/components/Forms/Legend.story.tsx | 27 + .../src/components/Forms/Legend.tsx | 30 + .../Forms/RadioButtonGroup/RadioButton.tsx | 123 + .../RadioButtonGroup/RadioButtonGroup.mdx | 48 + .../RadioButtonGroup.story.tsx | 75 + .../RadioButtonGroup/RadioButtonGroup.tsx | 100 + .../src/components/Forms/commonStyles.ts | 114 + .../src/components/Forms/getFormStyles.ts | 26 + .../grafana-ui/src/components/Forms/types.ts | 1 + .../src/components/Gauge/Gauge.test.tsx | 79 + .../grafana-ui/src/components/Gauge/Gauge.tsx | 256 + .../src/components/Graph/Graph.story.tsx | 149 + .../src/components/Graph/Graph.test.tsx | 175 + .../grafana-ui/src/components/Graph/Graph.tsx | 387 + .../src/components/Graph/GraphContextMenu.tsx | 126 + .../components/Graph/GraphSeriesToggler.tsx | 85 + .../Graph/GraphTooltip/GraphTooltip.tsx | 36 + .../MultiModeGraphTooltip.test.tsx | 94 + .../GraphTooltip/MultiModeGraphTooltip.tsx | 44 + .../Graph/GraphTooltip/SeriesTable.tsx | 91 + .../GraphTooltip/SingleModeGraphTooltip.tsx | 49 + .../components/Graph/GraphTooltip/types.ts | 17 + .../Graph/GraphWithLegend.story.tsx | 142 + .../src/components/Graph/GraphWithLegend.tsx | 130 + .../grafana-ui/src/components/Graph/types.ts | 17 + .../src/components/Graph/utils.test.ts | 209 + .../grafana-ui/src/components/Graph/utils.ts | 142 + .../src/components/GraphNG/GraphNG.story.tsx | 69 + .../src/components/GraphNG/GraphNG.tsx | 211 + .../GraphNG/__snapshots__/utils.test.ts.snap | 153 + .../src/components/GraphNG/hooks.ts | 41 + .../src/components/GraphNG/types.ts | 25 + .../src/components/GraphNG/utils.test.ts | 94 + .../src/components/GraphNG/utils.ts | 198 + .../grafana-ui/src/components/Icon/Icon.mdx | 22 + .../src/components/Icon/Icon.story.tsx | 94 + .../grafana-ui/src/components/Icon/Icon.tsx | 115 + .../src/components/Icon/assets/Apps.tsx | 13 + .../src/components/Icon/assets/Bell.tsx | 21 + .../src/components/Icon/assets/Circle.tsx | 17 + .../src/components/Icon/assets/Cog.tsx | 14 + .../src/components/Icon/assets/Favorite.tsx | 10 + .../src/components/Icon/assets/Folder.tsx | 10 + .../src/components/Icon/assets/FolderPlus.tsx | 21 + .../src/components/Icon/assets/Grafana.tsx | 70 + .../src/components/Icon/assets/Heart.tsx | 10 + .../src/components/Icon/assets/HeartBreak.tsx | 14 + .../src/components/Icon/assets/Import.tsx | 23 + .../src/components/Icon/assets/PanelAdd.tsx | 49 + .../src/components/Icon/assets/PlusSquare.tsx | 21 + .../components/Icon/assets/ReusablePanel.tsx | 13 + .../src/components/Icon/assets/Shield.tsx | 14 + .../components/Icon/assets/SquareShape.tsx | 17 + .../src/components/Icon/assets/index.ts | 16 + .../src/components/Icon/assets/types.ts | 7 + .../src/components/Icon/custom/index.tsx | 147 + .../src/components/IconButton/IconButton.mdx | 12 + .../IconButton/IconButton.story.tsx | 75 + .../src/components/IconButton/IconButton.tsx | 129 + .../src/components/InfoBox/FeatureInfoBox.tsx | 65 + .../src/components/InfoBox/InfoBox.mdx | 24 + .../src/components/InfoBox/InfoBox.story.tsx | 53 + .../src/components/InfoBox/InfoBox.tsx | 119 + .../src/components/InfoBox/panelArt_dark.svg | 58 + .../src/components/InfoBox/panelArt_light.svg | 64 + .../InfoTooltip.story.internal.tsx | 13 + .../components/InfoTooltip/InfoTooltip.tsx | 15 + .../grafana-ui/src/components/Input/Input.mdx | 40 + .../src/components/Input/Input.story.tsx | 109 + .../grafana-ui/src/components/Input/Input.tsx | 263 + .../src/components/InputControl.tsx | 5 + .../JSONFormatter/JSONFormatter.tsx | 53 + .../JSONFormatter/json_explorer/helpers.ts | 119 + .../json_explorer/json_explorer.ts | 429 + .../src/components/Layout/Layout.mdx | 96 + .../src/components/Layout/Layout.story.tsx | 45 + .../src/components/Layout/Layout.tsx | 159 + .../src/components/List/AbstractList.test.tsx | 50 + .../src/components/List/AbstractList.tsx | 49 + .../src/components/List/InlineList.tsx | 8 + .../components/List/List.story.internal.tsx | 70 + .../grafana-ui/src/components/List/List.tsx | 8 + .../__snapshots__/AbstractList.test.tsx.snap | 93 + .../LoadingPlaceholder/LoadingPlaceholder.mdx | 15 + .../LoadingPlaceholder.story.tsx | 19 + .../LoadingPlaceholder/LoadingPlaceholder.tsx | 32 + .../src/components/Logs/LogDetails.test.tsx | 131 + .../src/components/Logs/LogDetails.tsx | 179 + .../components/Logs/LogDetailsRow.test.tsx | 74 + .../src/components/Logs/LogDetailsRow.tsx | 181 + .../src/components/Logs/LogLabelStats.tsx | 98 + .../src/components/Logs/LogLabelStatsRow.tsx | 83 + .../src/components/Logs/LogLabels.test.tsx | 28 + .../src/components/Logs/LogLabels.tsx | 77 + .../components/Logs/LogMessageAnsi.test.tsx | 37 + .../src/components/Logs/LogMessageAnsi.tsx | 75 + .../grafana-ui/src/components/Logs/LogRow.tsx | 249 + .../components/Logs/LogRowContext.test.tsx | 23 + .../src/components/Logs/LogRowContext.tsx | 213 + .../Logs/LogRowContextProvider.test.tsx | 193 + .../components/Logs/LogRowContextProvider.tsx | 211 + .../src/components/Logs/LogRowMessage.tsx | 131 + .../Logs/LogRowMessageDetectedFields.tsx | 49 + .../src/components/Logs/LogRows.test.tsx | 169 + .../src/components/Logs/LogRows.tsx | 197 + .../src/components/Logs/getLogRowStyles.ts | 188 + .../src/components/Logs/logParser.ts | 100 + .../FieldNameByRegexMatcherEditor.tsx | 27 + .../MatchersUI/FieldNameMatcherEditor.tsx | 56 + .../MatchersUI/FieldNamesMatcherEditor.tsx | 80 + .../MatchersUI/FieldTypeMatcherEditor.tsx | 86 + .../MatchersUI/FieldsByFrameRefIdMatcher.tsx | 65 + .../components/MatchersUI/fieldMatchersUI.ts | 15 + .../src/components/MatchersUI/types.ts | 16 + .../components/Menu/Menu.story.internal.tsx | 70 + .../grafana-ui/src/components/Menu/Menu.tsx | 219 + .../grafana-ui/src/components/Modal/Modal.mdx | 17 + .../src/components/Modal/Modal.story.tsx | 111 + .../src/components/Modal/Modal.test.tsx | 27 + .../grafana-ui/src/components/Modal/Modal.tsx | 90 + .../src/components/Modal/ModalHeader.tsx | 32 + .../src/components/Modal/ModalTabContent.tsx | 20 + .../src/components/Modal/ModalTabsHeader.tsx | 39 + .../src/components/Modal/ModalsContext.tsx | 63 + .../src/components/Modal/getModalStyles.ts | 70 + .../src/components/Monaco/CodeEditor.mdx | 8 + .../Monaco/CodeEditor.story.internal.tsx | 63 + .../src/components/Monaco/CodeEditor.tsx | 109 + .../src/components/Monaco/CodeEditorLazy.tsx | 27 + .../src/components/Monaco/suggestions.test.ts | 38 + .../src/components/Monaco/suggestions.ts | 119 + .../grafana-ui/src/components/Monaco/types.ts | 77 + .../grafana-ui/src/components/Monaco/utils.ts | 17 + .../src/components/NodeGraph/Edge.tsx | 59 + .../components/NodeGraph/EdgeArrowMarker.tsx | 24 + .../src/components/NodeGraph/EdgeLabel.tsx | 65 + .../src/components/NodeGraph/Node.tsx | 168 + .../components/NodeGraph/NodeGraph.test.tsx | 188 + .../src/components/NodeGraph/NodeGraph.tsx | 250 + .../src/components/NodeGraph/ViewControls.tsx | 91 + .../src/components/NodeGraph/index.ts | 2 + .../src/components/NodeGraph/layout.ts | 213 + .../src/components/NodeGraph/types.ts | 21 + .../NodeGraph/useCategorizeFrames.ts | 25 + .../components/NodeGraph/useContextMenu.tsx | 152 + .../src/components/NodeGraph/useNodeLimit.ts | 50 + .../src/components/NodeGraph/usePanning.ts | 136 + .../src/components/NodeGraph/useZoom.ts | 91 + .../src/components/NodeGraph/utils.test.ts | 106 + .../src/components/NodeGraph/utils.ts | 254 + .../src/components/OptionsUI/color.tsx | 68 + .../src/components/OptionsUI/fieldColor.tsx | 158 + .../src/components/OptionsUI/links.tsx | 23 + .../src/components/OptionsUI/mappings.tsx | 22 + .../src/components/OptionsUI/multiSelect.tsx | 73 + .../src/components/OptionsUI/number.tsx | 55 + .../src/components/OptionsUI/select.tsx | 74 + .../src/components/OptionsUI/slider.tsx | 23 + .../src/components/OptionsUI/stats.tsx | 18 + .../src/components/OptionsUI/string.tsx | 39 + .../src/components/OptionsUI/strings.tsx | 112 + .../src/components/OptionsUI/thresholds.tsx | 28 + .../src/components/OptionsUI/units.tsx | 11 + .../PageLayout/PageToolbar.story.tsx | 53 + .../src/components/PageLayout/PageToolbar.tsx | 202 + .../src/components/Pagination/Pagination.mdx | 21 + .../Pagination/Pagination.story.tsx | 22 + .../src/components/Pagination/Pagination.tsx | 125 + .../components/PieChart/PieChart.story.tsx | 40 + .../src/components/PieChart/PieChart.tsx | 341 + .../src/components/Portal/Portal.tsx | 43 + .../QueryField/QueryField.story.tsx | 13 + .../components/QueryField/QueryField.test.tsx | 84 + .../src/components/QueryField/QueryField.tsx | 224 + .../RefreshPicker/RefreshPicker.story.tsx | 68 + .../RefreshPicker/RefreshPicker.test.tsx | 39 + .../RefreshPicker/RefreshPicker.tsx | 103 + .../RefreshPicker/_RefreshPicker.scss | 4 + .../SecretFormField.story.internal.tsx | 40 + .../SecretFormField/SecretFormField.tsx | 83 + .../src/components/Segment/Segment.story.tsx | 151 + .../src/components/Segment/Segment.tsx | 70 + .../components/Segment/SegmentAsync.story.tsx | 143 + .../src/components/Segment/SegmentAsync.tsx | 94 + .../components/Segment/SegmentInput.story.tsx | 101 + .../src/components/Segment/SegmentInput.tsx | 91 + .../src/components/Segment/SegmentSelect.tsx | 61 + .../src/components/Segment/index.ts | 6 + .../src/components/Segment/styles.ts | 21 + .../src/components/Segment/types.ts | 9 + .../components/Segment/useExpandableLabel.tsx | 38 + .../components/Select/DropdownIndicator.tsx | 11 + .../components/Select/IndicatorsContainer.tsx | 24 + .../src/components/Select/InputControl.tsx | 72 + .../src/components/Select/MultiValue.tsx | 33 + .../src/components/Select/Select.mdx | 161 + .../src/components/Select/Select.story.tsx | 289 + .../src/components/Select/Select.tsx | 33 + .../src/components/Select/SelectBase.test.tsx | 196 + .../src/components/Select/SelectBase.tsx | 356 + .../src/components/Select/SelectMenu.tsx | 69 + .../components/Select/SelectOptionGroup.tsx | 92 + .../src/components/Select/SingleValue.tsx | 92 + .../src/components/Select/ValueContainer.tsx | 36 + .../src/components/Select/getSelectStyles.ts | 113 + .../src/components/Select/mockOptions.tsx | 33 + .../components/Select/resetSelectStyles.ts | 27 + .../grafana-ui/src/components/Select/types.ts | 112 + .../src/components/Select/utils.test.ts | 84 + .../grafana-ui/src/components/Select/utils.ts | 43 + .../components/SetInterval/SetInterval.tsx | 85 + .../SingleStatBaseOptions.test.ts | 232 + .../SingleStatShared/SingleStatBaseOptions.ts | 346 + .../src/components/SingleStatShared/index.ts | 6 + .../src/components/Slider/RangeSlider.mdx | 12 + .../components/Slider/RangeSlider.story.tsx | 30 + .../components/Slider/RangeSlider.test.tsx | 17 + .../src/components/Slider/RangeSlider.tsx | 53 + .../src/components/Slider/Slider.mdx | 12 + .../src/components/Slider/Slider.story.tsx | 30 + .../src/components/Slider/Slider.test.tsx | 29 + .../src/components/Slider/Slider.tsx | 97 + .../src/components/Slider/_Slider.scss | 1 + .../src/components/Slider/styles.ts | 112 + .../grafana-ui/src/components/Slider/types.ts | 29 + .../src/components/Sparkline/Sparkline.tsx | 184 + .../src/components/Sparkline/utils.ts | 25 + .../src/components/Spinner/Spinner.mdx | 17 + .../src/components/Spinner/Spinner.story.tsx | 38 + .../src/components/Spinner/Spinner.tsx | 34 + .../StatsPicker/StatsPicker.story.tsx | 82 + .../components/StatsPicker/StatsPicker.tsx | 82 + .../src/components/Switch/Switch.mdx | 30 + .../src/components/Switch/Switch.story.tsx | 48 + .../src/components/Switch/Switch.tsx | 130 + .../TabbedContainer/TabbedContainer.tsx | 85 + .../src/components/Table/BarGaugeCell.tsx | 57 + .../src/components/Table/DefaultCell.tsx | 54 + .../src/components/Table/Filter.tsx | 57 + .../src/components/Table/FilterActions.tsx | 31 + .../src/components/Table/FilterList.tsx | 99 + .../src/components/Table/FilterPopup.tsx | 103 + .../src/components/Table/ImageCell.tsx | 14 + .../src/components/Table/JSONViewCell.tsx | 60 + .../grafana-ui/src/components/Table/Table.mdx | 10 + .../src/components/Table/Table.story.tsx | 152 + .../src/components/Table/Table.test.tsx | 169 + .../grafana-ui/src/components/Table/Table.tsx | 270 + .../src/components/Table/TableCell.tsx | 74 + .../grafana-ui/src/components/Table/styles.ts | 167 + .../grafana-ui/src/components/Table/types.ts | 51 + .../src/components/Table/utils.test.ts | 417 + .../grafana-ui/src/components/Table/utils.ts | 210 + .../TableInputCSV.story.internal.tsx | 26 + .../TableInputCSV/TableInputCSV.test.tsx | 24 + .../TableInputCSV/TableInputCSV.tsx | 117 + .../src/components/Tabs/Counter.tsx | 29 + .../grafana-ui/src/components/Tabs/Tab.tsx | 106 + .../src/components/Tabs/TabContent.tsx | 27 + .../src/components/Tabs/Tabs.story.tsx | 62 + .../src/components/Tabs/TabsBar.mdx | 21 + .../src/components/Tabs/TabsBar.tsx | 44 + .../grafana-ui/src/components/Tags/Tag.mdx | 23 + .../src/components/Tags/Tag.story.tsx | 20 + .../grafana-ui/src/components/Tags/Tag.tsx | 72 + .../src/components/Tags/TagList.mdx | 22 + .../src/components/Tags/TagList.story.tsx | 26 + .../src/components/Tags/TagList.tsx | 39 + .../src/components/TagsInput/TagItem.tsx | 56 + .../src/components/TagsInput/TagsInput.mdx | 11 + .../components/TagsInput/TagsInput.story.tsx | 33 + .../src/components/TagsInput/TagsInput.tsx | 87 + .../src/components/TextArea/TextArea.mdx | 29 + .../components/TextArea/TextArea.story.tsx | 47 + .../src/components/TextArea/TextArea.tsx | 34 + .../components/ThemeColors/Colors.story.tsx | 18 + .../src/components/ThemeColors/Colors.tsx | 75 + .../ThemeColors/ThemeColors.story.tsx | 18 + .../components/ThemeColors/ThemeColors.tsx | 132 + .../ThresholdsEditor.story.tsx | 31 + .../ThresholdsEditor.test.tsx | 218 + .../ThresholdsEditorNew/ThresholdsEditor.tsx | 334 + .../TimePicker/TimeOfDayPicker.story.tsx | 54 + .../components/TimePicker/TimeOfDayPicker.tsx | 118 + .../components/TimePicker/TimeRangeInput.mdx | 37 + .../TimePicker/TimeRangeInput.story.tsx | 93 + .../components/TimePicker/TimeRangeInput.tsx | 144 + .../TimePicker/TimeRangePicker.story.tsx | 126 + .../TimePicker/TimeRangePicker.test.tsx | 32 + .../components/TimePicker/TimeRangePicker.tsx | 210 + .../TimePickerCalendar.test.tsx | 53 + .../TimeRangePicker/TimePickerCalendar.tsx | 322 + .../TimePickerContent.test.tsx | 170 + .../TimeRangePicker/TimePickerContent.tsx | 321 + .../TimeRangePicker/TimePickerFooter.tsx | 115 + .../TimeRangePicker/TimePickerTitle.tsx | 23 + .../TimeRangePicker/TimeRangeForm.tsx | 159 + .../TimeRangePicker/TimeRangeList.tsx | 89 + .../TimeRangePicker/TimeRangeOption.tsx | 49 + .../TimePicker/TimeRangePicker/mapper.ts | 17 + .../TimePicker/TimeZonePicker.story.tsx | 38 + .../components/TimePicker/TimeZonePicker.tsx | 152 + .../TimeZonePicker/TimeZoneDescription.tsx | 53 + .../TimeZonePicker/TimeZoneGroup.tsx | 45 + .../TimeZonePicker/TimeZoneOffset.tsx | 72 + .../TimeZonePicker/TimeZoneOption.tsx | 159 + .../TimeZonePicker/TimeZoneTitle.tsx | 28 + .../TimePicker/_TimeOfDayPicker.scss | 39 + .../src/components/TimePicker/rangeOptions.ts | 37 + .../ToggleButtonGroup.story.internal.tsx | 50 + .../ToggleButtonGroup/ToggleButtonGroup.tsx | 73 + .../src/components/Tooltip/Popover.tsx | 104 + .../components/Tooltip/PopoverController.tsx | 81 + .../src/components/Tooltip/Tooltip.mdx | 22 + .../src/components/Tooltip/Tooltip.story.tsx | 28 + .../src/components/Tooltip/Tooltip.test.tsx | 18 + .../src/components/Tooltip/Tooltip.tsx | 57 + .../src/components/Tooltip/_Tooltip.scss | 148 + .../__snapshots__/Tooltip.test.tsx.snap | 12 + .../components/Typeahead/Typeahead.test.tsx | 56 + .../src/components/Typeahead/Typeahead.tsx | 238 + .../Typeahead/TypeaheadInfo.test.tsx | 12 + .../components/Typeahead/TypeaheadInfo.tsx | 49 + .../components/Typeahead/TypeaheadItem.tsx | 89 + .../src/components/UnitPicker/UnitPicker.mdx | 6 + .../UnitPicker/UnitPicker.story.tsx | 16 + .../src/components/UnitPicker/UnitPicker.tsx | 67 + .../ValueMappingsEditor/MappingRow.tsx | 123 + .../ValueMappingsEditor.story.tsx | 12 + .../ValueMappingsEditor.test.tsx | 74 + .../ValueMappingsEditor.tsx | 60 + .../components/ValuePicker/ValuePicker.mdx | 37 + .../ValuePicker/ValuePicker.story.tsx | 47 + .../components/ValuePicker/ValuePicker.tsx | 74 + .../components/VizLayout/VizLayout.story.tsx | 66 + .../src/components/VizLayout/VizLayout.tsx | 109 + .../src/components/VizLegend/SeriesIcon.tsx | 20 + .../components/VizLegend/VizLegend.story.tsx | 184 + .../src/components/VizLegend/VizLegend.tsx | 49 + .../components/VizLegend/VizLegendList.tsx | 110 + .../VizLegend/VizLegendListItem.tsx | 73 + .../VizLegend/VizLegendSeriesIcon.tsx | 32 + .../VizLegend/VizLegendStatsList.tsx | 41 + .../components/VizLegend/VizLegendTable.tsx | 112 + .../VizLegend/VizLegendTableItem.tsx | 105 + .../src/components/VizLegend/types.ts | 47 + .../components/VizRepeater/VizRepeater.tsx | 217 + packages/grafana-ui/src/components/index.scss | 8 + packages/grafana-ui/src/components/index.ts | 221 + .../components/transitions/FadeTransition.tsx | 43 + .../transitions/SlideOutTransition.tsx | 50 + .../grafana-ui/src/components/uPlot/Plot.scss | 19 + .../src/components/uPlot/Plot.test.tsx | 274 + .../grafana-ui/src/components/uPlot/Plot.tsx | 102 + .../src/components/uPlot/PlotLegend.tsx | 107 + .../grafana-ui/src/components/uPlot/config.ts | 228 + .../uPlot/config/UPlotAxisBuilder.ts | 193 + .../uPlot/config/UPlotConfigBuilder.test.ts | 490 + .../uPlot/config/UPlotConfigBuilder.ts | 180 + .../uPlot/config/UPlotScaleBuilder.test.ts | 21 + .../uPlot/config/UPlotScaleBuilder.ts | 122 + .../uPlot/config/UPlotSeriesBuilder.ts | 209 + .../components/uPlot/config/gradientFills.ts | 95 + .../src/components/uPlot/context.ts | 83 + .../uPlot/geometries/EventsCanvas.tsx | 48 + .../components/uPlot/geometries/Marker.tsx | 26 + .../components/uPlot/geometries/XYCanvas.tsx | 31 + .../src/components/uPlot/geometries/index.ts | 4 + .../src/components/uPlot/hooks.test.ts | 135 + .../grafana-ui/src/components/uPlot/hooks.ts | 187 + .../uPlot/plugins/AnnotationsEditorPlugin.tsx | 59 + .../components/uPlot/plugins/ClickPlugin.tsx | 125 + .../components/uPlot/plugins/CursorPlugin.tsx | 116 + .../uPlot/plugins/SelectionPlugin.tsx | 93 + .../uPlot/plugins/TooltipPlugin.tsx | 129 + .../components/uPlot/plugins/ZoomPlugin.tsx | 27 + .../src/components/uPlot/plugins/index.ts | 5 + .../grafana-ui/src/components/uPlot/types.ts | 34 + .../src/components/uPlot/utils.test.ts | 15 + .../grafana-ui/src/components/uPlot/utils.ts | 77 + packages/grafana-ui/src/index.scss | 1 + packages/grafana-ui/src/index.ts | 16 + .../src/slate-plugins/braces.test.tsx | 60 + .../grafana-ui/src/slate-plugins/braces.ts | 109 + .../src/slate-plugins/clear.test.tsx | 42 + .../grafana-ui/src/slate-plugins/clear.ts | 28 + .../grafana-ui/src/slate-plugins/clipboard.ts | 66 + .../src/slate-plugins/indentation.ts | 94 + .../grafana-ui/src/slate-plugins/index.ts | 9 + .../grafana-ui/src/slate-plugins/newline.ts | 39 + .../src/slate-plugins/runner.test.tsx | 21 + .../grafana-ui/src/slate-plugins/runner.ts | 20 + .../src/slate-plugins/selection_shortcuts.ts | 32 + .../slate-plugins/slate-prism/TOKEN_MARK.ts | 3 + .../src/slate-plugins/slate-prism/index.ts | 253 + .../src/slate-plugins/slate-prism/options.tsx | 77 + .../src/slate-plugins/suggestions.tsx | 328 + .../src/themes/ThemeContext.test.tsx | 73 + .../grafana-ui/src/themes/ThemeContext.tsx | 77 + .../src/themes/_variables.dark.scss.tmpl.ts | 405 + .../src/themes/_variables.light.scss.tmpl.ts | 394 + .../src/themes/_variables.scss.tmpl.ts | 237 + packages/grafana-ui/src/themes/dark.ts | 135 + packages/grafana-ui/src/themes/default.ts | 137 + packages/grafana-ui/src/themes/getTheme.ts | 15 + packages/grafana-ui/src/themes/index.ts | 8 + packages/grafana-ui/src/themes/light.ts | 136 + packages/grafana-ui/src/themes/mixins.ts | 46 + .../src/themes/selectThemeVariant.test.ts | 50 + .../src/themes/selectThemeVariant.ts | 10 + .../src/themes/stylesFactory.test.ts | 31 + .../grafana-ui/src/themes/stylesFactory.ts | 12 + packages/grafana-ui/src/types/completion.ts | 108 + packages/grafana-ui/src/types/forms.ts | 19 + packages/grafana-ui/src/types/icon.ts | 272 + packages/grafana-ui/src/types/index.ts | 6 + packages/grafana-ui/src/types/input.ts | 9 + packages/grafana-ui/src/types/jquery.d.ts | 17 + packages/grafana-ui/src/types/mdx.d.ts | 4 + packages/grafana-ui/src/types/orientation.ts | 1 + packages/grafana-ui/src/types/size.ts | 1 + packages/grafana-ui/src/types/storybook.ts | 2 + packages/grafana-ui/src/types/theme.ts | 5 + packages/grafana-ui/src/utils/ansicolor.ts | 485 + packages/grafana-ui/src/utils/children.ts | 19 + packages/grafana-ui/src/utils/colors.ts | 148 + packages/grafana-ui/src/utils/dataLinks.ts | 19 + packages/grafana-ui/src/utils/dom.ts | 41 + .../src/utils/generatedFileBanner.ts | 10 + packages/grafana-ui/src/utils/index.ts | 12 + packages/grafana-ui/src/utils/measureText.ts | 54 + .../src/utils/renderOrCallToRender.ts | 22 + packages/grafana-ui/src/utils/scrollbar.ts | 34 + packages/grafana-ui/src/utils/slate.ts | 41 + packages/grafana-ui/src/utils/squares.ts | 45 + .../grafana-ui/src/utils/standardEditors.tsx | 367 + .../utils/storybook/DashboardStoryCanvas.tsx | 21 + .../src/utils/storybook/StoryExample.tsx | 29 + .../src/utils/storybook/UseState.tsx | 42 + .../grafana-ui/src/utils/storybook/data.ts | 13 + .../grafana-ui/src/utils/storybook/knobs.ts | 16 + .../src/utils/storybook/noopControl.tsx | 1 + .../grafana-ui/src/utils/storybook/useSize.ts | 7 + .../src/utils/storybook/withCenteredStory.tsx | 34 + .../src/utils/storybook/withPaddedStory.tsx | 18 + .../utils/storybook/withRightAlignedStory.tsx | 20 + .../utils/storybook/withStoryContainer.tsx | 82 + .../src/utils/storybook/withTheme.tsx | 38 + packages/grafana-ui/src/utils/tags.ts | 89 + packages/grafana-ui/src/utils/typeahead.ts | 64 + .../src/utils/useAsyncDependency.ts | 13 + .../grafana-ui/src/utils/useClientRect.ts | 11 + .../src/utils/useComponetInstanceId.ts | 14 + .../utils/useDelayedSwitch.story.internal.tsx | 28 + .../grafana-ui/src/utils/useDelayedSwitch.ts | 55 + .../grafana-ui/src/utils/validate.test.ts | 56 + packages/grafana-ui/src/utils/validate.ts | 30 + packages/grafana-ui/tsconfig.build.json | 4 + packages/grafana-ui/tsconfig.json | 16 + packages/jaeger-ui-components/package.json | 45 + .../src/ScrollManager.test.js | 284 + .../src/ScrollManager.tsx | 274 + packages/jaeger-ui-components/src/Theme.tsx | 222 + .../SpanGraph/CanvasSpanGraph.test.js | 33 + .../SpanGraph/CanvasSpanGraph.tsx | 76 + .../SpanGraph/GraphTicks.test.js | 44 + .../TracePageHeader/SpanGraph/GraphTicks.tsx | 47 + .../SpanGraph/Scrubber.test.js | 62 + .../TracePageHeader/SpanGraph/Scrubber.tsx | 114 + .../SpanGraph/TickLabels.test.js | 59 + .../TracePageHeader/SpanGraph/TickLabels.tsx | 60 + .../SpanGraph/ViewingLayer.test.js | 350 + .../SpanGraph/ViewingLayer.tsx | 409 + .../TracePageHeader/SpanGraph/index.test.js | 76 + .../src/TracePageHeader/SpanGraph/index.tsx | 102 + .../SpanGraph/render-into-canvas.test.js | 197 + .../SpanGraph/render-into-canvas.tsx | 60 + .../TracePageHeader/TracePageHeader.test.js | 80 + .../src/TracePageHeader/TracePageHeader.tsx | 299 + .../TracePageSearchBar.markers.tsx | 15 + .../TracePageSearchBar.test.js | 92 + .../TracePageHeader/TracePageSearchBar.tsx | 146 + .../src/TracePageHeader/index.tsx | 15 + .../ListView/Positions.test.js | 244 + .../ListView/Positions.tsx | 197 + .../ListView/__snapshots__/index.test.js.snap | 98 + .../ListView/index.test.js | 238 + .../TraceTimelineViewer/ListView/index.tsx | 490 + .../ReferencesButton.test.js | 88 + .../TraceTimelineViewer/ReferencesButton.tsx | 105 + .../src/TraceTimelineViewer/SpanBar.test.js | 101 + .../src/TraceTimelineViewer/SpanBar.tsx | 217 + .../TraceTimelineViewer/SpanBarRow.test.js | 175 + .../src/TraceTimelineViewer/SpanBarRow.tsx | 523 + .../SpanDetail/AccordianKeyValues.markers.tsx | 15 + .../SpanDetail/AccordianKeyValues.test.js | 97 + .../SpanDetail/AccordianKeyValues.tsx | 166 + .../SpanDetail/AccordianLogs.test.js | 88 + .../SpanDetail/AccordianLogs.tsx | 122 + .../SpanDetail/AccordianReferences.test.js | 111 + .../SpanDetail/AccordianReferences.tsx | 155 + .../SpanDetail/AccordianText.test.js | 55 + .../SpanDetail/AccordianText.tsx | 98 + .../SpanDetail/DetailState.tsx | 93 + .../SpanDetail/KeyValuesTable.test.js | 145 + .../SpanDetail/KeyValuesTable.tsx | 177 + .../SpanDetail/TextList.test.js | 40 + .../SpanDetail/TextList.tsx | 64 + .../SpanDetail/index.test.js | 186 + .../TraceTimelineViewer/SpanDetail/index.tsx | 278 + .../TraceTimelineViewer/SpanDetailRow.test.js | 100 + .../src/TraceTimelineViewer/SpanDetailRow.tsx | 169 + .../SpanTreeOffset.test.js | 155 + .../TraceTimelineViewer/SpanTreeOffset.tsx | 171 + .../src/TraceTimelineViewer/Ticks.test.js | 25 + .../src/TraceTimelineViewer/Ticks.tsx | 98 + .../TimelineCollapser.test.js | 32 + .../TimelineHeaderRow/TimelineCollapser.tsx | 62 + .../TimelineColumnResizer.test.js | 105 + .../TimelineColumnResizer.tsx | 217 + .../TimelineHeaderRow.test.js | 107 + .../TimelineHeaderRow/TimelineHeaderRow.tsx | 116 + .../TimelineViewingLayer.test.js | 194 + .../TimelineViewingLayer.tsx | 282 + .../TimelineHeaderRow/index.tsx | 15 + .../src/TraceTimelineViewer/TimelineRow.tsx | 70 + .../VirtualizedTraceView.test.js | 396 + .../VirtualizedTraceView.tsx | 471 + .../src/TraceTimelineViewer/index.test.js | 73 + .../src/TraceTimelineViewer/index.tsx | 198 + .../src/TraceTimelineViewer/types.tsx | 62 + .../src/TraceTimelineViewer/utils.test.js | 156 + .../src/TraceTimelineViewer/utils.tsx | 115 + .../jaeger-ui-components/src/Tween.test.js | 194 + packages/jaeger-ui-components/src/Tween.tsx | 114 + .../src/common/BreakableText.tsx | 64 + .../src/common/CopyIcon.test.js | 70 + .../src/common/CopyIcon.tsx | 97 + .../src/common/ExternalLinks.tsx | 59 + .../src/common/LabeledList.tsx | 69 + .../src/common/LoadingIndicator.tsx | 78 + .../src/common/NewWindowIcon.test.js | 40 + .../src/common/NewWindowIcon.tsx | 45 + .../src/common/TraceName.tsx | 71 + .../src/common/UiFindInput.test.js | 65 + .../src/common/UiFindInput.tsx | 62 + .../__snapshots__/CopyIcon.test.js.snap | 18 + .../__snapshots__/NewWindowIcon.test.js.snap | 8 + .../__snapshots__/UiFindInput.test.js.snap | 10 + .../src/constants/default-config.tsx | 86 + .../src/constants/index.tsx | 28 + .../jaeger-ui-components/src/demo/.eslintrc | 5 + .../src/demo/trace-generators.js | 173 + packages/jaeger-ui-components/src/index.ts | 16 + .../src/keyboard-mappings.tsx | 36 + .../src/keyboard-shortcuts.tsx | 53 + .../src/model/ddg/PathElem.test.js | 186 + .../src/model/ddg/PathElem.tsx | 125 + .../ddg/__snapshots__/PathElem.test.js.snap | 45 + .../model/ddg/sample-paths.test.resources.js | 121 + .../src/model/ddg/types.tsx | 46 + .../src/model/link-patterns.test.js | 441 + .../src/model/link-patterns.tsx | 251 + .../jaeger-ui-components/src/model/span.tsx | 25 + .../src/model/trace-viewer.ts | 20 + .../src/model/transform-trace-data.test.js | 55 + .../src/model/transform-trace-data.tsx | 183 + .../src/scroll-page.test.js | 158 + .../jaeger-ui-components/src/scroll-page.tsx | 55 + .../src/selectors/process.js | 16 + .../src/selectors/process.test.js | 30 + .../src/selectors/span.js | 88 + .../src/selectors/span.test.js | 204 + .../src/selectors/trace.fixture.js | 60 + .../src/selectors/trace.js | 295 + .../src/selectors/trace.test.js | 355 + .../src/types/TDdgState.tsx | 36 + .../jaeger-ui-components/src/types/TNil.tsx | 18 + .../src/types/TTraceDiffState.tsx | 24 + .../src/types/TTraceTimeline.tsx | 28 + .../src/types/api-error.tsx | 24 + .../src/types/archive.tsx | 25 + .../jaeger-ui-components/src/types/config.tsx | 57 + .../src/types/embedded.tsx | 25 + .../jaeger-ui-components/src/types/index.tsx | 30 + .../jaeger-ui-components/src/types/search.tsx | 54 + .../jaeger-ui-components/src/types/trace.ts | 94 + .../src/uberUtilityStyles.ts | 69 + .../src/uiElementsContext.tsx | 239 + .../src/url/ReferenceLink.test.js | 72 + .../src/url/ReferenceLink.tsx | 58 + .../src/url/externalLinkContext.tsx | 24 + .../DraggableManager/DraggableManager.test.js | 299 + .../DraggableManager/DraggableManager.tsx | 224 + .../utils/DraggableManager/EUpdateTypes.tsx | 36 + .../src/utils/DraggableManager/README.md | 287 + .../DraggableManager/demo/DividerDemo.css | 44 + .../DraggableManager/demo/DividerDemo.tsx | 81 + .../demo/DraggableManagerDemo.css | 31 + .../demo/DraggableManagerDemo.tsx | 69 + .../DraggableManager/demo/RegionDemo.css | 38 + .../DraggableManager/demo/RegionDemo.tsx | 120 + .../utils/DraggableManager/demo/demo-ux.gif | Bin 0 -> 1055352 bytes .../src/utils/DraggableManager/demo/index.tsx | 15 + .../src/utils/DraggableManager/index.tsx | 17 + .../src/utils/DraggableManager/types.tsx | 35 + .../src/utils/TreeNode.js | 100 + .../src/utils/TreeNode.test.js | 293 + .../src/utils/color-generator.test.js | 38 + .../src/utils/color-generator.tsx | 92 + .../src/utils/config/get-config.tsx | 29 + .../jaeger-ui-components/src/utils/date.tsx | 123 + .../src/utils/filter-spans.test.js | 184 + .../src/utils/filter-spans.tsx | 67 + .../jaeger-ui-components/src/utils/number.tsx | 38 + .../jaeger-ui-components/src/utils/sort.js | 41 + .../src/utils/sort.test.js | 107 + .../src/utils/span-ancestor-ids.test.js | 96 + .../src/utils/span-ancestor-ids.tsx | 42 + .../src/utils/test/requestAnimationFrame.js | 40 + packages/jaeger-ui-components/tsconfig.json | 11 + .../jaeger-ui-components/typings/custom.d.ts | 35 + packaging/conf/nfpm.yaml | 36 + packaging/deb/control/postinst | 92 + packaging/deb/default/grafana-server | 24 + packaging/deb/init.d/grafana-server | 151 + packaging/deb/systemd/grafana-server.service | 32 + packaging/docker/Dockerfile | 73 + packaging/docker/README.md | 62 + packaging/docker/build-enterprise.sh | 59 + packaging/docker/build.sh | 111 + packaging/docker/custom/Dockerfile | 45 + packaging/docker/custom/ubuntu.Dockerfile | 49 + packaging/docker/push_to_docker_hub.sh | 69 + packaging/docker/run.sh | 89 + packaging/docker/ubuntu.Dockerfile | 58 + packaging/mac/bin/grafana | 28 + packaging/publish/publish_both.sh | 22 + packaging/publish/publish_testing.sh | 16 + packaging/rpm/control/postinst | 92 + packaging/rpm/control/posttrans | 32 + packaging/rpm/init.d/grafana-server | 157 + packaging/rpm/sysconfig/grafana-server | 24 + packaging/rpm/systemd/grafana-server.service | 30 + packaging/wrappers/grafana-cli | 39 + pkg/README.md | 86 + pkg/api/admin.go | 50 + pkg/api/admin_provisioning.go | 41 + pkg/api/admin_users.go | 192 + pkg/api/admin_users_test.go | 521 + pkg/api/alerting.go | 524 + pkg/api/alerting_test.go | 181 + pkg/api/annotations.go | 290 + pkg/api/annotations_test.go | 358 + pkg/api/api.go | 455 + pkg/api/apikey.go | 91 + pkg/api/app_routes.go | 64 + pkg/api/avatar/avatar.go | 247 + pkg/api/basic_auth.go | 20 + pkg/api/basic_auth_test.go | 48 + pkg/api/common_test.go | 225 + pkg/api/dashboard.go | 615 + pkg/api/dashboard_permission.go | 123 + pkg/api/dashboard_permission_test.go | 390 + pkg/api/dashboard_snapshot.go | 327 + pkg/api/dashboard_snapshot_test.go | 295 + pkg/api/dashboard_test.go | 1318 + pkg/api/dataproxy.go | 65 + pkg/api/dataproxy_test.go | 34 + pkg/api/datasource/validation.go | 65 + pkg/api/datasources.go | 476 + pkg/api/datasources_test.go | 154 + pkg/api/dtos/acl.go | 14 + pkg/api/dtos/alerting.go | 137 + pkg/api/dtos/alerting_test.go | 35 + pkg/api/dtos/annotations.go | 43 + pkg/api/dtos/apikey.go | 7 + pkg/api/dtos/dashboard.go | 58 + pkg/api/dtos/datasource.go | 64 + pkg/api/dtos/folder.go | 25 + pkg/api/dtos/index.go | 67 + pkg/api/dtos/invite.go | 26 + pkg/api/dtos/models.go | 89 + pkg/api/dtos/models_test.go | 86 + pkg/api/dtos/org.go | 14 + pkg/api/dtos/playlist.go | 24 + pkg/api/dtos/plugins.go | 68 + pkg/api/dtos/prefs.go | 13 + pkg/api/dtos/short_url.go | 10 + pkg/api/dtos/user.go | 46 + pkg/api/dtos/user_token.go | 16 + pkg/api/folder.go | 172 + pkg/api/folder_permission.go | 137 + pkg/api/folder_permission_test.go | 401 + pkg/api/folder_test.go | 244 + pkg/api/frontend_logging.go | 40 + pkg/api/frontend_logging_test.go | 314 + pkg/api/frontend_metrics.go | 21 + pkg/api/frontendlogging/sentry.go | 95 + pkg/api/frontendlogging/source_maps.go | 164 + pkg/api/frontendlogging/test-data/foo.js.map | 1 + pkg/api/frontendsettings.go | 293 + pkg/api/frontendsettings_test.go | 135 + pkg/api/grafana_com_proxy.go | 49 + pkg/api/health.go | 21 + pkg/api/health_test.go | 190 + pkg/api/http_server.go | 462 + pkg/api/http_server_test.go | 29 + pkg/api/index.go | 473 + pkg/api/ldap_debug.go | 331 + pkg/api/ldap_debug_test.go | 575 + pkg/api/login.go | 352 + pkg/api/login_oauth.go | 296 + pkg/api/login_test.go | 709 + pkg/api/metrics.go | 251 + pkg/api/org.go | 184 + pkg/api/org_invite.go | 256 + pkg/api/org_users.go | 219 + pkg/api/org_users_test.go | 126 + pkg/api/password.go | 64 + pkg/api/playlist.go | 164 + pkg/api/playlist_play.go | 97 + pkg/api/pluginproxy/access_token_provider.go | 208 + .../pluginproxy/access_token_provider_test.go | 282 + pkg/api/pluginproxy/ds_auth_provider.go | 86 + pkg/api/pluginproxy/ds_proxy.go | 330 + pkg/api/pluginproxy/ds_proxy_test.go | 880 + pkg/api/pluginproxy/pluginproxy.go | 75 + pkg/api/pluginproxy/pluginproxy_test.go | 169 + .../pluginproxy/test-data/access-token-1.json | 9 + .../pluginproxy/test-data/access-token-2.json | 9 + pkg/api/pluginproxy/utils.go | 69 + pkg/api/pluginproxy/utils_test.go | 20 + pkg/api/plugins.go | 397 + pkg/api/preferences.go | 73 + pkg/api/quota.go | 68 + pkg/api/render.go | 90 + pkg/api/response/response.go | 221 + pkg/api/routing/route_register.go | 178 + pkg/api/routing/route_register_test.go | 267 + pkg/api/routing/routing.go | 27 + pkg/api/search.go | 90 + pkg/api/short_url.go | 68 + pkg/api/signup.go | 147 + pkg/api/stars.go | 35 + pkg/api/static/static.go | 193 + pkg/api/team.go | 177 + pkg/api/team_members.go | 121 + pkg/api/team_members_test.go | 73 + pkg/api/team_test.go | 152 + pkg/api/user.go | 365 + pkg/api/user_test.go | 210 + pkg/api/user_token.go | 146 + pkg/api/user_token_test.go | 311 + pkg/api/utils.go | 9 + pkg/bus/bus.go | 222 + pkg/bus/bus_test.go | 130 + pkg/cmd/grafana-cli/commands/commands.go | 146 + .../commands/commandstest/context.go | 26 + .../commands/commandstest/fake_api_client.go | 36 + .../commands/commandstest/fake_ioutil.go | 55 + .../encrypt_datasource_passwords.go | 130 + .../encrypt_datasource_passwords_test.go | 91 + .../grafana-cli/commands/install_command.go | 345 + .../commands/install_command_test.go | 268 + .../commands/listremote_command.go | 27 + .../commands/listversions_command.go | 36 + pkg/cmd/grafana-cli/commands/ls_command.go | 54 + .../grafana-cli/commands/ls_command_test.go | 84 + .../grafana-cli/commands/remove_command.go | 33 + .../commands/reset_password_command.go | 67 + ...18fa4da8096a952608a7e4c7782b4260b41bcf.zip | Bin 0 -> 910 bytes .../testdata/plugin-with-absolute-member.zip | Bin 0 -> 124 bytes .../testdata/plugin-with-parent-member.zip | Bin 0 -> 128 bytes .../commands/testdata/plugin-with-symlink.zip | Bin 0 -> 553 bytes .../commands/upgrade_all_command.go | 64 + .../commands/upgrade_all_command_test.go | 49 + .../grafana-cli/commands/upgrade_command.go | 36 + pkg/cmd/grafana-cli/logger/logger.go | 49 + pkg/cmd/grafana-cli/main.go | 102 + pkg/cmd/grafana-cli/models/model.go | 54 + pkg/cmd/grafana-cli/services/api_client.go | 215 + .../grafana-cli/services/api_client_test.go | 99 + pkg/cmd/grafana-cli/services/io_util.go | 29 + pkg/cmd/grafana-cli/services/services.go | 117 + pkg/cmd/grafana-cli/utils/command_line.go | 63 + pkg/cmd/grafana-cli/utils/grafana_path.go | 70 + pkg/cmd/grafana-server/diagnostics.go | 78 + pkg/cmd/grafana-server/diagnostics_test.go | 73 + pkg/cmd/grafana-server/main.go | 199 + pkg/components/apikeygen/apikeygen.go | 71 + pkg/components/apikeygen/apikeygen_test.go | 25 + pkg/components/dashdiffs/compare.go | 149 + pkg/components/dashdiffs/formatter_basic.go | 422 + pkg/components/dashdiffs/formatter_json.go | 493 + pkg/components/dashdiffs/formatter_test.go | 130 + pkg/components/gtime/gtime.go | 87 + pkg/components/gtime/gtime_test.go | 102 + .../imguploader/azureblobuploader.go | 326 + .../imguploader/azureblobuploader_test.go | 26 + pkg/components/imguploader/gcs/gcsuploader.go | 230 + .../imguploader/gcs/gcsuploader_test.go | 164 + pkg/components/imguploader/imguploader.go | 157 + .../imguploader/imguploader_test.go | 187 + pkg/components/imguploader/localuploader.go | 22 + .../imguploader/localuploader_test.go | 18 + pkg/components/imguploader/s3uploader.go | 142 + pkg/components/imguploader/s3uploader_test.go | 26 + pkg/components/imguploader/webdavuploader.go | 109 + .../imguploader/webdavuploader_test.go | 40 + pkg/components/null/float.go | 141 + pkg/components/securedata/securedata.go | 16 + .../securejsondata/securejsondata.go | 53 + pkg/components/simplejson/simplejson.go | 484 + pkg/components/simplejson/simplejson_go11.go | 92 + pkg/components/simplejson/simplejson_test.go | 248 + pkg/events/events.go | 48 + pkg/expr/classic/classic.go | 160 + pkg/expr/classic/classic_test.go | 199 + pkg/expr/classic/evaluator.go | 98 + pkg/expr/classic/evaluator_test.go | 143 + pkg/expr/classic/reduce.go | 205 + pkg/expr/classic/reduce_test.go | 420 + pkg/expr/commands.go | 275 + pkg/expr/graph.go | 183 + pkg/expr/mathexp/exp.go | 539 + pkg/expr/mathexp/exp_nan_null_val_test.go | 423 + pkg/expr/mathexp/exp_scalar_no_test.go | 144 + pkg/expr/mathexp/exp_series_test.go | 383 + pkg/expr/mathexp/exp_test.go | 181 + pkg/expr/mathexp/funcs.go | 118 + pkg/expr/mathexp/funcs_test.go | 88 + pkg/expr/mathexp/parse/lex.go | 354 + pkg/expr/mathexp/parse/lex_test.go | 169 + pkg/expr/mathexp/parse/node.go | 422 + pkg/expr/mathexp/parse/parse.go | 354 + pkg/expr/mathexp/reduce.go | 101 + pkg/expr/mathexp/reduce_test.go | 240 + pkg/expr/mathexp/resample.go | 83 + pkg/expr/mathexp/resample_test.go | 260 + pkg/expr/mathexp/type_series.go | 201 + pkg/expr/mathexp/types.go | 107 + pkg/expr/mathexp/types_test.go | 168 + pkg/expr/mathexp/union_test.go | 244 + pkg/expr/nodes.go | 365 + pkg/expr/service.go | 52 + pkg/expr/service_test.go | 113 + pkg/expr/transform.go | 249 + pkg/extensions/main.go | 33 + pkg/ifaces/gcsifaces/gcsifaces.go | 44 + pkg/infra/fs/copy.go | 169 + pkg/infra/fs/copy_test.go | 173 + pkg/infra/fs/exists.go | 18 + pkg/infra/fs/exists_test.go | 31 + pkg/infra/localcache/cache.go | 19 + pkg/infra/log/file.go | 278 + pkg/infra/log/file_test.go | 48 + pkg/infra/log/handlers.go | 9 + pkg/infra/log/interface.go | 31 + pkg/infra/log/log.go | 288 + pkg/infra/log/syslog.go | 104 + pkg/infra/log/syslog_windows.go | 23 + pkg/infra/metrics/frontendmetrics.go | 43 + pkg/infra/metrics/graphitebridge/graphite.go | 411 + .../metrics/graphitebridge/graphite_test.go | 558 + pkg/infra/metrics/metrics.go | 621 + pkg/infra/metrics/metricutil/utils.go | 31 + pkg/infra/metrics/metricutil/utils_test.go | 32 + pkg/infra/metrics/service.go | 54 + pkg/infra/metrics/settings.go | 61 + pkg/infra/network/address.go | 44 + pkg/infra/network/address_test.go | 82 + pkg/infra/remotecache/database_storage.go | 141 + .../remotecache/database_storage_test.go | 75 + pkg/infra/remotecache/memcached_storage.go | 71 + .../memcached_storage_integration_test.go | 15 + pkg/infra/remotecache/redis_storage.go | 118 + .../redis_storage_integration_test.go | 16 + pkg/infra/remotecache/redis_storage_test.go | 99 + pkg/infra/remotecache/remotecache.go | 140 + pkg/infra/remotecache/remotecache_test.go | 92 + pkg/infra/remotecache/testing.go | 33 + pkg/infra/serverlock/model.go | 9 + pkg/infra/serverlock/serverlock.go | 116 + .../serverlock/serverlock_integration_test.go | 35 + pkg/infra/serverlock/serverlock_test.go | 58 + pkg/infra/tracing/tracing.go | 164 + pkg/infra/tracing/tracing_test.go | 101 + pkg/infra/usagestats/service.go | 114 + pkg/infra/usagestats/types.go | 10 + pkg/infra/usagestats/usage_stats.go | 340 + .../usagestats/usage_stats_service_test.go | 106 + pkg/infra/usagestats/usage_stats_test.go | 562 + pkg/login/auth.go | 77 + pkg/login/auth_test.go | 225 + pkg/login/brute_force_login_protection.go | 47 + .../brute_force_login_protection_test.go | 134 + pkg/login/grafana_login.go | 42 + pkg/login/grafana_login_test.go | 133 + pkg/login/ldap_login.go | 106 + pkg/login/ldap_login_test.go | 162 + pkg/login/social/azuread_oauth.go | 131 + pkg/login/social/azuread_oauth_test.go | 271 + pkg/login/social/common.go | 102 + pkg/login/social/generic_oauth.go | 448 + pkg/login/social/generic_oauth_test.go | 603 + pkg/login/social/github_oauth.go | 253 + pkg/login/social/gitlab_oauth.go | 130 + pkg/login/social/google_oauth.go | 46 + pkg/login/social/grafana_com_oauth.go | 80 + pkg/login/social/okta_oauth.go | 153 + pkg/login/social/social.go | 291 + pkg/middleware/auth.go | 185 + pkg/middleware/auth_test.go | 145 + pkg/middleware/cookies/cookies.go | 71 + pkg/middleware/csp.go | 49 + pkg/middleware/dashboard_redirect.go | 84 + pkg/middleware/dashboard_redirect_test.go | 97 + pkg/middleware/gziper.go | 41 + pkg/middleware/logger.go | 76 + pkg/middleware/middleware.go | 79 + pkg/middleware/middleware_basic_auth_test.go | 135 + pkg/middleware/middleware_test.go | 649 + pkg/middleware/org_redirect.go | 49 + pkg/middleware/org_redirect_test.go | 61 + pkg/middleware/quota.go | 28 + pkg/middleware/quota_test.go | 246 + pkg/middleware/rate_limit.go | 25 + pkg/middleware/rate_limit_test.go | 91 + pkg/middleware/recovery.go | 168 + pkg/middleware/recovery_test.go | 92 + pkg/middleware/request_metrics.go | 260 + pkg/middleware/request_tracing.go | 36 + pkg/middleware/testing.go | 118 + pkg/middleware/validate_host.go | 28 + pkg/mocks/mock_gcsifaces/mocks.go | 236 + pkg/models/address.go | 10 + pkg/models/alert.go | 226 + pkg/models/alert_notifications.go | 176 + pkg/models/alert_test.go | 62 + pkg/models/apikey.go | 68 + pkg/models/context.go | 80 + pkg/models/dashboard_acl.go | 114 + pkg/models/dashboard_acl_test.go | 26 + pkg/models/dashboard_snapshot.go | 102 + pkg/models/dashboard_version.go | 86 + pkg/models/dashboards.go | 463 + pkg/models/dashboards_test.go | 86 + pkg/models/datasource.go | 257 + pkg/models/datasource_cache.go | 334 + pkg/models/datasource_cache_test.go | 386 + pkg/models/folders.go | 105 + pkg/models/health.go | 3 + pkg/models/helpflags.go | 16 + pkg/models/licensing.go | 27 + pkg/models/live.go | 28 + pkg/models/login_attempt.go | 36 + pkg/models/measurement.go | 39 + pkg/models/models.go | 14 + pkg/models/notifications.go | 49 + pkg/models/org.go | 90 + pkg/models/org_user.go | 119 + pkg/models/playlist.go | 96 + pkg/models/plugin_setting_cache.go | 23 + pkg/models/plugin_setting_cache_test.go | 70 + pkg/models/plugin_settings.go | 79 + pkg/models/preferences.go | 48 + pkg/models/quotas.go | 86 + pkg/models/shorturl.go | 26 + pkg/models/star.go | 40 + pkg/models/stats.go | 100 + pkg/models/tags.go | 60 + pkg/models/tags_test.go | 96 + pkg/models/team.go | 99 + pkg/models/team_member.go | 78 + pkg/models/temp_user.go | 103 + pkg/models/test_data.go | 18 + pkg/models/user.go | 266 + pkg/models/user_auth.go | 128 + pkg/models/user_token.go | 75 + pkg/models/validations.go | 12 + pkg/plugins/app_plugin.go | 123 + pkg/plugins/backend_utils.go | 19 + .../backendplugin/coreplugin/core_plugin.go | 82 + .../coreplugin/core_plugin_test.go | 67 + .../coreplugin/query_endpoint_adapter.go | 113 + .../backendplugin/grpcplugin/client.go | 123 + .../backendplugin/grpcplugin/client_v1.go | 84 + .../backendplugin/grpcplugin/client_v2.go | 179 + .../backendplugin/grpcplugin/grpc_plugin.go | 136 + .../backendplugin/grpcplugin/log_wrapper.go | 163 + .../grpcplugin/log_wrapper_test.go | 29 + pkg/plugins/backendplugin/instrumentation.go | 82 + pkg/plugins/backendplugin/manager.go | 460 + pkg/plugins/backendplugin/manager_test.go | 433 + pkg/plugins/backendplugin/plugin.go | 30 + pkg/plugins/backendplugin/plugin_settings.go | 39 + .../backendplugin/plugin_settings_test.go | 46 + .../pluginextensionv2/generate.sh | 16 + .../pluginextensionv2/renderer_grpc_plugin.go | 35 + .../pluginextensionv2/rendererv2.pb.go | 334 + .../pluginextensionv2/rendererv2.proto | 29 + .../backendplugin/resource_response_stream.go | 57 + pkg/plugins/dashboard_importer.go | 188 + pkg/plugins/dashboard_importer_test.go | 103 + pkg/plugins/dashboards.go | 120 + pkg/plugins/dashboards_test.go | 68 + pkg/plugins/dashboards_updater.go | 131 + .../wrapper/datasource_plugin_wrapper.go | 170 + .../wrapper/datasource_plugin_wrapper_test.go | 86 + .../wrapper/datasource_plugin_wrapper_v2.go | 133 + pkg/plugins/datasource_plugin.go | 78 + pkg/plugins/error.go | 14 + pkg/plugins/frontend_plugin.go | 87 + pkg/plugins/frontend_plugin_test.go | 33 + pkg/plugins/manifest.go | 225 + pkg/plugins/manifest_test.go | 122 + pkg/plugins/models.go | 216 + pkg/plugins/panel_plugin.go | 25 + pkg/plugins/plugins.go | 541 + pkg/plugins/plugins_test.go | 290 + pkg/plugins/queries.go | 94 + pkg/plugins/renderer_plugin.go | 66 + .../nested/nested/plugin.json | 14 + .../duplicate-plugins/nested/plugin.json | 14 + .../invalid-v1-signature/plugin/MANIFEST.txt | 1 + .../invalid-v1-signature/plugin/plugin.json | 14 + .../plugin/MANIFEST.txt | 27 + .../invalid-v2-signature-2/plugin/extraFile | 0 .../invalid-v2-signature-2/plugin/plugin.json | 16 + .../invalid-v2-signature/plugin/MANIFEST.txt | 28 + .../invalid-v2-signature/plugin/plugin.json | 16 + .../lacking-files/plugin/MANIFEST.txt | 24 + .../testdata/lacking-files/plugin/plugin.json | 14 + .../test-app/dashboards/connections.json | 29 + .../dashboards/connections_result.json | 21 + .../testdata/test-app/dashboards/memory.json | 5 + pkg/plugins/testdata/test-app/plugin.json | 55 + .../testdata/unsigned/plugin/plugin.json | 14 + .../plugin/MANIFEST.txt | 30 + .../valid-v2-pvt-signature/plugin/plugin.json | 16 + .../valid-v2-signature/plugin/MANIFEST.txt | 27 + .../valid-v2-signature/plugin/plugin.json | 16 + pkg/plugins/update_checker.go | 127 + pkg/registry/di.go | 45 + pkg/registry/registry.go | 153 + pkg/ruleguard.rules.go | 499 + pkg/server/server.go | 326 + pkg/services/alerting/alerting_usage.go | 112 + pkg/services/alerting/alerting_usage_test.go | 132 + pkg/services/alerting/commands.go | 36 + pkg/services/alerting/conditions/evaluator.go | 157 + .../alerting/conditions/evaluator_test.go | 62 + pkg/services/alerting/conditions/query.go | 385 + .../alerting/conditions/query_test.go | 370 + pkg/services/alerting/conditions/reducer.go | 175 + .../alerting/conditions/reducer_test.go | 411 + pkg/services/alerting/engine.go | 232 + .../alerting/engine_integration_test.go | 153 + pkg/services/alerting/engine_test.go | 127 + pkg/services/alerting/eval_context.go | 247 + pkg/services/alerting/eval_context_test.go | 342 + pkg/services/alerting/eval_handler.go | 74 + pkg/services/alerting/eval_handler_test.go | 208 + pkg/services/alerting/extractor.go | 247 + pkg/services/alerting/extractor_test.go | 301 + pkg/services/alerting/interfaces.go | 63 + pkg/services/alerting/models.go | 44 + pkg/services/alerting/notifier.go | 311 + pkg/services/alerting/notifier_test.go | 390 + .../alerting/notifiers/alertmanager.go | 205 + .../alerting/notifiers/alertmanager_test.go | 137 + pkg/services/alerting/notifiers/base.go | 148 + pkg/services/alerting/notifiers/base_test.go | 223 + pkg/services/alerting/notifiers/dingding.go | 158 + .../alerting/notifiers/dingding_test.go | 59 + pkg/services/alerting/notifiers/discord.go | 225 + .../alerting/notifiers/discord_test.go | 53 + pkg/services/alerting/notifiers/email.go | 128 + pkg/services/alerting/notifiers/email_test.go | 78 + pkg/services/alerting/notifiers/googlechat.go | 229 + .../alerting/notifiers/googlechat_test.go | 51 + pkg/services/alerting/notifiers/hipchat.go | 186 + .../alerting/notifiers/hipchat_test.go | 79 + pkg/services/alerting/notifiers/kafka.go | 133 + pkg/services/alerting/notifiers/kafka_test.go | 53 + pkg/services/alerting/notifiers/line.go | 99 + pkg/services/alerting/notifiers/line_test.go | 47 + pkg/services/alerting/notifiers/opsgenie.go | 196 + .../alerting/notifiers/opsgenie_test.go | 103 + pkg/services/alerting/notifiers/pagerduty.go | 246 + .../alerting/notifiers/pagerduty_test.go | 485 + pkg/services/alerting/notifiers/pushover.go | 413 + .../alerting/notifiers/pushover_test.go | 99 + pkg/services/alerting/notifiers/sensu.go | 153 + pkg/services/alerting/notifiers/sensu_test.go | 55 + pkg/services/alerting/notifiers/sensugo.go | 204 + .../alerting/notifiers/sensugo_test.go | 57 + pkg/services/alerting/notifiers/slack.go | 397 + pkg/services/alerting/notifiers/slack_test.go | 226 + pkg/services/alerting/notifiers/teams.go | 144 + pkg/services/alerting/notifiers/teams_test.go | 73 + pkg/services/alerting/notifiers/telegram.go | 278 + .../alerting/notifiers/telegram_test.go | 126 + pkg/services/alerting/notifiers/threema.go | 165 + .../alerting/notifiers/threema_test.go | 124 + pkg/services/alerting/notifiers/victorops.go | 139 + .../alerting/notifiers/victorops_test.go | 51 + pkg/services/alerting/notifiers/webhook.go | 141 + .../alerting/notifiers/webhook_test.go | 47 + pkg/services/alerting/reader.go | 48 + pkg/services/alerting/result_handler.go | 114 + pkg/services/alerting/rule.go | 212 + pkg/services/alerting/rule_test.go | 223 + pkg/services/alerting/scheduler.go | 84 + pkg/services/alerting/test_notification.go | 118 + pkg/services/alerting/test_rule.go | 62 + .../alerting/testdata/collapsed-panels.json | 597 + .../alerting/testdata/dash-without-id.json | 284 + .../alerting/testdata/graphite-alert.json | 64 + .../alerting/testdata/influxdb-alert.json | 285 + .../alerting/testdata/panel-with-id-0.json | 63 + .../panel-without-specified-datasource.json | 185 + .../alerting/testdata/panels-missing-id.json | 62 + .../alerting/testdata/settings/empty.json | 1 + .../testdata/settings/one_condition.json | 38 + .../testdata/settings/three_conditions.json | 94 + .../testdata/settings/two_conditions.json | 66 + .../alerting/testdata/v5-dashboard.json | 60 + pkg/services/alerting/ticker.go | 76 + pkg/services/alerting/ticker_test.go | 121 + pkg/services/annotations/annotations.go | 115 + pkg/services/auth/auth_token.go | 457 + pkg/services/auth/auth_token_test.go | 586 + pkg/services/auth/model.go | 73 + pkg/services/auth/testing.go | 106 + pkg/services/auth/token_cleanup.go | 68 + pkg/services/auth/token_cleanup_test.go | 70 + pkg/services/cleanup/cleanup.go | 170 + pkg/services/cleanup/cleanup_test.go | 41 + .../contexthandler/auth_proxy_test.go | 137 + .../contexthandler/authproxy/authproxy.go | 363 + .../authproxy/authproxy_test.go | 223 + pkg/services/contexthandler/contexthandler.go | 448 + .../contexthandler/contexthandler_test.go | 124 + pkg/services/dashboards/acl_service.go | 56 + pkg/services/dashboards/dashboard_service.go | 392 + .../dashboards/dashboard_service_test.go | 328 + pkg/services/dashboards/folder_service.go | 243 + .../dashboards/folder_service_test.go | 203 + pkg/services/datasources/cache.go | 103 + pkg/services/datasources/log.go | 9 + pkg/services/guardian/guardian.go | 305 + pkg/services/guardian/guardian_test.go | 728 + pkg/services/guardian/guardian_util_test.go | 278 + pkg/services/hooks/hooks.go | 44 + pkg/services/ldap/helpers.go | 57 + pkg/services/ldap/ldap.go | 592 + pkg/services/ldap/ldap_helpers_test.go | 191 + pkg/services/ldap/ldap_login_test.go | 234 + pkg/services/ldap/ldap_private_test.go | 273 + pkg/services/ldap/ldap_test.go | 227 + pkg/services/ldap/settings.go | 179 + pkg/services/ldap/settings_test.go | 24 + pkg/services/ldap/testdata/ldap.toml | 27 + pkg/services/ldap/testing.go | 95 + pkg/services/librarypanels/api.go | 135 + pkg/services/librarypanels/database.go | 667 + pkg/services/librarypanels/guard.go | 39 + pkg/services/librarypanels/librarypanels.go | 280 + .../librarypanels_connections_test.go | 97 + .../librarypanels_create_test.go | 104 + .../librarypanels_delete_test.go | 44 + .../librarypanels_get_all_test.go | 494 + .../librarypanels/librarypanels_get_test.go | 89 + .../librarypanels/librarypanels_patch_test.go | 272 + .../librarypanels_permissions_test.go | 478 + .../librarypanels/librarypanels_test.go | 911 + pkg/services/librarypanels/models.go | 139 + pkg/services/licensing/oss.go | 66 + pkg/services/live/channel.go | 43 + pkg/services/live/channel_test.go | 28 + pkg/services/live/features/broadcast.go | 38 + pkg/services/live/features/dashboard.go | 74 + pkg/services/live/features/measurements.go | 35 + pkg/services/live/features/testdata.go | 113 + pkg/services/live/live.go | 271 + pkg/services/live/pluginHandler.go | 27 + pkg/services/login/errors.go | 9 + pkg/services/login/login.go | 261 + pkg/services/login/login_test.go | 191 + pkg/services/multildap/multildap.go | 252 + pkg/services/multildap/multildap_test.go | 520 + pkg/services/ngalert/alert_definition.go | 22 + pkg/services/ngalert/api.go | 240 + pkg/services/ngalert/common_test.go | 80 + pkg/services/ngalert/database.go | 296 + pkg/services/ngalert/database_mig.go | 109 + pkg/services/ngalert/database_test.go | 416 + pkg/services/ngalert/eval/alert_query.go | 288 + pkg/services/ngalert/eval/alert_query_test.go | 297 + pkg/services/ngalert/eval/eval.go | 237 + pkg/services/ngalert/fetcher.go | 15 + pkg/services/ngalert/instance.go | 96 + pkg/services/ngalert/instance_api.go | 17 + pkg/services/ngalert/instance_database.go | 115 + .../ngalert/instance_database_test.go | 163 + pkg/services/ngalert/instance_labels.go | 105 + pkg/services/ngalert/middleware.go | 21 + pkg/services/ngalert/models.go | 115 + pkg/services/ngalert/ngalert.go | 118 + pkg/services/ngalert/schedule.go | 370 + pkg/services/ngalert/schedule_test.go | 217 + pkg/services/ngalert/validator.go | 79 + pkg/services/notifications/codes.go | 108 + pkg/services/notifications/codes_test.go | 37 + pkg/services/notifications/email.go | 36 + pkg/services/notifications/mailer.go | 231 + pkg/services/notifications/notifications.go | 230 + .../notifications/notifications_test.go | 37 + .../send_email_integration_test.go | 68 + pkg/services/notifications/webhook.go | 101 + pkg/services/oauthtoken/oauth_token_util.go | 91 + .../provisioning/dashboards/config_reader.go | 118 + .../dashboards/config_reader_test.go | 120 + .../provisioning/dashboards/dashboard.go | 135 + .../provisioning/dashboards/dashboard_mock.go | 65 + .../provisioning/dashboards/file_reader.go | 463 + .../dashboards/file_reader_linux_test.go | 40 + .../dashboards/file_reader_test.go | 499 + .../applied-defaults/dev-dashboards.yaml | 6 + .../broken-configs/commented.yaml | 6 + .../dashboards-from-disk/dev-dashboards.yaml | 18 + .../dashboards-from-disk/sample.yaml | 10 + .../test-configs/version-0/version-0.yaml | 15 + .../broken-dashboards/empty-json.json | 0 .../broken-dashboards/invalid.json | 6 + .../containing-id/dashboard1.json | 68 + .../folder-one/dashboard1.json | 172 + .../folder-one/dashboard2.json | 172 + .../folderOne/dashboard1.json | 172 + .../folderTwo/dashboard2.json | 172 + .../folders-from-files-structure/root.json | 172 + .../one-dashboard/dashboard1.json | 173 + .../testdata/test-dashboards/symlink | 1 + .../unprovision/dashboard1.json | 172 + pkg/services/provisioning/dashboards/types.go | 128 + .../provisioning/datasources/config_reader.go | 139 + .../datasources/config_reader_test.go | 297 + .../provisioning/datasources/datasources.go | 98 + .../all-properties/all-properties.yaml | 31 + .../testdata/all-properties/not.yaml.txt | 0 .../testdata/all-properties/sample.yaml | 32 + .../testdata/all-properties/second.yaml | 7 + .../appliedDefaults/without-defaults.yaml | 5 + .../testdata/broken-yaml/broken.yaml | 6 + .../testdata/broken-yaml/commented.yaml | 48 + .../testdata/double-default/default-1.yaml | 7 + .../testdata/double-default/default-2.yaml | 7 + .../one-datasources.yaml | 7 + .../insert-two-delete-two/two-datasources.yml | 7 + .../invalid-access/invalid-access.yaml | 6 + .../testdata/multiple-org-default/config.yaml | 25 + .../two-datasources/two-datasources.yaml | 9 + .../testdata/version-0/version-0.yaml | 28 + .../zero-datasources/placeholder-for-git | 0 .../provisioning/datasources/types.go | 272 + .../notifiers/alert_notifications.go | 144 + .../provisioning/notifiers/config_reader.go | 174 + .../notifiers/config_reader_test.go | 327 + .../test-configs/broken-yaml/broken.yaml | 9 + .../test-configs/broken-yaml/not.yaml.text | 6 + .../correct-properties-with-orgName.yaml | 12 + .../correct-properties.yaml | 46 + .../test-configs/double-default/default-1.yml | 7 + .../double-default/default-2.yaml | 7 + .../testdata/test-configs/empty/empty.yaml | 0 .../test-configs/empty_folder/.gitignore | 4 + .../incorrect-settings.yaml | 10 + .../no-required-fields.yaml | 35 + .../two-notifications/two-notifications.yaml | 12 + .../unknown-notifier/notification.yaml | 4 + pkg/services/provisioning/notifiers/types.go | 107 + .../provisioning/plugins/config_reader.go | 136 + .../plugins/config_reader_test.go | 86 + .../plugins/plugin_provisioner.go | 85 + .../plugins/plugin_provisioner_test.go | 95 + .../test-configs/broken-yaml/broken.yaml | 4 + .../test-configs/broken-yaml/not.yaml.text | 6 + .../correct-properties.yaml | 10 + .../test-configs/empty_folder/.gitignore | 4 + .../incorrect-settings.yaml | 3 + .../test-configs/unknown-app/unknown-app.yaml | 2 + pkg/services/provisioning/plugins/types.go | 57 + pkg/services/provisioning/provisioning.go | 172 + .../provisioning/provisioning_mock.go | 74 + .../provisioning/provisioning_test.go | 132 + pkg/services/provisioning/utils/utils.go | 20 + pkg/services/provisioning/utils/utils_test.go | 29 + pkg/services/provisioning/values/values.go | 281 + .../provisioning/values/values_test.go | 312 + pkg/services/quota/quota.go | 157 + pkg/services/rendering/http_mode.go | 124 + pkg/services/rendering/interface.go | 40 + pkg/services/rendering/plugin_mode.go | 104 + pkg/services/rendering/rendering.go | 283 + pkg/services/rendering/rendering_test.go | 59 + pkg/services/search/hits.go | 45 + pkg/services/search/service.go | 135 + pkg/services/search/service_test.go | 57 + pkg/services/search/sorting.go | 58 + pkg/services/shorturls/short_url_service.go | 91 + .../shorturls/short_url_service_test.go | 85 + pkg/services/sqlstore/alert.go | 387 + pkg/services/sqlstore/alert_notification.go | 602 + .../sqlstore/alert_notification_test.go | 479 + pkg/services/sqlstore/alert_test.go | 358 + pkg/services/sqlstore/annotation.go | 270 + pkg/services/sqlstore/annotation_cleanup.go | 114 + .../sqlstore/annotation_cleanup_test.go | 229 + pkg/services/sqlstore/annotation_test.go | 255 + pkg/services/sqlstore/apikey.go | 102 + pkg/services/sqlstore/apikey_test.go | 139 + pkg/services/sqlstore/dashboard.go | 761 + pkg/services/sqlstore/dashboard_acl.go | 125 + pkg/services/sqlstore/dashboard_acl_test.go | 244 + .../sqlstore/dashboard_folder_test.go | 474 + .../sqlstore/dashboard_provisioning.go | 115 + .../sqlstore/dashboard_provisioning_test.go | 153 + .../dashboard_service_integration_test.go | 1039 + pkg/services/sqlstore/dashboard_snapshot.go | 133 + .../sqlstore/dashboard_snapshot_test.go | 190 + pkg/services/sqlstore/dashboard_test.go | 516 + pkg/services/sqlstore/dashboard_version.go | 134 + .../sqlstore/dashboard_version_test.go | 173 + pkg/services/sqlstore/database_wrapper.go | 106 + pkg/services/sqlstore/datasource.go | 302 + pkg/services/sqlstore/datasource_test.go | 382 + pkg/services/sqlstore/health.go | 15 + pkg/services/sqlstore/health_test.go | 18 + pkg/services/sqlstore/logger.go | 114 + pkg/services/sqlstore/login_attempt.go | 93 + pkg/services/sqlstore/login_attempt_test.go | 127 + pkg/services/sqlstore/migrations/alert_mig.go | 215 + .../sqlstore/migrations/annotation_mig.go | 214 + .../sqlstore/migrations/apikey_mig.go | 85 + .../sqlstore/migrations/cache_data_mig.go | 22 + pkg/services/sqlstore/migrations/common.go | 47 + .../sqlstore/migrations/dashboard_acl.go | 52 + .../sqlstore/migrations/dashboard_mig.go | 228 + .../migrations/dashboard_snapshot_mig.go | 74 + .../migrations/dashboard_version_mig.go | 59 + .../sqlstore/migrations/datasource_mig.go | 157 + .../sqlstore/migrations/heartbeat_mig.go | 18 + .../sqlstore/migrations/login_attempt_mig.go | 42 + .../sqlstore/migrations/migrations.go | 71 + .../sqlstore/migrations/migrations_test.go | 49 + pkg/services/sqlstore/migrations/org_mig.go | 68 + .../sqlstore/migrations/playlist_mig.go | 46 + .../sqlstore/migrations/plugin_setting.go | 40 + .../sqlstore/migrations/preferences_mig.go | 45 + pkg/services/sqlstore/migrations/quota_mig.go | 31 + .../migrations/serverlock_migrations.go | 22 + .../sqlstore/migrations/session_mig.go | 16 + .../sqlstore/migrations/short_url_mig.go | 27 + pkg/services/sqlstore/migrations/stats_mig.go | 57 + pkg/services/sqlstore/migrations/tag_mig.go | 23 + pkg/services/sqlstore/migrations/team_mig.go | 63 + pkg/services/sqlstore/migrations/temp_user.go | 113 + .../sqlstore/migrations/user_auth_mig.go | 45 + .../migrations/user_auth_token_mig.go | 47 + pkg/services/sqlstore/migrations/user_mig.go | 167 + pkg/services/sqlstore/migrator/column.go | 23 + pkg/services/sqlstore/migrator/conditions.go | 48 + pkg/services/sqlstore/migrator/dialect.go | 290 + pkg/services/sqlstore/migrator/migrations.go | 232 + pkg/services/sqlstore/migrator/migrator.go | 193 + .../sqlstore/migrator/mysql_dialect.go | 226 + .../sqlstore/migrator/postgres_dialect.go | 254 + .../sqlstore/migrator/sqlite_dialect.go | 187 + pkg/services/sqlstore/migrator/types.go | 115 + pkg/services/sqlstore/org.go | 359 + pkg/services/sqlstore/org_test.go | 334 + pkg/services/sqlstore/org_users.go | 225 + .../sqlstore/permissions/dashboard.go | 77 + pkg/services/sqlstore/playlist.go | 157 + pkg/services/sqlstore/playlist_test.go | 69 + pkg/services/sqlstore/plugin_setting.go | 113 + pkg/services/sqlstore/preferences.go | 111 + pkg/services/sqlstore/preferences_test.go | 116 + pkg/services/sqlstore/quota.go | 247 + pkg/services/sqlstore/quota_test.go | 237 + pkg/services/sqlstore/searchstore/builder.go | 133 + pkg/services/sqlstore/searchstore/doc.go | 35 + pkg/services/sqlstore/searchstore/filters.go | 150 + .../sqlstore/searchstore/search_test.go | 205 + pkg/services/sqlstore/session.go | 84 + pkg/services/sqlstore/sql_test_data.go | 67 + pkg/services/sqlstore/sqlbuilder.go | 95 + pkg/services/sqlstore/sqlbuilder_test.go | 346 + pkg/services/sqlstore/sqlstore.go | 525 + pkg/services/sqlstore/sqlstore.goconvey | 1 + pkg/services/sqlstore/sqlstore_test.go | 109 + pkg/services/sqlstore/sqlutil/sqlutil.go | 67 + pkg/services/sqlstore/star.go | 70 + pkg/services/sqlstore/stars_test.go | 42 + pkg/services/sqlstore/stats.go | 273 + .../sqlstore/stats_integration_test.go | 18 + pkg/services/sqlstore/stats_test.go | 123 + pkg/services/sqlstore/tables.go | 9 + pkg/services/sqlstore/tags.go | 26 + pkg/services/sqlstore/tags_test.go | 28 + pkg/services/sqlstore/team.go | 464 + pkg/services/sqlstore/team_test.go | 327 + pkg/services/sqlstore/temp_user.go | 147 + pkg/services/sqlstore/temp_user_test.go | 88 + pkg/services/sqlstore/tls_mysql.go | 43 + pkg/services/sqlstore/transactions.go | 84 + pkg/services/sqlstore/transactions_test.go | 56 + pkg/services/sqlstore/user.go | 775 + pkg/services/sqlstore/user_auth.go | 284 + pkg/services/sqlstore/user_auth_test.go | 225 + pkg/services/sqlstore/user_test.go | 635 + pkg/services/teamguardian/team.go | 34 + pkg/services/teamguardian/teams_test.go | 88 + pkg/services/validations/oss.go | 15 + pkg/setting/date_formats.go | 57 + pkg/setting/date_formats_test.go | 36 + pkg/setting/dynamic_settings_test.go | 39 + pkg/setting/expanders.go | 149 + pkg/setting/expanders_test.go | 99 + pkg/setting/setting.go | 1394 + pkg/setting/setting_oauth.go | 26 + pkg/setting/setting_plugins.go | 25 + pkg/setting/setting_plugins_test.go | 43 + pkg/setting/setting_quota.go | 91 + pkg/setting/setting_sentry.go | 22 + pkg/setting/setting_session_test.go | 44 + pkg/setting/setting_smtp.go | 37 + pkg/setting/setting_test.go | 429 + pkg/setting/testdata/invalid.ini | 3 + pkg/setting/testdata/override.ini | 3 + pkg/setting/testdata/override_windows.ini | 3 + pkg/setting/testdata/session.ini | 2 + pkg/tests/README.md | 3 + pkg/tests/api/metrics/README.md | 3 + pkg/tests/api/metrics/api_metrics_test.go | 212 + pkg/tests/testinfra/testinfra.go | 212 + pkg/tests/web/index_view_test.go | 64 + .../applicationinsights-datasource.go | 317 + .../applicationinsights-datasource_test.go | 256 + .../applicationinsights-metrics.go | 314 + .../applicationinsights-metrics_test.go | 192 + .../azure-log-analytics-datasource.go | 330 + .../azure-log-analytics-datasource_test.go | 157 + .../azure-log-analytics-table-frame.go | 235 + .../azure-log-analytics-table-frame_test.go | 173 + .../azuremonitor/azuremonitor-datasource.go | 437 + .../azuremonitor-datasource_test.go | 511 + pkg/tsdb/azuremonitor/azuremonitor-time.go | 41 + pkg/tsdb/azuremonitor/azuremonitor.go | 126 + .../insights-analytics-datasource.go | 235 + pkg/tsdb/azuremonitor/macros.go | 152 + pkg/tsdb/azuremonitor/macros_test.go | 151 + ...plication-insights-response-raw-query.json | 27 + ...insights-response-raw-query-segmented.json | 43 + ...ghts-response-metrics-multi-segmented.json | 72 + ...-insights-response-metrics-no-segment.json | 23 + ...n-insights-response-metrics-segmented.json | 45 + ...5-application-insights-empty-response.json | 1 + .../testdata/applicationinsights/bigger.json | 1 + .../1-azure-monitor-response-avg.json | 47 + .../2-azure-monitor-response-total.json | 47 + .../3-azure-monitor-response-maximum.json | 47 + .../4-azure-monitor-response-minimum.json | 47 + .../5-azure-monitor-response-count.json | 47 + ...ure-monitor-response-single-dimension.json | 128 + ...zure-monitor-response-multi-dimension.json | 119 + ...ure-monitor-response-unspecified-unit.json | 31 + ...lytics-response-metrics-single-series.json | 38 + ...tics-response-metrics-multiple-series.json | 68 + ...ics-response-metrics-no-metric-column.json | 31 + ...ytics-response-metrics-no-time-column.json | 31 + ...tics-response-metrics-no-value-column.json | 31 + .../6-log-analytics-response-table.json | 87 + .../7-log-analytics-all-types-table.json | 64 + .../8-log-analytics-response-nan-inf.json | 29 + pkg/tsdb/azuremonitor/time-grain.go | 52 + pkg/tsdb/azuremonitor/time-grain_test.go | 56 + pkg/tsdb/azuremonitor/types.go | 182 + pkg/tsdb/azuremonitor/url-builder.go | 35 + pkg/tsdb/azuremonitor/url-builder_test.go | 60 + pkg/tsdb/cloudmonitoring/annotation_query.go | 97 + .../cloudmonitoring/annotation_query_test.go | 60 + pkg/tsdb/cloudmonitoring/cloudmonitoring.go | 590 + .../cloudmonitoring/cloudmonitoring_test.go | 1200 + .../1-series-response-agg-one-metric.json | 46 + .../test-data/2-series-response-no-agg.json | 145 + ...ies-response-distribution-exponential.json | 112 + ...series-response-distribution-explicit.json | 209 + .../5-series-response-meta-data.json | 78 + .../test-data/6-series-response-slo.json | 17 + .../test-data/7-series-response-mql.json | 61 + .../cloudmonitoring/time_series_filter.go | 388 + pkg/tsdb/cloudmonitoring/time_series_query.go | 374 + pkg/tsdb/cloudmonitoring/types.go | 191 + pkg/tsdb/cloudwatch/annotation_query.go | 198 + pkg/tsdb/cloudwatch/cloudwatch.go | 375 + pkg/tsdb/cloudwatch/cloudwatch_query.go | 71 + pkg/tsdb/cloudwatch/cloudwatch_query_test.go | 143 + .../cloudwatch/get_metric_data_executor.go | 36 + .../get_metric_data_executor_test.go | 48 + pkg/tsdb/cloudwatch/live.go | 313 + pkg/tsdb/cloudwatch/log_actions.go | 342 + pkg/tsdb/cloudwatch/log_actions_test.go | 518 + pkg/tsdb/cloudwatch/log_query.go | 226 + pkg/tsdb/cloudwatch/log_query_test.go | 488 + pkg/tsdb/cloudwatch/logs.go | 63 + .../cloudwatch/metric_data_input_builder.go | 26 + .../cloudwatch/metric_data_query_builder.go | 135 + .../metric_data_query_builder_test.go | 214 + pkg/tsdb/cloudwatch/metric_find_query.go | 810 + pkg/tsdb/cloudwatch/metric_find_query_test.go | 549 + pkg/tsdb/cloudwatch/query_transformer.go | 224 + pkg/tsdb/cloudwatch/query_transformer_test.go | 249 + pkg/tsdb/cloudwatch/request_parser.go | 163 + pkg/tsdb/cloudwatch/request_parser_test.go | 212 + pkg/tsdb/cloudwatch/response_parser.go | 233 + pkg/tsdb/cloudwatch/response_parser_test.go | 322 + pkg/tsdb/cloudwatch/sort_frame.go | 41 + pkg/tsdb/cloudwatch/sort_frame_test.go | 57 + pkg/tsdb/cloudwatch/test_utils.go | 192 + pkg/tsdb/cloudwatch/time_series_query.go | 129 + pkg/tsdb/cloudwatch/time_series_query_test.go | 23 + pkg/tsdb/cloudwatch/types.go | 67 + pkg/tsdb/elasticsearch/client/client.go | 329 + pkg/tsdb/elasticsearch/client/client_test.go | 399 + .../elasticsearch/client/index_pattern.go | 365 + .../client/index_pattern_test.go | 288 + pkg/tsdb/elasticsearch/client/models.go | 316 + .../elasticsearch/client/search_request.go | 458 + .../client/search_request_test.go | 473 + pkg/tsdb/elasticsearch/elasticsearch.go | 46 + pkg/tsdb/elasticsearch/models.go | 100 + pkg/tsdb/elasticsearch/response_parser.go | 583 + .../elasticsearch/response_parser_test.go | 1032 + pkg/tsdb/elasticsearch/time_series_query.go | 400 + .../elasticsearch/time_series_query_test.go | 1033 + pkg/tsdb/fake_test.go | 45 + pkg/tsdb/frame_util.go | 37 + pkg/tsdb/graphite/graphite.go | 240 + pkg/tsdb/graphite/graphite_test.go | 61 + pkg/tsdb/graphite/types.go | 8 + pkg/tsdb/influxdb/flux/builder.go | 317 + pkg/tsdb/influxdb/flux/builder_test.go | 25 + pkg/tsdb/influxdb/flux/executor.go | 117 + pkg/tsdb/influxdb/flux/executor_test.go | 267 + pkg/tsdb/influxdb/flux/flux.go | 109 + pkg/tsdb/influxdb/flux/macros.go | 39 + pkg/tsdb/influxdb/flux/macros_test.go | 54 + pkg/tsdb/influxdb/flux/query_models.go | 94 + pkg/tsdb/influxdb/flux/testdata/aggregate.csv | 7 + .../flux/testdata/aggregate.golden.txt | 18 + pkg/tsdb/influxdb/flux/testdata/boolean.csv | 9 + .../influxdb/flux/testdata/boolean.golden.txt | 58 + pkg/tsdb/influxdb/flux/testdata/buckets.csv | 7 + .../influxdb/flux/testdata/buckets.golden.txt | 18 + pkg/tsdb/influxdb/flux/testdata/grouping.csv | 21 + .../flux/testdata/grouping.golden.txt | 58 + .../testdata/max_data_points_exceeded.csv | 28 + pkg/tsdb/influxdb/flux/testdata/multiple.csv | 19 + .../flux/testdata/multiple.golden.txt | 47 + .../testdata/non_standard_time_column.csv | 5 + .../non_standard_time_column.golden.txt | 16 + pkg/tsdb/influxdb/flux/testdata/renamed.csv | 24 + .../influxdb/flux/testdata/renamed.golden.txt | 25 + pkg/tsdb/influxdb/flux/testdata/simple.csv | 6 + .../influxdb/flux/testdata/simple.golden.txt | 17 + pkg/tsdb/influxdb/flux/testdata/single.csv | 5 + .../influxdb/flux/testdata/single.golden.txt | 16 + pkg/tsdb/influxdb/flux/testdata/table.csv | 6 + .../influxdb/flux/testdata/table.golden.txt | 16 + pkg/tsdb/influxdb/influxdb.go | 177 + pkg/tsdb/influxdb/influxdb_test.go | 63 + pkg/tsdb/influxdb/model_parser.go | 165 + pkg/tsdb/influxdb/model_parser_test.go | 178 + pkg/tsdb/influxdb/models.go | 49 + pkg/tsdb/influxdb/query.go | 199 + pkg/tsdb/influxdb/query_part.go | 152 + pkg/tsdb/influxdb/query_part_test.go | 44 + pkg/tsdb/influxdb/query_test.go | 203 + pkg/tsdb/influxdb/response_parser.go | 149 + pkg/tsdb/influxdb/response_parser_test.go | 201 + pkg/tsdb/interval.go | 222 + pkg/tsdb/interval_test.go | 92 + pkg/tsdb/loki/loki.go | 182 + pkg/tsdb/loki/loki_test.go | 87 + pkg/tsdb/loki/types.go | 12 + pkg/tsdb/models.go | 272 + pkg/tsdb/mssql/macros.go | 134 + pkg/tsdb/mssql/macros_test.go | 220 + pkg/tsdb/mssql/mssql.go | 152 + pkg/tsdb/mssql/mssql_test.go | 1166 + pkg/tsdb/mysql/macros.go | 140 + pkg/tsdb/mysql/macros_test.go | 188 + pkg/tsdb/mysql/mysql.go | 155 + pkg/tsdb/mysql/mysql_test.go | 1071 + pkg/tsdb/opentsdb/opentsdb.go | 213 + pkg/tsdb/opentsdb/opentsdb_test.go | 173 + pkg/tsdb/opentsdb/types.go | 12 + pkg/tsdb/postgres/locker.go | 85 + pkg/tsdb/postgres/locker_test.go | 63 + pkg/tsdb/postgres/macros.go | 162 + pkg/tsdb/postgres/macros_test.go | 225 + pkg/tsdb/postgres/postgres.go | 182 + pkg/tsdb/postgres/postgres_test.go | 1151 + pkg/tsdb/postgres/tlsmanager.go | 228 + pkg/tsdb/postgres/tlsmanager_test.go | 290 + pkg/tsdb/prometheus/prometheus.go | 234 + pkg/tsdb/prometheus/prometheus_test.go | 128 + pkg/tsdb/prometheus/types.go | 12 + pkg/tsdb/query_endpoint.go | 23 + pkg/tsdb/request.go | 26 + pkg/tsdb/request_test.go | 67 + pkg/tsdb/sqleng/sql_engine.go | 722 + pkg/tsdb/sqleng/sql_engine_test.go | 312 + pkg/tsdb/tempo/tempo.go | 144 + pkg/tsdb/tempo/tempo_test.go | 23 + pkg/tsdb/testdatasource/resource_handler.go | 157 + pkg/tsdb/testdatasource/scenarios.go | 1016 + pkg/tsdb/testdatasource/scenarios_test.go | 214 + pkg/tsdb/testdatasource/testdata.go | 42 + pkg/tsdb/testdatasource/utils.go | 22 + pkg/tsdb/time_range.go | 132 + pkg/tsdb/time_range_test.go | 252 + pkg/util/encoding.go | 73 + pkg/util/encoding_test.go | 34 + pkg/util/encryption.go | 79 + pkg/util/encryption_test.go | 30 + pkg/util/errutil/errors.go | 24 + pkg/util/filepath.go | 131 + pkg/util/ip_address.go | 57 + pkg/util/ip_address_test.go | 34 + pkg/util/json.go | 4 + pkg/util/math.go | 9 + pkg/util/md5.go | 26 + pkg/util/md5_test.go | 17 + pkg/util/proxyutil/proxyutil.go | 44 + pkg/util/proxyutil/proxyutil_test.go | 67 + pkg/util/retryer/retryer.go | 56 + pkg/util/retryer/retryer_test.go | 22 + pkg/util/shortid_generator.go | 26 + pkg/util/shortid_generator_test.go | 11 + pkg/util/split_email.go | 14 + pkg/util/split_email_test.go | 52 + pkg/util/strings.go | 85 + pkg/util/strings_test.go | 95 + pkg/util/url.go | 52 + pkg/util/url_test.go | 81 + pkg/util/validation.go | 19 + pkg/util/validation_test.go | 38 + plugins-bundled/.gitignore | 9 + plugins-bundled/README.md | 6 + plugins-bundled/external.json | 3 + .../internal/input-datasource/LICENSE | 202 + .../internal/input-datasource/README.md | 3 + .../internal/input-datasource/jest.config.js | 8 + .../internal/input-datasource/package.json | 26 + .../src/InputConfigEditor.tsx | 69 + .../src/InputDatasource.test.ts | 56 + .../input-datasource/src/InputDatasource.ts | 123 + .../input-datasource/src/InputQueryEditor.tsx | 90 + .../input-datasource/src/img/input.svg | 14 + .../internal/input-datasource/src/module.ts | 11 + .../internal/input-datasource/src/plugin.json | 21 + .../input-datasource/src/testHelpers.ts | 27 + .../internal/input-datasource/src/types.ts | 11 + .../internal/input-datasource/src/utils.ts | 8 + .../internal/input-datasource/tsconfig.json | 9 + public/app/app.ts | 239 + public/app/core/actions/cleanUp.ts | 11 + public/app/core/actions/index.ts | 5 + public/app/core/angular_wrappers.ts | 189 + public/app/core/app_events.ts | 5 + .../app/core/components/Animations/FadeIn.tsx | 44 + .../core/components/Animations/SlideDown.tsx | 51 + .../AppNotifications/AppNotificationItem.tsx | 35 + .../AppNotifications/AppNotificationList.tsx | 62 + .../app/core/components/Branding/Branding.tsx | 53 + public/app/core/components/Card/Card.tsx | 38 + .../CopyToClipboard/CopyToClipboard.tsx | 78 + .../DynamicImports/ErrorLoadingChunk.tsx | 35 + .../LoadingChunkPlaceHolder.tsx | 10 + .../DynamicImports/SafeDynamicImport.test.tsx | 36 + .../DynamicImports/SafeDynamicImport.tsx | 27 + .../components/EmptyListCTA/EmptyListCTA.tsx | 100 + .../core/components/ErrorPage/ErrorPage.tsx | 90 + .../components/FilterInput/FilterInput.tsx | 24 + public/app/core/components/Footer/Footer.tsx | 88 + .../ForgottenPassword/ChangePassword.tsx | 63 + .../ForgottenPassword/ChangePasswordPage.tsx | 16 + .../ForgottenPassword/ForgottenPassword.tsx | 68 + .../ForgottenPassword/SendResetMailPage.tsx | 14 + .../LayoutSelector/LayoutSelector.tsx | 27 + .../LocalStorageValueProvider.tsx | 41 + .../LocalStorageValueProvider/index.tsx | 1 + .../app/core/components/Login/LoginCtrl.tsx | 162 + .../app/core/components/Login/LoginForm.tsx | 60 + .../app/core/components/Login/LoginLayout.tsx | 129 + .../app/core/components/Login/LoginPage.tsx | 80 + .../components/Login/LoginServiceButtons.tsx | 151 + .../app/core/components/Login/UserSignup.tsx | 27 + .../OrgActionBar/OrgActionBar.test.tsx | 24 + .../components/OrgActionBar/OrgActionBar.tsx | 37 + .../__snapshots__/OrgActionBar.test.tsx.snap | 28 + public/app/core/components/OrgSwitcher.tsx | 78 + public/app/core/components/Page/Page.tsx | 58 + .../app/core/components/Page/PageContents.tsx | 14 + .../components/PageHeader/PageHeader.test.tsx | 45 + .../core/components/PageHeader/PageHeader.tsx | 147 + .../core/components/PageLoader/PageLoader.tsx | 17 + .../PermissionList/AddPermission.tsx | 145 + .../DisabledPermissionListItem.tsx | 43 + .../PermissionList/PermissionList.tsx | 61 + .../PermissionList/PermissionListItem.tsx | 100 + .../PermissionList/PermissionsInfo.tsx | 13 + .../core/components/PluginHelp/PluginHelp.tsx | 82 + .../QueryOperationAction.test.tsx | 23 + .../QueryOperationAction.tsx | 44 + .../QueryOperationRow.test.tsx | 139 + .../QueryOperationRow/QueryOperationRow.tsx | 169 + .../components/Select/DashboardPicker.tsx | 46 + .../components/Select/DataSourcePicker.tsx | 151 + .../components/Select/FolderPicker.test.tsx | 30 + .../core/components/Select/FolderPicker.tsx | 166 + .../components/Select/MetricSelect.test.tsx | 49 + .../core/components/Select/MetricSelect.tsx | 66 + .../app/core/components/Select/OrgPicker.tsx | 69 + .../app/core/components/Select/SortPicker.tsx | 38 + .../components/Select/TeamPicker.test.tsx | 23 + .../app/core/components/Select/TeamPicker.tsx | 78 + .../components/Select/UserPicker.test.tsx | 14 + .../app/core/components/Select/UserPicker.tsx | 79 + .../__snapshots__/FolderPicker.test.tsx.snap | 18 + .../SharedPreferences/SharedPreferences.tsx | 181 + public/app/core/components/Signup/Signup.tsx | 126 + .../app/core/components/Signup/SignupPage.tsx | 15 + .../core/components/Signup/VerifyEmail.tsx | 62 + .../components/Signup/VerifyEmailPage.tsx | 22 + .../SplitPaneWrapper/SplitPaneWrapper.tsx | 175 + .../core/components/TagFilter/TagBadge.tsx | 33 + .../core/components/TagFilter/TagFilter.tsx | 135 + .../core/components/TagFilter/TagOption.tsx | 47 + .../core/components/TagFilter/TagValue.tsx | 25 + .../TimePicker/TimePickerWithHistory.tsx | 57 + .../core/components/TraceToLogsSettings.tsx | 72 + .../CalculateFieldTransformerEditor.tsx | 385 + .../ConcatenateTransformerEditor.tsx | 92 + .../FilterByNameTransformerEditor.tsx | 211 + .../FilterByRefIdTransformerEditor.tsx | 139 + .../FilterByValueFilterEditor.tsx | 169 + .../FilterByValueTransformerEditor.tsx | 182 + .../ValueMatchers/BasicMatcherEditor.tsx | 104 + .../ValueMatchers/NoopMatcherEditor.tsx | 22 + .../ValueMatchers/RangeMatcherEditor.tsx | 81 + .../ValueMatchers/types.ts | 14 + .../ValueMatchers/utils.ts | 34 + .../ValueMatchers/valueMatchersUI.ts | 9 + .../GroupByTransformerEditor.tsx | 145 + .../LabelsToFieldsTransformerEditor.tsx | 65 + .../TransformersUI/MergeTransformerEditor.tsx | 20 + .../OrganizeFieldsTransformerEditor.tsx | 231 + .../ReduceTransformerEditor.tsx | 108 + .../RenameByRegexTransformer.tsx | 131 + .../SeriesToFieldsTransformerEditor.tsx | 49 + .../SeriesToRowsTransformerEditor.tsx | 20 + .../SortByTransformerEditor.tsx | 68 + .../app/core/components/WithFeatureToggle.tsx | 13 + .../core/components/code_editor/brace.d.ts | 4 + .../components/code_editor/code_editor.ts | 197 + .../code_editor/theme-grafana-dark.js | 117 + .../components/colorpicker/spectrum_picker.ts | 24 + .../core/components/connectWithCleanUp.tsx | 40 + .../app/core/components/dashboard_selector.ts | 36 + .../components/form_dropdown/form_dropdown.ts | 276 + public/app/core/components/help/HelpModal.tsx | 101 + public/app/core/components/info_popover.ts | 68 + .../app/core/components/jsontree/jsontree.ts | 29 + .../components/modals/AngularModalProxy.tsx | 16 + .../core/components/query_part/query_part.ts | 118 + .../query_part/query_part_editor.ts | 185 + public/app/core/components/scroll/scroll.ts | 49 + .../core/components/search/search_results.ts | 0 .../sidemenu/BottomNavLinks.test.tsx | 100 + .../components/sidemenu/BottomNavLinks.tsx | 107 + .../sidemenu/BottomSection.test.tsx | 44 + .../components/sidemenu/BottomSection.tsx | 30 + .../sidemenu/DropDownChild.test.tsx | 35 + .../components/sidemenu/DropDownChild.tsx | 27 + .../components/sidemenu/SideMenu.test.tsx | 65 + .../app/core/components/sidemenu/SideMenu.tsx | 33 + .../sidemenu/SideMenuDropDown.test.tsx | 50 + .../components/sidemenu/SideMenuDropDown.tsx | 32 + .../core/components/sidemenu/SignIn.test.tsx | 11 + .../app/core/components/sidemenu/SignIn.tsx | 33 + .../components/sidemenu/TopSection.test.tsx | 41 + .../core/components/sidemenu/TopSection.tsx | 29 + .../sidemenu/TopSectionItem.test.tsx | 25 + .../components/sidemenu/TopSectionItem.tsx | 25 + .../BottomNavLinks.test.tsx.snap | 176 + .../__snapshots__/BottomSection.test.tsx.snap | 34 + .../__snapshots__/DropDownChild.test.tsx.snap | 22 + .../__snapshots__/SideMenu.test.tsx.snap | 37 + .../SideMenuDropDown.test.tsx.snap | 106 + .../__snapshots__/SignIn.test.tsx.snap | 41 + .../__snapshots__/TopSection.test.tsx.snap | 51 + .../TopSectionItem.test.tsx.snap | 83 + .../core/components/sidemenu/utils.test.ts | 25 + public/app/core/components/sidemenu/utils.ts | 8 + .../app/core/components/sql_part/sql_part.ts | 74 + .../components/sql_part/sql_part_editor.ts | 196 + public/app/core/components/switch.ts | 90 + public/app/core/config.ts | 18 + public/app/core/constants.ts | 14 + public/app/core/controllers/all.ts | 4 + public/app/core/controllers/invited_ctrl.ts | 53 + .../app/core/controllers/json_editor_ctrl.ts | 20 + .../core/controllers/reset_password_ctrl.ts | 64 + public/app/core/controllers/signup_ctrl.ts | 66 + public/app/core/copy/appNotification.ts | 55 + public/app/core/core.ts | 64 + public/app/core/core_module.ts | 18 + public/app/core/directives/array_join.ts | 29 + .../app/core/directives/autofill_event_fix.ts | 35 + public/app/core/directives/diff-view.ts | 72 + .../app/core/directives/dropdown_typeahead.ts | 274 + public/app/core/directives/give_focus.ts | 29 + public/app/core/directives/metric_segment.ts | 266 + public/app/core/directives/misc.ts | 216 + .../app/core/directives/ng_model_on_blur.ts | 59 + .../app/core/directives/rebuild_on_change.ts | 73 + public/app/core/directives/tags.ts | 118 + public/app/core/filters/filters.ts | 59 + public/app/core/hooks/useNavModel.ts | 9 + public/app/core/hooks/useRefMounted.ts | 12 + public/app/core/injectorMonkeyPatch.ts | 50 + public/app/core/jquery_extended.ts | 52 + public/app/core/lodash_extended.ts | 11 + public/app/core/logs_model.test.ts | 767 + public/app/core/logs_model.ts | 482 + public/app/core/middlewares/application.ts | 29 + public/app/core/mod_defs.d.ts | 14 + public/app/core/nav_model_srv.ts | 78 + public/app/core/partials.ts | 4 + public/app/core/profiler.ts | 30 + .../app/core/reducers/appNotification.test.ts | 161 + public/app/core/reducers/appNotification.ts | 44 + public/app/core/reducers/application.test.ts | 16 + public/app/core/reducers/application.ts | 17 + public/app/core/reducers/index.ts | 11 + public/app/core/reducers/location.test.ts | 154 + public/app/core/reducers/location.ts | 46 + public/app/core/reducers/navModel.test.ts | 80 + public/app/core/reducers/navModel.ts | 90 + public/app/core/reducers/processsAclItems.ts | 32 + public/app/core/reducers/root.test.ts | 97 + public/app/core/reducers/root.ts | 83 + public/app/core/selectors/location.ts | 7 + public/app/core/selectors/navModel.ts | 53 + public/app/core/services/AngularLoader.ts | 46 + public/app/core/services/FetchQueue.test.ts | 156 + public/app/core/services/FetchQueue.ts | 94 + .../core/services/FetchQueueWorker.test.ts | 97 + public/app/core/services/FetchQueueWorker.ts | 57 + .../app/core/services/ResponseQueue.test.ts | 98 + public/app/core/services/ResponseQueue.ts | 40 + .../core/services/__mocks__/backend_srv.ts | 43 + .../app/core/services/__mocks__/search_srv.ts | 22 + public/app/core/services/alert_srv.ts | 12 + public/app/core/services/all.ts | 10 + public/app/core/services/analytics.ts | 45 + public/app/core/services/backend_srv.ts | 418 + public/app/core/services/bridge_srv.test.ts | 76 + public/app/core/services/bridge_srv.ts | 152 + public/app/core/services/context_srv.ts | 93 + .../core/services/dynamic_directive_srv.ts | 55 + public/app/core/services/echo/Echo.ts | 90 + public/app/core/services/echo/EchoSrv.ts | 17 + .../echo/backends/PerformanceBackend.ts | 47 + .../backends/sentry/SentryBackend.test.ts | 132 + .../echo/backends/sentry/SentryBackend.ts | 59 + .../CustomEndpointTransport.test.ts | 64 + .../transports/CustomEndpointTransport.ts | 118 + .../sentry/transports/EchoSrvTransport.ts | 14 + .../services/echo/backends/sentry/types.ts | 14 + public/app/core/services/impression_srv.ts | 48 + public/app/core/services/keybindingSrv.ts | 375 + public/app/core/services/ng_react.ts | 316 + public/app/core/services/popover_srv.ts | 81 + public/app/core/services/search_srv.ts | 166 + public/app/core/services/segment_srv.ts | 123 + public/app/core/services/timer.ts | 31 + public/app/core/services/util_srv.ts | 116 + public/app/core/specs/OrgSwitcher.test.tsx | 48 + public/app/core/specs/backend_srv.test.ts | 608 + public/app/core/specs/factors.test.ts | 8 + public/app/core/specs/flatten.test.ts | 22 + public/app/core/specs/rangeutil.test.ts | 116 + public/app/core/specs/search_srv.test.ts | 302 + public/app/core/specs/store.test.ts | 44 + public/app/core/specs/table_model.test.ts | 251 + public/app/core/specs/ticks.test.ts | 34 + public/app/core/specs/time_series.test.ts | 453 + public/app/core/store.ts | 59 + public/app/core/table_model.ts | 177 + public/app/core/time_series2.ts | 368 + public/app/core/utils/CancelablePromise.ts | 22 + public/app/core/utils/ConfigProvider.tsx | 35 + public/app/core/utils/UserProvider.tsx | 200 + public/app/core/utils/acl.ts | 32 + public/app/core/utils/applyStateChanges.ts | 7 + public/app/core/utils/arrayMove.ts | 4 + public/app/core/utils/browser.test.ts | 74 + public/app/core/utils/browser.ts | 31 + public/app/core/utils/colors.ts | 5 + .../app/core/utils/connectWithReduxStore.tsx | 25 + public/app/core/utils/css_loader.ts | 77 + public/app/core/utils/dag.test.ts | 120 + public/app/core/utils/dag.ts | 214 + public/app/core/utils/deferred.ts | 14 + public/app/core/utils/docsLinks.ts | 11 + public/app/core/utils/errors.test.ts | 55 + public/app/core/utils/errors.ts | 17 + public/app/core/utils/explore.test.ts | 472 + public/app/core/utils/explore.ts | 500 + public/app/core/utils/factors.ts | 5 + public/app/core/utils/fetch.test.ts | 175 + public/app/core/utils/fetch.ts | 155 + public/app/core/utils/flatten.ts | 37 + public/app/core/utils/kbn.test.ts | 75 + public/app/core/utils/kbn.ts | 171 + public/app/core/utils/model_utils.ts | 9 + public/app/core/utils/outline.ts | 34 + public/app/core/utils/promiseToDigest.test.ts | 27 + public/app/core/utils/promiseToDigest.ts | 3 + public/app/core/utils/query.test.ts | 30 + public/app/core/utils/query.ts | 37 + public/app/core/utils/react2angular.ts | 11 + public/app/core/utils/richHistory.test.ts | 217 + public/app/core/utils/richHistory.ts | 389 + public/app/core/utils/shortLinks.test.ts | 29 + public/app/core/utils/shortLinks.ts | 36 + public/app/core/utils/sort_by_keys.ts | 17 + public/app/core/utils/standardTransformers.ts | 34 + public/app/core/utils/ticks.ts | 214 + public/app/core/utils/timePicker.test.ts | 79 + public/app/core/utils/timePicker.ts | 38 + public/app/core/utils/version.ts | 47 + public/app/dev.ts | 11 + public/app/features/admin/AdminEditOrgCtrl.ts | 60 + .../app/features/admin/AdminEditOrgPage.tsx | 110 + .../app/features/admin/AdminListOrgsPage.tsx | 54 + public/app/features/admin/AdminOrgsTable.tsx | 57 + public/app/features/admin/AdminSettings.tsx | 77 + public/app/features/admin/LicenseChrome.tsx | 94 + public/app/features/admin/OrgRolePicker.tsx | 20 + .../app/features/admin/ServerStats.test.tsx | 27 + public/app/features/admin/ServerStats.tsx | 71 + public/app/features/admin/UpgradePage.tsx | 212 + public/app/features/admin/UserAdminPage.tsx | 200 + public/app/features/admin/UserCreatePage.tsx | 86 + .../app/features/admin/UserLdapSyncInfo.tsx | 70 + .../app/features/admin/UserListAdminPage.tsx | 162 + public/app/features/admin/UserOrgs.tsx | 257 + public/app/features/admin/UserPermissions.tsx | 102 + public/app/features/admin/UserProfile.tsx | 330 + public/app/features/admin/UserSessions.tsx | 102 + public/app/features/admin/index.ts | 16 + .../admin/ldap/LdapConnectionStatus.tsx | 86 + public/app/features/admin/ldap/LdapPage.tsx | 153 + .../app/features/admin/ldap/LdapSyncInfo.tsx | 62 + .../features/admin/ldap/LdapUserGroups.tsx | 69 + .../app/features/admin/ldap/LdapUserInfo.tsx | 39 + .../admin/ldap/LdapUserMappingInfo.tsx | 46 + .../admin/ldap/LdapUserPermissions.tsx | 51 + .../app/features/admin/ldap/LdapUserTeams.tsx | 62 + .../features/admin/partials/admin_home.html | 12 + .../app/features/admin/partials/edit_org.html | 52 + public/app/features/admin/partials/stats.html | 54 + .../features/admin/partials/styleguide.html | 27 + public/app/features/admin/state/actions.ts | 276 + public/app/features/admin/state/apis.ts | 33 + .../app/features/admin/state/reducers.test.ts | 294 + public/app/features/admin/state/reducers.ts | 175 + .../features/alerting/AlertRuleItem.test.tsx | 41 + .../app/features/alerting/AlertRuleItem.tsx | 58 + .../features/alerting/AlertRuleList.test.tsx | 117 + .../app/features/alerting/AlertRuleList.tsx | 171 + public/app/features/alerting/AlertTab.tsx | 247 + .../features/alerting/AlertTabCtrl.test.ts | 81 + public/app/features/alerting/AlertTabCtrl.ts | 510 + .../alerting/EditNotificationChannelPage.tsx | 142 + .../alerting/NewNotificationChannelPage.tsx | 96 + .../features/alerting/NextGenAlertingPage.tsx | 195 + .../alerting/NotificationsEditCtrl.ts | 179 + .../alerting/NotificationsListPage.tsx | 120 + public/app/features/alerting/StateHistory.tsx | 111 + .../features/alerting/TestRuleResult.test.tsx | 48 + .../app/features/alerting/TestRuleResult.tsx | 125 + .../TestRuleResult.test.tsx.snap | 7 + .../components/AlertDefinitionItem.tsx | 45 + .../components/AlertDefinitionOptions.tsx | 100 + .../components/AlertingQueryEditor.tsx | 92 + .../components/AlertingQueryPreview.tsx | 130 + .../alerting/components/BasicSettings.tsx | 44 + .../alerting/components/ChannelSettings.tsx | 36 + .../components/NotificationChannelForm.tsx | 129 + .../components/NotificationChannelOptions.tsx | 81 + .../components/NotificationSettings.tsx | 59 + .../alerting/components/OptionElement.tsx | 58 + .../components/PreviewInstancesTab.tsx | 26 + .../alerting/components/PreviewQueryTab.tsx | 63 + .../getAlertingValidationMessage.test.ts | 167 + .../alerting/getAlertingValidationMessage.ts | 48 + .../alerting/partials/alert_howto.html | 29 + .../features/alerting/partials/alert_tab.html | 243 + .../alerting/state/ThresholdMapper.test.ts | 138 + .../alerting/state/ThresholdMapper.ts | 71 + public/app/features/alerting/state/actions.ts | 284 + .../app/features/alerting/state/alertDef.ts | 171 + .../features/alerting/state/reducers.test.ts | 423 + .../app/features/alerting/state/reducers.ts | 280 + .../features/alerting/state/selectors.test.ts | 98 + .../app/features/alerting/state/selectors.ts | 33 + .../utils/notificationChannel.test.ts | 209 + .../alerting/utils/notificationChannels.ts | 71 + public/app/features/all.ts | 11 + public/app/features/annotations/all.ts | 5 + .../annotations/annotation_tooltip.ts | 97 + .../features/annotations/annotations_srv.ts | 277 + .../components/AnnotationResultMapper.tsx | 195 + .../StandardAnnotationQueryEditor.tsx | 188 + .../app/features/annotations/editor_ctrl.ts | 156 + .../app/features/annotations/event_editor.ts | 112 + .../app/features/annotations/event_manager.ts | 184 + .../features/annotations/events_processing.ts | 27 + .../features/annotations/partials/editor.html | 147 + .../annotations/partials/event_editor.html | 35 + .../annotations/specs/annotations_srv.test.ts | 30 + .../specs/annotations_srv_specs.test.ts | 39 + .../standardAnnotationSupport.test.ts | 97 + .../annotations/standardAnnotationSupport.ts | 212 + public/app/features/annotations/types.ts | 20 + .../features/api-keys/ApiKeysActionBar.tsx | 31 + .../api-keys/ApiKeysAddedModal.test.tsx | 25 + .../features/api-keys/ApiKeysAddedModal.tsx | 47 + .../features/api-keys/ApiKeysController.tsx | 19 + public/app/features/api-keys/ApiKeysForm.tsx | 111 + .../features/api-keys/ApiKeysPage.test.tsx | 210 + public/app/features/api-keys/ApiKeysPage.tsx | 168 + public/app/features/api-keys/ApiKeysTable.tsx | 49 + .../api-keys/__mocks__/apiKeysMock.ts | 27 + .../ApiKeysAddedModal.test.tsx.snap | 79 + public/app/features/api-keys/state/actions.ts | 31 + .../features/api-keys/state/reducers.test.ts | 27 + .../app/features/api-keys/state/reducers.ts | 30 + .../features/api-keys/state/selectors.test.ts | 25 + .../app/features/api-keys/state/selectors.ts | 11 + .../AddPanelWidget/AddPanelWidget.test.tsx | 25 + .../AddPanelWidget/AddPanelWidget.tsx | 305 + .../AddPanelWidget/_AddPanelWidget.scss | 78 + .../AddPanelWidget.test.tsx.snap | 51 + .../components/AddPanelWidget/index.ts | 1 + .../DashExportModal/DashboardExporter.test.ts | 276 + .../DashExportModal/DashboardExporter.ts | 214 + .../components/DashExportModal/index.ts | 1 + .../dashboard/components/DashNav/DashNav.tsx | 298 + .../components/DashNav/DashNavButton.tsx | 44 + .../DashNav/DashNavTimeControls.tsx | 127 + .../dashboard/components/DashNav/index.ts | 2 + .../DashboardPermissions.tsx | 122 + .../DashboardRow/DashboardRow.test.tsx | 50 + .../components/DashboardRow/DashboardRow.tsx | 107 + .../components/DashboardRow/index.ts | 1 + .../DashboardSettings/AnnotationsSettings.tsx | 30 + .../AutoRefreshIntervals.test.tsx | 175 + .../AutoRefreshIntervals.tsx | 93 + .../DashboardSettings/DashboardSettings.tsx | 199 + .../DashboardSettings/GeneralSettings.tsx | 134 + .../DashboardSettings/JsonEditorSettings.tsx | 54 + .../DashboardSettings/LinksSettings.test.tsx | 179 + .../DashboardSettings/LinksSettings.tsx | 37 + .../DashboardSettings/TimePickerSettings.tsx | 85 + .../VersionsSettings.test.tsx | 142 + .../DashboardSettings/VersionsSettings.tsx | 210 + .../DashboardSettings/__mocks__/versions.ts | 112 + .../components/DashboardSettings/index.ts | 1 + .../DeleteDashboard/DeleteDashboardButton.tsx | 27 + .../DeleteDashboard/DeleteDashboardModal.tsx | 90 + .../DeleteDashboard/useDashboardDelete.tsx | 25 + .../FolderPicker/FolderPickerCtrl.ts | 205 + .../components/Inspector/InspectContent.tsx | 104 + .../components/Inspector/PanelInspector.tsx | 77 + .../dashboard/components/Inspector/hooks.ts | 64 + .../LinksSettings/LinkSettingsEdit.tsx | 150 + .../LinksSettings/LinkSettingsHeader.tsx | 32 + .../LinksSettings/LinkSettingsList.tsx | 121 + .../components/LinksSettings/index.tsx | 3 + .../PanelEditor/AngularPanelOptions.tsx | 132 + .../DefaultFieldConfigEditor.test.tsx | 263 + .../PanelEditor/DefaultFieldConfigEditor.tsx | 131 + .../PanelEditor/DynamicConfigValueEditor.tsx | 113 + .../components/PanelEditor/OptionsGroup.tsx | 185 + .../PanelEditor/OptionsPaneContent.tsx | 312 + .../PanelEditor/OverrideEditor.test.tsx | 136 + .../components/PanelEditor/OverrideEditor.tsx | 211 + .../PanelEditor/OverrideFieldConfigEditor.tsx | 112 + .../components/PanelEditor/PanelEditor.tsx | 500 + .../PanelEditor/PanelEditorQueries.tsx | 79 + .../PanelEditor/PanelEditorTabs.tsx | 114 + .../PanelEditor/PanelNotSupported.test.tsx | 48 + .../PanelEditor/PanelNotSupported.tsx | 33 + .../PanelEditor/PanelOptionsEditor.tsx | 93 + .../PanelEditor/PanelOptionsTab.tsx | 175 + .../PanelEditor/VisualizationTab.tsx | 119 + .../dashboard/components/PanelEditor/index.ts | 1 + .../PanelEditor/state/actions.test.ts | 128 + .../components/PanelEditor/state/actions.ts | 170 + .../components/PanelEditor/state/reducers.ts | 106 + .../PanelEditor/state/selectors.test.ts | 74 + .../components/PanelEditor/state/selectors.ts | 51 + .../components/PanelEditor/state/utils.ts | 9 + .../dashboard/components/PanelEditor/types.ts | 36 + .../PanelEditor/usePanelLatestData.ts | 46 + .../components/PanelEditor/utils.test.ts | 88 + .../dashboard/components/PanelEditor/utils.ts | 63 + .../RepeatRowSelect/RepeatRowSelect.tsx | 40 + .../RowOptions/RowOptionsButton.tsx | 37 + .../components/RowOptions/RowOptionsForm.tsx | 46 + .../components/RowOptions/RowOptionsModal.tsx | 30 + .../SaveDashboard/SaveDashboardAsModal.tsx | 50 + .../SaveDashboard/SaveDashboardButton.tsx | 64 + .../SaveDashboard/SaveDashboardErrorProxy.tsx | 141 + .../SaveDashboard/SaveDashboardModal.tsx | 51 + .../SaveDashboard/SaveDashboardModalProxy.tsx | 25 + .../SaveProvisionedDashboard.tsx | 12 + .../SaveDashboard/UnsavedChangesModal.tsx | 50 + .../forms/SaveDashboardAsForm.test.tsx | 118 + .../forms/SaveDashboardAsForm.tsx | 129 + .../forms/SaveDashboardForm.test.tsx | 101 + .../SaveDashboard/forms/SaveDashboardForm.tsx | 73 + .../forms/SaveProvisionedDashboardForm.tsx | 85 + .../components/SaveDashboard/types.ts | 21 + .../SaveDashboard/useDashboardSave.tsx | 54 + .../components/ShareModal/ShareEmbed.tsx | 109 + .../components/ShareModal/ShareExport.tsx | 114 + .../components/ShareModal/ShareLink.test.tsx | 188 + .../components/ShareModal/ShareLink.tsx | 159 + .../components/ShareModal/ShareModal.tsx | 116 + .../components/ShareModal/ShareSnapshot.tsx | 307 + .../ShareModal/ShareSnapshotCtrl.ts | 0 .../dashboard/components/ShareModal/index.ts | 2 + .../dashboard/components/ShareModal/types.ts | 17 + .../dashboard/components/ShareModal/utils.ts | 103 + .../components/SubMenu/Annotations.tsx | 40 + .../components/SubMenu/DashboardLinks.tsx | 73 + .../SubMenu/DashboardLinksDashboard.test.tsx | 118 + .../SubMenu/DashboardLinksDashboard.tsx | 167 + .../dashboard/components/SubMenu/SubMenu.tsx | 81 + .../components/SubMenu/SubMenuItems.tsx | 35 + .../TransformationEditor.tsx | 174 + .../TransformationOperationRow.tsx | 63 + .../TransformationOperationRows.tsx | 43 + .../TransformationsEditor.test.tsx | 79 + .../TransformationsEditor.tsx | 391 + .../components/TransformationsEditor/types.ts | 6 + .../VersionHistory/HistoryListCtrl.test.ts | 161 + .../VersionHistory/HistoryListCtrl.ts | 114 + .../VersionHistory/HistorySrv.test.ts | 74 + .../components/VersionHistory/HistorySrv.ts | 55 + .../VersionHistory/RevertDashboardModal.tsx | 26 + .../VersionHistory/VersionHistoryButtons.tsx | 30 + .../VersionHistoryComparison.tsx | 47 + .../VersionHistory/VersionHistoryHeader.tsx | 31 + .../VersionHistory/VersionHistoryTable.tsx | 66 + .../VersionHistory/__mocks__/history.ts | 177 + .../components/VersionHistory/index.ts | 2 + .../components/VersionHistory/template.html | 39 + .../VersionHistory/useDashboardRestore.tsx | 35 + .../VizTypePicker/VizTypePicker.tsx | 114 + .../VizTypePicker/VizTypePickerPlugin.tsx | 176 + .../containers/DashboardPage.test.tsx | 286 + .../dashboard/containers/DashboardPage.tsx | 371 + .../containers/SoloPanelPage.test.tsx | 142 + .../dashboard/containers/SoloPanelPage.tsx | 110 + .../__snapshots__/DashboardPage.test.tsx.snap | 801 + .../dashboard/dashgrid/DashboardGrid.test.tsx | 90 + .../dashboard/dashgrid/DashboardGrid.tsx | 281 + .../dashboard/dashgrid/DashboardPanel.tsx | 146 + .../dashboard/dashgrid/PanelChrome.test.tsx | 91 + .../dashboard/dashgrid/PanelChrome.tsx | 364 + .../dashboard/dashgrid/PanelChromeAngular.tsx | 263 + .../dashgrid/PanelHeader/PanelHeader.tsx | 75 + .../PanelHeader/PanelHeaderCorner.test.tsx | 14 + .../PanelHeader/PanelHeaderCorner.tsx | 115 + .../PanelHeaderLoadingIndicator.tsx | 48 + .../dashgrid/PanelHeader/PanelHeaderMenu.tsx | 34 + .../PanelHeader/PanelHeaderMenuItem.tsx | 67 + .../PanelHeader/PanelHeaderMenuProvider.tsx | 29 + .../PanelHeader/PanelHeaderMenuTrigger.tsx | 51 + .../PanelHeader/PanelHeaderMenuWrapper.tsx | 28 + .../PanelHeader/PanelHeaderNotice.tsx | 27 + .../PanelHeader/PanelHeaderNotices.tsx | 47 + .../dashboard/dashgrid/PanelPluginError.tsx | 85 + .../dashboard/dashgrid/PanelResizer.tsx | 77 + .../__snapshots__/DashboardGrid.test.tsx.snap | 943 + public/app/features/dashboard/index.ts | 9 + .../dashboard/services/ChangeTracker.test.ts | 99 + .../dashboard/services/ChangeTracker.ts | 201 + .../dashboard/services/DashboardLoaderSrv.ts | 139 + .../dashboard/services/DashboardSrv.ts | 84 + .../dashboard/services/TimeSrv.test.ts | 252 + .../features/dashboard/services/TimeSrv.ts | 316 + .../dashboard/services/UnsavedChangesSrv.ts | 23 + .../dashboard/state/DashboardMigrator.test.ts | 935 + .../dashboard/state/DashboardMigrator.ts | 846 + .../state/DashboardModel.repeat.test.ts | 731 + .../dashboard/state/DashboardModel.test.ts | 708 + .../dashboard/state/DashboardModel.ts | 1067 + .../dashboard/state/PanelModel.test.ts | 468 + .../features/dashboard/state/PanelModel.ts | 577 + .../app/features/dashboard/state/actions.ts | 166 + .../dashboard/state/analyticsProcessor.ts | 14 + .../state/getPanelOptionsWithDefaults.test.ts | 419 + .../state/getPanelOptionsWithDefaults.ts | 218 + public/app/features/dashboard/state/index.ts | 2 + .../dashboard/state/initDashboard.test.ts | 307 + .../features/dashboard/state/initDashboard.ts | 287 + .../features/dashboard/state/reducers.test.ts | 85 + .../app/features/dashboard/state/reducers.ts | 135 + .../app/features/dashboard/state/selectors.ts | 9 + .../dashboard/utils/getPanelMenu.test.ts | 204 + .../features/dashboard/utils/getPanelMenu.ts | 212 + .../dashboard/utils/getRefreshFromUrl.test.ts | 75 + .../dashboard/utils/getRefreshFromUrl.ts | 39 + .../dashboard/utils/loadSnapshotData.ts | 24 + .../features/dashboard/utils/panel.test.ts | 93 + public/app/features/dashboard/utils/panel.ts | 153 + .../datasources/DashboardsTable.test.tsx | 63 + .../features/datasources/DashboardsTable.tsx | 56 + .../datasources/DataSourceDashboards.test.tsx | 31 + .../datasources/DataSourceDashboards.tsx | 101 + .../datasources/DataSourceList.test.tsx | 29 + .../features/datasources/DataSourcesList.tsx | 50 + .../datasources/DataSourcesListPage.test.tsx | 51 + .../datasources/DataSourcesListPage.tsx | 110 + .../datasources/NewDataSourcePage.tsx | 198 + .../datasources/__mocks__/dataSourcesMocks.ts | 50 + .../DashboardsTable.test.tsx.snap | 88 + .../DataSourceDashboards.test.tsx.snap | 17 + .../DataSourcesListPage.test.tsx.snap | 154 + public/app/features/datasources/mocks.ts | 25 + .../datasources/partials/http_settings.html | 0 .../partials/http_settings_next.html | 1 + .../partials/tls_auth_settings.html | 62 + .../settings/BasicSettings.test.tsx | 22 + .../datasources/settings/BasicSettings.tsx | 50 + .../datasources/settings/ButtonRow.test.tsx | 32 + .../datasources/settings/ButtonRow.tsx | 48 + .../datasources/settings/CloudInfoBox.tsx | 92 + .../settings/DataSourceSettingsPage.test.tsx | 79 + .../settings/DataSourceSettingsPage.tsx | 281 + .../datasources/settings/HttpSettingsCtrl.ts | 21 + .../datasources/settings/PluginSettings.tsx | 95 + .../settings/TlsAuthSettingsCtrl.ts | 10 + .../__snapshots__/BasicSettings.test.tsx.snap | 41 + .../__snapshots__/ButtonRow.test.tsx.snap | 61 + .../DataSourceSettingsPage.test.tsx.snap | 439 + .../datasources/state/actions.test.ts | 200 + .../app/features/datasources/state/actions.ts | 235 + .../datasources/state/buildCategories.test.ts | 56 + .../datasources/state/buildCategories.ts | 215 + .../features/datasources/state/navModel.ts | 142 + .../datasources/state/reducers.test.ts | 174 + .../features/datasources/state/reducers.ts | 170 + .../features/datasources/state/selectors.ts | 37 + .../utils/passwordHandlers.test.ts | 32 + .../datasources/utils/passwordHandlers.ts | 45 + public/app/features/explore/AdHocFilter.tsx | 90 + .../explore/AdHocFilterField.test.tsx | 231 + .../app/features/explore/AdHocFilterField.tsx | 167 + public/app/features/explore/ElapsedTime.tsx | 22 + .../features/explore/ErrorContainer.test.tsx | 35 + .../app/features/explore/ErrorContainer.tsx | 30 + public/app/features/explore/Explore.test.tsx | 127 + public/app/features/explore/Explore.tsx | 449 + .../features/explore/ExploreDrawer.test.tsx | 11 + public/app/features/explore/ExploreDrawer.tsx | 93 + .../features/explore/ExploreGraphNGPanel.tsx | 161 + .../features/explore/ExploreGraphPanel.tsx | 172 + .../features/explore/ExplorePaneContainer.tsx | 143 + .../explore/ExploreQueryInspector.test.tsx | 83 + .../explore/ExploreQueryInspector.tsx | 86 + .../features/explore/ExploreTimeControls.tsx | 87 + .../app/features/explore/ExploreToolbar.tsx | 279 + public/app/features/explore/JSONViewer.tsx | 9 + public/app/features/explore/LiveLogs.test.tsx | 106 + public/app/features/explore/LiveLogs.tsx | 166 + .../app/features/explore/LiveTailButton.tsx | 85 + public/app/features/explore/Logs.tsx | 464 + public/app/features/explore/LogsContainer.tsx | 202 + .../features/explore/MetaInfoText.test.tsx | 34 + public/app/features/explore/MetaInfoText.tsx | 69 + .../explore/NoDataSourceCallToAction.tsx | 46 + .../features/explore/NodeGraphContainer.tsx | 48 + public/app/features/explore/QueryEditor.tsx | 99 + public/app/features/explore/QueryRow.test.tsx | 47 + public/app/features/explore/QueryRow.tsx | 230 + .../features/explore/QueryRowActions.test.tsx | 39 + .../app/features/explore/QueryRowActions.tsx | 64 + public/app/features/explore/QueryRows.tsx | 27 + .../app/features/explore/QueryStatus.test.tsx | 19 + public/app/features/explore/QueryStatus.tsx | 52 + .../explore/ReturnToDashboardButton.test.tsx | 41 + .../explore/ReturnToDashboardButton.tsx | 103 + .../explore/RichHistory/RichHistory.test.tsx | 50 + .../explore/RichHistory/RichHistory.tsx | 186 + .../RichHistory/RichHistoryCard.test.tsx | 134 + .../explore/RichHistory/RichHistoryCard.tsx | 329 + .../RichHistory/RichHistoryContainer.test.tsx | 41 + .../RichHistory/RichHistoryContainer.tsx | 76 + .../RichHistoryQueriesTab.test.tsx | 59 + .../RichHistory/RichHistoryQueriesTab.tsx | 262 + .../RichHistory/RichHistorySettings.test.tsx | 36 + .../RichHistory/RichHistorySettings.tsx | 116 + .../RichHistoryStarredTab.test.tsx | 45 + .../RichHistory/RichHistoryStarredTab.tsx | 170 + .../app/features/explore/RunButton.test.tsx | 47 + public/app/features/explore/RunButton.tsx | 41 + .../explore/SecondaryActions.test.tsx | 69 + .../app/features/explore/SecondaryActions.tsx | 62 + .../features/explore/TableContainer.test.tsx | 41 + .../app/features/explore/TableContainer.tsx | 87 + public/app/features/explore/Time.tsx | 35 + .../features/explore/TimeSyncButton.test.tsx | 19 + .../app/features/explore/TimeSyncButton.tsx | 28 + .../explore/TraceView/TraceView.test.tsx | 368 + .../features/explore/TraceView/TraceView.tsx | 216 + .../explore/TraceView/createSpanLink.test.ts | 133 + .../explore/TraceView/createSpanLink.tsx | 103 + .../features/explore/TraceView/uiElements.tsx | 95 + .../TraceView/useChildrenState.test.ts | 65 + .../explore/TraceView/useChildrenState.ts | 106 + .../explore/TraceView/useDetailState.test.ts | 56 + .../explore/TraceView/useDetailState.ts | 88 + .../TraceView/useHoverIndentGuide.test.ts | 16 + .../explore/TraceView/useHoverIndentGuide.ts | 30 + .../explore/TraceView/useSearch.test.ts | 44 + .../features/explore/TraceView/useSearch.ts | 15 + .../explore/TraceView/useViewRange.test.ts | 25 + .../explore/TraceView/useViewRange.ts | 35 + public/app/features/explore/Wrapper.test.tsx | 379 + public/app/features/explore/Wrapper.tsx | 67 + .../features/explore/__mocks__/mockData.ts | 382 + .../ErrorContainer.test.tsx.snap | 33 + .../__snapshots__/Explore.test.tsx.snap | 40 + .../__snapshots__/MetaInfoText.test.tsx.snap | 18 + .../QueryRowActions.test.tsx.snap | 59 + .../TableContainer.test.tsx.snap | 19 + .../explore/flotgraph/getGraphSeriesModel.ts | 147 + .../app/features/explore/flotgraph/types.ts | 48 + .../explore/slate-plugins/prism/index.tsx | 130 + .../features/explore/state/datasource.test.ts | 50 + .../app/features/explore/state/datasource.ts | 107 + .../explore/state/explorePane.test.ts | 145 + .../app/features/explore/state/explorePane.ts | 318 + public/app/features/explore/state/history.ts | 58 + .../app/features/explore/state/main.test.ts | 209 + public/app/features/explore/state/main.ts | 247 + .../app/features/explore/state/query.test.ts | 220 + public/app/features/explore/state/query.ts | 694 + .../features/explore/state/selectors.test.ts | 92 + .../app/features/explore/state/selectors.ts | 21 + .../app/features/explore/state/time.test.ts | 75 + public/app/features/explore/state/time.ts | 170 + .../app/features/explore/state/utils.test.ts | 49 + public/app/features/explore/state/utils.ts | 100 + .../features/explore/useLiveTailControls.ts | 58 + .../explore/utils/LogsCrossFadeTransition.tsx | 69 + .../features/explore/utils/decorators.test.ts | 397 + .../app/features/explore/utils/decorators.ts | 158 + .../app/features/explore/utils/links.test.ts | 104 + public/app/features/explore/utils/links.ts | 121 + public/app/features/explore/utils/set.test.ts | 52 + public/app/features/explore/utils/set.ts | 35 + public/app/features/explore/utils/time.ts | 18 + .../expressions/ExpressionDatasource.ts | 45 + .../expressions/ExpressionQueryEditor.tsx | 203 + public/app/features/expressions/types.ts | 20 + .../features/folders/FolderPermissions.tsx | 134 + .../folders/FolderSettingsPage.test.tsx | 58 + .../features/folders/FolderSettingsPage.tsx | 120 + .../FolderSettingsPage.test.tsx.snap | 117 + .../components/NewDashboardsFolder.tsx | 84 + public/app/features/folders/state/actions.ts | 128 + public/app/features/folders/state/navModel.ts | 64 + .../features/folders/state/reducers.test.ts | 145 + public/app/features/folders/state/reducers.ts | 50 + public/app/features/inspector/DetailText.tsx | 16 + .../inspector/InspectDataTab.test.tsx | 65 + .../app/features/inspector/InspectDataTab.tsx | 321 + .../features/inspector/InspectErrorTab.tsx | 22 + .../app/features/inspector/InspectJSONTab.tsx | 165 + .../features/inspector/InspectMetadataTab.tsx | 13 + .../features/inspector/InspectStatsTab.tsx | 49 + .../features/inspector/InspectStatsTable.tsx | 68 + .../features/inspector/InspectSubtitle.tsx | 57 + .../app/features/inspector/QueryInspector.tsx | 321 + public/app/features/inspector/styles.ts | 83 + public/app/features/inspector/types.ts | 8 + .../AddLibraryPanelModal.tsx | 52 + .../DeleteLibraryPanelModal.tsx | 91 + .../DeleteLibraryPanelModal/actions.ts | 17 + .../DeleteLibraryPanelModal/reducer.test.ts | 35 + .../DeleteLibraryPanelModal/reducer.ts | 33 + .../LibraryPanelCard/LibraryPanelCard.tsx | 98 + .../LibraryPanelInfo/LibraryPanelInfo.tsx | 55 + .../LibraryPanelsView/LibraryPanelsView.tsx | 129 + .../components/LibraryPanelsView/actions.ts | 61 + .../LibraryPanelsView/reducer.test.ts | 148 + .../components/LibraryPanelsView/reducer.ts | 66 + .../PanelLibraryOptionsGroup.tsx | 94 + .../SaveLibraryPanelModal.tsx | 122 + .../components/UnlinkModal/UnlinkModal.tsx | 26 + public/app/features/library-panels/guard.ts | 6 + .../app/features/library-panels/state/api.ts | 64 + public/app/features/library-panels/styles.ts | 53 + public/app/features/library-panels/types.ts | 46 + public/app/features/library-panels/utils.ts | 60 + .../library-panels/utils/usePanelSave.ts | 29 + public/app/features/live/channel.ts | 188 + .../live/dashboard/DashboardChangedModal.tsx | 118 + .../live/dashboard/dashboardWatcher.ts | 186 + public/app/features/live/dashboard/types.ts | 15 + public/app/features/live/features.ts | 61 + public/app/features/live/live.ts | 160 + .../live/measurements/measurementsSupport.ts | 40 + public/app/features/live/scopes.ts | 154 + .../manage-dashboards/DashboardImportPage.tsx | 161 + .../manage-dashboards/SnapshotListPage.tsx | 30 + .../components/ImportDashboardForm.tsx | 159 + .../components/ImportDashboardOverview.tsx | 125 + .../components/SnapshotListTable.tsx | 100 + .../components/UploadDashboard/index.ts | 1 + .../uploadDashboardDirective.ts | 71 + .../app/features/manage-dashboards/index.ts | 5 + .../services/ValidationSrv.ts | 70 + .../manage-dashboards/state/actions.ts | 237 + .../manage-dashboards/state/reducers.ts | 106 + .../manage-dashboards/state/selectors.ts | 28 + .../app/features/manage-dashboards/types.ts | 13 + .../manage-dashboards/utils/validation.ts | 43 + public/app/features/org/NewOrgPage.tsx | 80 + .../app/features/org/OrgDetailsPage.test.tsx | 53 + public/app/features/org/OrgDetailsPage.tsx | 65 + public/app/features/org/OrgProfile.test.tsx | 20 + public/app/features/org/OrgProfile.tsx | 29 + public/app/features/org/SelectOrgPage.tsx | 58 + public/app/features/org/UserInviteForm.tsx | 94 + public/app/features/org/UserInvitePage.tsx | 33 + .../OrgDetailsPage.test.tsx.snap | 49 + .../__snapshots__/OrgProfile.test.tsx.snap | 14 + public/app/features/org/state/actions.test.ts | 40 + public/app/features/org/state/actions.ts | 30 + .../app/features/org/state/reducers.test.ts | 27 + public/app/features/org/state/reducers.ts | 28 + public/app/features/panel/all.ts | 5 + .../app/features/panel/metrics_panel_ctrl.ts | 244 + public/app/features/panel/panel_ctrl.ts | 114 + public/app/features/panel/panel_directive.ts | 120 + public/app/features/panel/panel_editor_tab.ts | 42 + .../panel/panellinks/linkSuppliers.test.ts | 156 + .../panel/panellinks/linkSuppliers.ts | 155 + .../app/features/panel/panellinks/link_srv.ts | 371 + .../app/features/panel/panellinks/module.html | 70 + .../app/features/panel/panellinks/module.ts | 63 + .../panel/panellinks/specs/link_srv.test.ts | 461 + .../panel/partials/query_editor_row.html | 2 + public/app/features/panel/query_ctrl.ts | 21 + public/app/features/panel/query_editor_row.ts | 46 + .../panel/specs/metrics_panel_ctrl.test.ts | 65 + public/app/features/playlist/all.ts | 5 + .../features/playlist/partials/playlist.html | 124 + .../playlist/partials/playlist_search.html | 35 + .../features/playlist/partials/playlists.html | 76 + .../features/playlist/playlist_edit_ctrl.ts | 152 + .../app/features/playlist/playlist_routes.ts | 33 + .../app/features/playlist/playlist_search.ts | 86 + public/app/features/playlist/playlist_srv.ts | 128 + .../app/features/playlist/playlists_ctrl.ts | 65 + .../playlist/specs/playlist_edit_ctrl.test.ts | 83 + .../playlist/specs/playlist_srv.test.ts | 153 + .../app/features/plugins/AppRootPage.test.tsx | 170 + public/app/features/plugins/AppRootPage.tsx | 124 + .../app/features/plugins/PluginDashboards.tsx | 112 + .../app/features/plugins/PluginList.test.tsx | 25 + public/app/features/plugins/PluginList.tsx | 24 + .../features/plugins/PluginListItem.test.tsx | 33 + .../app/features/plugins/PluginListItem.tsx | 41 + .../features/plugins/PluginListPage.test.tsx | 86 + .../app/features/plugins/PluginListPage.tsx | 84 + public/app/features/plugins/PluginPage.tsx | 500 + .../features/plugins/PluginSettingsCache.ts | 25 + .../features/plugins/PluginSignatureBadge.tsx | 75 + .../app/features/plugins/PluginStateInfo.tsx | 38 + .../features/plugins/PluginsErrorsInfo.tsx | 104 + .../features/plugins/__mocks__/pluginMocks.ts | 98 + .../__snapshots__/PluginList.test.tsx.snap | 241 + .../PluginListItem.test.tsx.snap | 114 + public/app/features/plugins/all.ts | 4 + .../app/features/plugins/built_in_plugins.ts | 111 + public/app/features/plugins/datasource_srv.ts | 273 + .../plugins/partials/plugin_page.html | 12 + .../plugins/partials/update_instructions.html | 21 + .../app/features/plugins/plugin_component.ts | 275 + .../features/plugins/plugin_loader.test.ts | 68 + public/app/features/plugins/plugin_loader.ts | 259 + .../app/features/plugins/plugin_page_ctrl.ts | 62 + .../plugins/specs/datasource_srv.test.ts | 247 + public/app/features/plugins/state/actions.ts | 51 + .../features/plugins/state/reducers.test.ts | 138 + public/app/features/plugins/state/reducers.ts | 57 + .../features/plugins/state/selectors.test.ts | 31 + .../app/features/plugins/state/selectors.ts | 14 + .../plugins/variableQueryEditorLoader.tsx | 38 + .../plugins/wrappers/AppConfigWrapper.tsx | 148 + .../features/profile/ChangePasswordForm.tsx | 77 + .../features/profile/ChangePasswordPage.tsx | 51 + public/app/features/profile/ProfileCtrl.ts | 71 + .../features/profile/UserOrganizations.tsx | 73 + .../app/features/profile/UserProfileEdit.tsx | 76 + .../features/profile/UserProfileEditForm.tsx | 72 + public/app/features/profile/UserSessions.tsx | 67 + public/app/features/profile/UserTeams.tsx | 61 + public/app/features/profile/all.ts | 0 public/app/features/profile/state/reducers.ts | 40 + .../app/features/profile/state/selectors.ts | 3 + .../features/query/components/HelpToggle.tsx | 26 + .../query/components/QueryEditorRow.test.ts | 63 + .../query/components/QueryEditorRow.tsx | 429 + .../components/QueryEditorRowTitle.test.tsx | 66 + .../query/components/QueryEditorRowTitle.tsx | 207 + .../query/components/QueryEditorRows.tsx | 99 + .../features/query/components/QueryGroup.tsx | 398 + .../query/components/QueryGroupOptions.tsx | 383 + .../query/state/PanelQueryRunner.test.ts | 349 + .../features/query/state/PanelQueryRunner.ts | 265 + .../query/state/queryAnalytics.test.ts | 106 + .../features/query/state/queryAnalytics.ts | 57 + .../features/query/state/runRequest.test.ts | 286 + public/app/features/query/state/runRequest.ts | 244 + public/app/features/query/state/types.ts | 5 + public/app/features/sandbox/TestStuffPage.tsx | 104 + .../features/search/components/ActionRow.tsx | 86 + .../search/components/ConfirmDeleteModal.tsx | 72 + .../search/components/DashboardActions.tsx | 28 + .../search/components/DashboardListPage.tsx | 55 + .../components/DashboardSearch.test.tsx | 135 + .../search/components/DashboardSearch.tsx | 106 + .../search/components/ManageDashboards.tsx | 173 + .../search/components/MoveToFolderModal.tsx | 87 + .../search/components/SearchCheckbox.tsx | 34 + .../search/components/SearchField.tsx | 81 + .../search/components/SearchItem.test.tsx | 65 + .../features/search/components/SearchItem.tsx | 92 + .../search/components/SearchResults.test.tsx | 56 + .../search/components/SearchResults.tsx | 146 + .../components/SearchResultsFilter.test.tsx | 90 + .../search/components/SearchResultsFilter.tsx | 90 + .../search/components/SearchWrapper.tsx | 54 + .../search/components/SectionHeader.tsx | 110 + public/app/features/search/connect.ts | 41 + public/app/features/search/constants.ts | 7 + .../search/hooks/useDashboardSearch.ts | 51 + .../search/hooks/useManageDashboards.test.ts | 98 + .../search/hooks/useManageDashboards.ts | 71 + public/app/features/search/hooks/useSearch.ts | 51 + .../features/search/hooks/useSearchQuery.ts | 72 + public/app/features/search/index.ts | 9 + public/app/features/search/loaders.ts | 11 + .../features/search/reducers/actionTypes.ts | 24 + .../search/reducers/dashboardSearch.test.ts | 93 + .../search/reducers/dashboardSearch.ts | 107 + .../search/reducers/manageDashboards.test.ts | 95 + .../search/reducers/manageDashboards.ts | 89 + .../search/reducers/searchQueryReducer.ts | 69 + public/app/features/search/testData.ts | 172 + public/app/features/search/types.ts | 105 + public/app/features/search/utils.test.ts | 207 + public/app/features/search/utils.ts | 261 + public/app/features/teams/CreateTeam.test.tsx | 17 + public/app/features/teams/CreateTeam.tsx | 106 + .../app/features/teams/TeamGroupSync.test.tsx | 63 + public/app/features/teams/TeamGroupSync.tsx | 164 + public/app/features/teams/TeamList.test.tsx | 128 + public/app/features/teams/TeamList.tsx | 178 + .../app/features/teams/TeamMemberRow.test.tsx | 91 + public/app/features/teams/TeamMemberRow.tsx | 109 + .../app/features/teams/TeamMembers.test.tsx | 80 + public/app/features/teams/TeamMembers.tsx | 162 + public/app/features/teams/TeamPages.test.tsx | 123 + public/app/features/teams/TeamPages.tsx | 151 + .../app/features/teams/TeamSettings.test.tsx | 29 + public/app/features/teams/TeamSettings.tsx | 60 + .../features/teams/__mocks__/navModelMock.ts | 59 + .../app/features/teams/__mocks__/teamMocks.ts | 73 + .../__snapshots__/CreateTeam.test.tsx.snap | 50 + .../__snapshots__/TeamGroupSync.test.tsx.snap | 272 + .../__snapshots__/TeamList.test.tsx.snap | 609 + .../__snapshots__/TeamMemberRow.test.tsx.snap | 286 + .../__snapshots__/TeamMembers.test.tsx.snap | 295 + .../__snapshots__/TeamPages.test.tsx.snap | 72 + .../__snapshots__/TeamSettings.test.tsx.snap | 28 + public/app/features/teams/state/actions.ts | 93 + public/app/features/teams/state/navModel.ts | 69 + .../app/features/teams/state/reducers.test.ts | 92 + public/app/features/teams/state/reducers.ts | 57 + .../features/teams/state/selectors.test.ts | 149 + public/app/features/teams/state/selectors.ts | 60 + public/app/features/templating/all.ts | 4 + .../app/features/templating/formatRegistry.ts | 265 + .../features/templating/template_srv.test.ts | 701 + .../app/features/templating/template_srv.ts | 333 + public/app/features/users/InviteeRow.tsx | 39 + .../app/features/users/InviteesTable.test.tsx | 31 + public/app/features/users/InviteesTable.tsx | 31 + .../app/features/users/SignupInvited.test.tsx | 127 + public/app/features/users/SignupInvited.tsx | 108 + .../app/features/users/TokenRevokedModal.tsx | 66 + .../features/users/UsersActionBar.test.tsx | 54 + public/app/features/users/UsersActionBar.tsx | 78 + .../app/features/users/UsersListPage.test.tsx | 63 + public/app/features/users/UsersListPage.tsx | 153 + public/app/features/users/UsersTable.test.tsx | 43 + public/app/features/users/UsersTable.tsx | 80 + .../app/features/users/__mocks__/userMocks.ts | 60 + .../__snapshots__/InviteesTable.test.tsx.snap | 180 + .../UsersActionBar.test.tsx.snap | 119 + .../__snapshots__/UsersListPage.test.tsx.snap | 68 + .../__snapshots__/UsersTable.test.tsx.snap | 486 + public/app/features/users/state/actions.ts | 39 + .../app/features/users/state/reducers.test.ts | 44 + public/app/features/users/state/reducers.ts | 44 + public/app/features/users/state/selectors.ts | 21 + public/app/features/variables/adapters.ts | 48 + .../variables/adhoc/AdHocVariableEditor.tsx | 66 + .../features/variables/adhoc/actions.test.ts | 460 + .../app/features/variables/adhoc/actions.ts | 160 + .../app/features/variables/adhoc/adapter.ts | 39 + .../adhoc/picker/AdHocFilterBuilder.tsx | 63 + .../variables/adhoc/picker/AdHocFilterKey.tsx | 65 + .../adhoc/picker/AdHocFilterRenderer.tsx | 40 + .../adhoc/picker/AdHocFilterValue.tsx | 39 + .../variables/adhoc/picker/AdHocPicker.tsx | 88 + .../adhoc/picker/ConditionSegment.tsx | 13 + .../adhoc/picker/OperatorSegment.tsx | 17 + .../features/variables/adhoc/reducer.test.ts | 220 + .../app/features/variables/adhoc/reducer.ts | 50 + .../variables/adhoc/urlParser.test.ts | 197 + .../app/features/variables/adhoc/urlParser.ts | 56 + .../constant/ConstantVariableEditor.tsx | 45 + .../variables/constant/actions.test.ts | 48 + .../features/variables/constant/actions.ts | 11 + .../features/variables/constant/adapter.ts | 47 + .../variables/constant/reducer.test.ts | 62 + .../features/variables/constant/reducer.ts | 30 + .../variables/custom/CustomVariableEditor.tsx | 81 + .../features/variables/custom/actions.test.ts | 61 + .../app/features/variables/custom/actions.ts | 11 + .../app/features/variables/custom/adapter.ts | 45 + .../features/variables/custom/reducer.test.ts | 297 + .../app/features/variables/custom/reducer.ts | 49 + .../datasource/DataSourceVariableEditor.tsx | 134 + .../variables/datasource/actions.test.ts | 162 + .../features/variables/datasource/actions.ts | 54 + .../features/variables/datasource/adapter.ts | 48 + .../variables/datasource/reducer.test.ts | 77 + .../features/variables/datasource/reducer.ts | 67 + .../variables/date/DateVariableEditor.tsx | 41 + .../variables/date/DateVariablePicker.tsx | 54 + .../features/variables/date/actions.test.ts | 55 + public/app/features/variables/date/actions.ts | 29 + .../features/variables/date/adapter.test.ts | 84 + public/app/features/variables/date/adapter.ts | 52 + .../features/variables/date/reducer.test.ts | 72 + public/app/features/variables/date/reducer.ts | 31 + .../editor/LegacyVariableQueryEditor.tsx | 54 + .../editor/SelectionOptionsEditor.tsx | 74 + .../editor/VariableEditorContainer.tsx | 135 + .../variables/editor/VariableEditorEditor.tsx | 213 + .../variables/editor/VariableEditorList.tsx | 232 + .../variables/editor/VariableHideSelect.tsx | 36 + .../editor/VariableSectionHeader.tsx | 24 + .../variables/editor/VariableSelectField.tsx | 53 + .../variables/editor/VariableSwitchField.tsx | 23 + .../editor/VariableTextAreaField.tsx | 67 + .../variables/editor/VariableTextField.tsx | 49 + .../variables/editor/VariableTypeSelect.tsx | 28 + .../editor/VariableValuesPreview.tsx | 73 + .../features/variables/editor/actions.test.ts | 17 + .../app/features/variables/editor/actions.ts | 129 + .../editor/getVariableQueryEditor.test.tsx | 103 + .../editor/getVariableQueryEditor.tsx | 72 + .../features/variables/editor/reducer.test.ts | 210 + .../app/features/variables/editor/reducer.ts | 90 + public/app/features/variables/editor/types.ts | 12 + .../getAllVariableValuesForUrl.test.ts | 104 + .../variables/getAllVariableValuesForUrl.ts | 27 + public/app/features/variables/guard.test.ts | 243 + public/app/features/variables/guard.ts | 190 + .../variables/inspect/NetworkGraph.tsx | 74 + .../variables/inspect/NetworkGraphModal.tsx | 44 + .../inspect/VariableUsagesButton.tsx | 33 + .../inspect/VariablesDependenciesButton.tsx | 50 + .../inspect/VariablesUnknownButton.tsx | 32 + .../inspect/VariablesUnknownTable.tsx | 80 + .../variables/inspect/reducer.test.ts | 29 + .../app/features/variables/inspect/reducer.ts | 46 + .../features/variables/inspect/utils.test.ts | 147 + .../app/features/variables/inspect/utils.ts | 290 + .../interval/IntervalVariableEditor.tsx | 110 + .../variables/interval/actions.test.ts | 196 + .../features/variables/interval/actions.ts | 45 + .../features/variables/interval/adapter.ts | 43 + .../variables/interval/reducer.test.ts | 123 + .../features/variables/interval/reducer.ts | 48 + .../OptionsPicker/OptionPicker.test.tsx | 140 + .../pickers/OptionsPicker/OptionsPicker.tsx | 135 + .../pickers/OptionsPicker/actions.test.ts | 549 + .../pickers/OptionsPicker/actions.ts | 214 + .../pickers/OptionsPicker/reducer.test.ts | 1007 + .../pickers/OptionsPicker/reducer.ts | 282 + .../variables/pickers/PickerRenderer.tsx | 56 + .../app/features/variables/pickers/index.ts | 1 + .../pickers/shared/VariableInput.tsx | 40 + .../variables/pickers/shared/VariableLink.tsx | 115 + .../pickers/shared/VariableOptions.tsx | 130 + .../app/features/variables/pickers/types.ts | 14 + .../query/QueryVariableEditor.test.tsx | 114 + .../variables/query/QueryVariableEditor.tsx | 301 + .../query/QueryVariableRefreshSelect.tsx | 32 + .../query/QueryVariableSortSelect.tsx | 36 + .../query/VariableQueryRunner.test.ts | 384 + .../variables/query/VariableQueryRunner.ts | 206 + .../features/variables/query/actions.test.ts | 760 + .../app/features/variables/query/actions.ts | 142 + .../app/features/variables/query/adapter.ts | 51 + .../variables/query/operators.test.ts | 311 + .../app/features/variables/query/operators.ts | 212 + .../variables/query/queryRunners.test.ts | 327 + .../features/variables/query/queryRunners.ts | 178 + .../features/variables/query/reducer.test.ts | 391 + .../app/features/variables/query/reducer.ts | 196 + .../query/variableQueryObserver.test.ts | 90 + .../variables/query/variableQueryObserver.ts | 36 + .../variables/shared/formatVariable.ts | 51 + .../variables/shared/multiOptions.test.ts | 76 + .../features/variables/shared/multiOptions.ts | 44 + .../shared/testing/adHocVariableBuilder.ts | 14 + .../variables/shared/testing/builders.ts | 22 + .../testing/datasourceVariableBuilder.ts | 14 + .../variables/shared/testing/helpers.ts | 12 + .../shared/testing/intervalVariableBuilder.ts | 24 + .../shared/testing/multiVariableBuilder.ts | 17 + .../shared/testing/optionsVariableBuilder.ts | 35 + .../shared/testing/queryVariableBuilder.ts | 19 + .../shared/testing/textboxVariableBuilder.ts | 9 + .../shared/testing/variableBuilder.ts | 25 + .../features/variables/state/actions.test.ts | 681 + .../app/features/variables/state/actions.ts | 726 + .../app/features/variables/state/helpers.ts | 93 + .../state/onTimeRangeUpdated.test.ts | 202 + .../variables/state/processVariable.test.ts | 426 + .../features/variables/state/reducers.test.ts | 162 + .../app/features/variables/state/reducers.ts | 27 + .../app/features/variables/state/selectors.ts | 51 + .../variables/state/setOptionFromUrl.test.ts | 149 + .../variables/state/sharedReducer.test.ts | 525 + .../features/variables/state/sharedReducer.ts | 190 + .../state/transactionReducer.test.ts | 62 + .../variables/state/transactionReducer.ts | 45 + public/app/features/variables/state/types.ts | 46 + .../state/upgradeLegacyQueries.test.ts | 146 + .../variables/state/variablesReducer.ts | 36 + .../app/features/variables/system/adapter.ts | 44 + .../textbox/TextBoxVariableEditor.tsx | 40 + .../textbox/TextBoxVariablePicker.tsx | 54 + .../variables/textbox/actions.test.ts | 55 + .../app/features/variables/textbox/actions.ts | 29 + .../variables/textbox/adapter.test.ts | 84 + .../app/features/variables/textbox/adapter.ts | 52 + .../variables/textbox/reducer.test.ts | 72 + .../app/features/variables/textbox/reducer.ts | 31 + public/app/features/variables/types.ts | 164 + public/app/features/variables/utils.test.ts | 84 + public/app/features/variables/utils.ts | 173 + public/app/index.ts | 12 + public/app/partials/confirm_modal.html | 51 + public/app/partials/edit_json.html | 24 + public/app/partials/modal.html | 12 + public/app/partials/reset_password.html | 58 + public/app/partials/signup_invited.html | 36 + .../CloudMonitoringMetricFindQuery.ts | 174 + .../datasource/cloud-monitoring/README.md | 7 + .../annotations_query_ctrl.ts | 15 + .../datasource/cloud-monitoring/api.test.ts | 79 + .../datasource/cloud-monitoring/api.ts | 86 + .../components/Aggregations.test.tsx | 72 + .../components/Aggregations.tsx | 80 + .../cloud-monitoring/components/AliasBy.tsx | 25 + .../components/AlignmentPeriods.tsx | 62 + .../components/Alignments.tsx | 39 + .../components/AnnotationQueryEditor.tsx | 153 + .../components/AnnotationsHelp.tsx | 44 + .../cloud-monitoring/components/Fields.tsx | 57 + .../cloud-monitoring/components/GroupBys.tsx | 58 + .../cloud-monitoring/components/Help.tsx | 139 + .../components/LabelFilter.tsx | 93 + .../components/MQLQueryEditor.tsx | 32 + .../components/MetricQueryEditor.tsx | 121 + .../cloud-monitoring/components/Metrics.tsx | 156 + .../cloud-monitoring/components/Project.tsx | 37 + .../components/QueryEditor.tsx | 149 + .../cloud-monitoring/components/QueryType.tsx | 34 + .../components/SLOQueryEditor.tsx | 112 + .../components/VariableQueryEditor.test.tsx | 64 + .../components/VariableQueryEditor.tsx | 349 + .../components/VisualMetricQueryEditor.tsx | 84 + .../VariableQueryEditor.test.tsx.snap | 24 + .../cloud-monitoring/components/index.ts | 16 + .../cloud-monitoring/config_ctrl.ts | 98 + .../datasource/cloud-monitoring/constants.ts | 266 + .../dashboards/cloud-storage-monitoring.json | 593 + .../dashboards/cloudfunctions-monitoring.json | 611 + .../dashboards/cloudsql-monitoring.json | 1049 + .../dashboards/cloudsql-mysql-monitoring.json | 607 + .../cloudsql-postgre-monitoring.json | 352 + .../dashboards/cloudtasks-monitoring.json | 421 + .../dashboards/dataprocessing-monitoring.json | 626 + .../firewall-insight-monitoring.json | 299 + .../dashboards/gce-network-monitoring.json | 475 + .../gce-vm-instance-monitoring.json | 851 + .../gke-prometheus-pod-node-monitoring.json | 591 + .../https-lb-backend-services-monitoring.json | 885 + .../https-loadbalancer-monitoring.json | 859 + .../dashboards/micro-service-monitoring.json | 713 + .../network-tcp-loadbalancer-monitoring.json | 545 + .../datasource/cloud-monitoring/datasource.ts | 382 + .../cloud-monitoring/functions.test.ts | 38 + .../datasource/cloud-monitoring/functions.ts | 128 + .../img/cloud_monitoring_logo.svg | 1 + .../datasource/cloud-monitoring/module.ts | 13 + .../partials/annotations.editor.html | 6 + .../cloud-monitoring/partials/config.html | 141 + .../datasource/cloud-monitoring/plugin.json | 122 + .../cloud-monitoring/specs/datasource.test.ts | 230 + .../cloud-monitoring/specs/testData.ts | 42 + .../datasource/cloud-monitoring/types.ts | 157 + .../datasource/cloud-monitoring/variables.ts | 31 + .../plugins/datasource/cloudwatch/README.md | 7 + .../cloudwatch/annotations_query_ctrl.ts | 31 + .../plugins/datasource/cloudwatch/aws_url.ts | 18 + .../cloudwatch/components/Alias.test.tsx | 10 + .../cloudwatch/components/Alias.tsx | 22 + .../components/AnnotationQueryEditor.tsx | 62 + .../cloudwatch/components/CloudWatchLink.tsx | 66 + .../components/ConfigEditor.test.tsx | 109 + .../cloudwatch/components/ConfigEditor.tsx | 63 + .../cloudwatch/components/Dimensions.test.tsx | 46 + .../cloudwatch/components/Dimensions.tsx | 81 + .../cloudwatch/components/Forms.tsx | 28 + .../cloudwatch/components/LogsCheatSheet.tsx | 273 + .../cloudwatch/components/LogsQueryEditor.tsx | 60 + .../components/LogsQueryField.test.tsx | 211 + .../cloudwatch/components/LogsQueryField.tsx | 398 + .../components/MetricsQueryEditor.test.tsx | 133 + .../components/MetricsQueryEditor.tsx | 195 + .../components/MetricsQueryFieldsEditor.tsx | 144 + .../components/PanelQueryEditor.tsx | 60 + .../cloudwatch/components/Stats.test.tsx | 21 + .../cloudwatch/components/Stats.tsx | 45 + .../components/ThrottlingErrorMessage.tsx | 29 + .../__snapshots__/Alias.test.tsx.snap | 18 + .../__snapshots__/ConfigEditor.test.tsx.snap | 316 + .../MetricsQueryEditor.test.tsx.snap | 3 + .../datasource/cloudwatch/components/index.ts | 7 + .../datasource/cloudwatch/dashboards/EBS.json | 1370 + .../cloudwatch/dashboards/Lambda.json | 976 + .../cloudwatch/dashboards/Logs.json | 784 + .../datasource/cloudwatch/dashboards/RDS.json | 1525 + .../datasource/cloudwatch/dashboards/ec2.json | 1594 + .../datasource/cloudwatch/datasource.d.ts | 2 + .../datasource/cloudwatch/datasource.test.ts | 94 + .../datasource/cloudwatch/datasource.ts | 1054 + .../cloudwatch/img/amazon-web-services.png | Bin 0 -> 7837 bytes .../cloudwatch/language_provider.test.ts | 185 + .../cloudwatch/language_provider.ts | 463 + .../datasource/cloudwatch/memoizedDebounce.ts | 13 + .../plugins/datasource/cloudwatch/module.tsx | 25 + .../partials/annotations.editor.html | 5 + .../cloudwatch/partials/query.parameter.html | 94 + .../plugins/datasource/cloudwatch/plugin.json | 33 + .../cloudwatch/query_parameter_ctrl.ts | 241 + .../cloudwatch/specs/datasource.test.ts | 1101 + .../plugins/datasource/cloudwatch/syntax.ts | 365 + .../plugins/datasource/cloudwatch/types.ts | 337 + .../cloudwatch/utils/query/ScrollQLLexer.js | 1390 + .../cloudwatch/utils/query/ScrollQLParser.js | 8603 +++++ .../utils/query/ScrollQLParserListener.js | 566 + .../utils/query/getStatsGroups.test.ts | 69 + .../cloudwatch/utils/query/getStatsGroups.ts | 41 + .../utils/rxjs/increasingInterval.ts | 32 + .../dashboard/DashboardQueryEditor.tsx | 183 + .../dashboard/DashboardQueryRow.tsx | 48 + .../plugins/datasource/dashboard/README.md | 4 + .../datasource/dashboard/datasource.ts | 23 + .../app/plugins/datasource/dashboard/index.ts | 3 + .../plugins/datasource/dashboard/module.ts | 4 + .../plugins/datasource/dashboard/plugin.json | 8 + .../dashboard/runSharedRequest.test.ts | 22 + .../datasource/dashboard/runSharedRequest.ts | 84 + .../app/plugins/datasource/dashboard/types.ts | 15 + .../datasource/elasticsearch/README.md | 7 + .../components/AddRemove.test.tsx | 42 + .../elasticsearch/components/AddRemove.tsx | 28 + .../elasticsearch/components/IconButton.tsx | 33 + .../elasticsearch/components/MetricPicker.tsx | 38 + .../BucketAggregationEditor.tsx | 78 + .../FiltersSettingsEditor/index.tsx | 81 + .../FiltersSettingsEditor/state/actions.ts | 16 + .../state/reducer.test.ts | 52 + .../FiltersSettingsEditor/state/reducer.ts | 21 + .../FiltersSettingsEditor/state/types.ts | 22 + .../FiltersSettingsEditor/utils.ts | 3 + .../SettingsEditor/index.tsx | 164 + .../SettingsEditor/useDescription.ts | 89 + .../BucketAggregationsEditor/aggregations.ts | 68 + .../BucketAggregationsEditor/index.tsx | 37 + .../BucketAggregationsEditor/state/actions.ts | 61 + .../state/reducer.test.ts | 152 + .../BucketAggregationsEditor/state/reducer.ts | 113 + .../BucketAggregationsEditor/state/types.ts | 51 + .../BucketAggregationsEditor/utils.ts | 143 + .../ElasticsearchQueryContext.test.tsx | 92 + .../QueryEditor/ElasticsearchQueryContext.tsx | 83 + .../MetricAggregationsEditor/MetricEditor.tsx | 122 + .../BucketScriptSettingsEditor/index.tsx | 106 + .../state/actions.ts | 34 + .../state/reducer.test.ts | 118 + .../state/reducer.ts | 46 + .../BucketScriptSettingsEditor/state/types.ts | 34 + .../BucketScriptSettingsEditor/utils.test.ts | 27 + .../BucketScriptSettingsEditor/utils.ts | 10 + .../MovingAverageSettingsEditor.tsx | 178 + .../SettingsEditor/SettingField.tsx | 46 + .../SettingsEditor/index.test.tsx | 82 + .../SettingsEditor/index.tsx | 145 + .../SettingsEditor/useDescription.ts | 40 + .../MetricAggregationsEditor/aggregations.ts | 357 + .../MetricAggregationsEditor/index.tsx | 40 + .../MetricAggregationsEditor/state/actions.ts | 96 + .../state/reducer.test.ts | 240 + .../MetricAggregationsEditor/state/reducer.ts | 165 + .../MetricAggregationsEditor/state/types.ts | 89 + .../MetricAggregationsEditor/styles.ts | 16 + .../MetricAggregationsEditor/utils.ts | 277 + .../components/QueryEditor/QueryEditorRow.tsx | 68 + .../QueryEditor/SettingsEditorContainer.tsx | 53 + .../components/QueryEditor/index.test.tsx | 49 + .../components/QueryEditor/index.tsx | 62 + .../components/QueryEditor/state.test.ts | 64 + .../components/QueryEditor/state.ts | 69 + .../components/QueryEditor/styles.ts | 5 + .../elasticsearch/components/types.ts | 4 + .../configuration/ConfigEditor.test.tsx | 57 + .../configuration/ConfigEditor.tsx | 73 + .../elasticsearch/configuration/DataLink.tsx | 141 + .../configuration/DataLinks.test.tsx | 88 + .../elasticsearch/configuration/DataLinks.tsx | 83 + .../configuration/ElasticDetails.test.tsx | 89 + .../configuration/ElasticDetails.tsx | 224 + .../configuration/LogsConfig.test.tsx | 26 + .../configuration/LogsConfig.tsx | 46 + .../elasticsearch/configuration/mocks.ts | 15 + .../elasticsearch/datasource.test.ts | 1020 + .../datasource/elasticsearch/datasource.ts | 882 + .../elasticsearch/elastic_response.ts | 707 + .../elasticsearch/hooks/useNextId.test.tsx | 29 + .../elasticsearch/hooks/useNextId.ts | 18 + .../hooks/useStatelessReducer.test.tsx | 69 + .../hooks/useStatelessReducer.ts | 45 + .../elasticsearch/img/elasticsearch.svg | 67 + .../datasource/elasticsearch/index_pattern.ts | 60 + .../elasticsearch/language_provider.test.ts | 174 + .../elasticsearch/language_provider.ts | 137 + .../datasource/elasticsearch/module.ts | 13 + .../partials/annotations.editor.html | 38 + .../datasource/elasticsearch/plugin.json | 29 + .../datasource/elasticsearch/query_builder.ts | 454 + .../datasource/elasticsearch/query_def.ts | 58 + .../datasource/elasticsearch/query_help.md | 10 + .../specs/elastic_response.test.ts | 1348 + .../elasticsearch/specs/index_pattern.test.ts | 111 + .../elasticsearch/specs/query_builder.test.ts | 752 + .../elasticsearch/specs/query_def.test.ts | 39 + .../plugins/datasource/elasticsearch/types.ts | 73 + .../datasource/elasticsearch/utils.test.ts | 36 + .../plugins/datasource/elasticsearch/utils.ts | 93 + .../__mocks__/query_ctrl.ts | 18 + .../__mocks__/sdk.ts | 3 + .../annotations_query_ctrl.ts | 78 + .../app_insights_datasource.test.ts | 544 + .../app_insights/app_insights_datasource.ts | 222 + .../app_insights/response_parser.ts | 238 + .../azure_log_analytics/__mocks__/schema.ts | 305 + .../azure_log_analytics_datasource.test.ts | 354 + .../azure_log_analytics_datasource.ts | 456 + .../response_parser.test.ts | 37 + .../azure_log_analytics/response_parser.ts | 224 + .../azure_monitor_datasource.test.ts | 968 + .../azure_monitor/azure_monitor_datasource.ts | 417 + .../azure_monitor/response_parser.ts | 169 + .../azure_monitor/supported_namespaces.ts | 247 + .../azure_monitor/url_builder.test.ts | 111 + .../azure_monitor/url_builder.ts | 54 + .../components/AnalyticsConfig.test.tsx | 84 + .../components/AnalyticsConfig.tsx | 209 + .../components/AzureCredentialsForm.test.tsx | 53 + .../components/AzureCredentialsForm.tsx | 158 + .../components/ConfigEditor.test.tsx | 45 + .../components/ConfigEditor.tsx | 295 + .../components/InsightsConfig.test.tsx | 83 + .../components/InsightsConfig.tsx | 74 + .../components/MonitorConfig.tsx | 81 + .../AnalyticsConfig.test.tsx.snap | 289 + .../AzureCredentialsForm.test.tsx.snap | 632 + .../__snapshots__/ConfigEditor.test.tsx.snap | 156 + .../InsightsConfig.test.tsx.snap | 183 + .../css/query_editor.css | 31 + .../datasource.ts | 274 + .../editor/KustoQueryField.tsx | 446 + .../editor/editor_component.tsx | 81 + .../editor/kusto/kusto.ts | 720 + .../editor/query_field.tsx | 343 + .../editor/typeahead.tsx | 77 + .../img/azure_monitor_cpu.png | Bin 0 -> 258796 bytes .../img/azure_monitor_network.png | Bin 0 -> 170395 bytes .../img/config_1_select_type.png | Bin 0 -> 9809 bytes .../config_2_azure_monitor_api_details.png | Bin 0 -> 20162 bytes .../img/config_3_app_insights_api_details.png | Bin 0 -> 10781 bytes .../img/config_4_save_and_test.png | Bin 0 -> 36072 bytes .../img/contoso_loans_grafana_dashboard.png | Bin 0 -> 256723 bytes .../img/grafana_cloud_install.png | Bin 0 -> 105323 bytes .../img/grafana_cloud_login.png | Bin 0 -> 61686 bytes .../img/logo.jpg | Bin 0 -> 9593 bytes .../insights_analytics_datasource.ts | 30 + .../log_analytics/querystring_builder.test.ts | 166 + .../log_analytics/querystring_builder.ts | 85 + .../module.tsx | 11 + .../partials/annotations.editor.html | 91 + .../partials/query.editor.html | 521 + .../plugin.json | 219 + .../query_ctrl.test.ts | 339 + .../query_ctrl.ts | 703 + .../query_help.md | 34 + .../time_grain_converter.test.ts | 23 + .../time_grain_converter.ts | 122 + .../grafana-azure-monitor-datasource/types.ts | 155 + .../app/plugins/datasource/grafana/README.md | 3 + .../datasource/grafana/annotation_ctrl.ts | 20 + .../grafana/components/QueryEditor.tsx | 167 + .../datasource/grafana/datasource.test.ts | 91 + .../plugins/datasource/grafana/datasource.ts | 144 + .../app/plugins/datasource/grafana/module.ts | 9 + .../grafana/partials/annotations.editor.html | 54 + .../plugins/datasource/grafana/plugin.json | 9 + .../app/plugins/datasource/grafana/types.ts | 38 + .../datasource/graphite/FunctionEditor.tsx | 120 + .../graphite/FunctionEditorControls.tsx | 67 + .../graphite/MetricTankMetaInspector.tsx | 181 + .../app/plugins/datasource/graphite/README.md | 13 + .../datasource/graphite/add_graphite_func.ts | 163 + .../graphite/configuration/ConfigEditor.tsx | 99 + .../graphite/dashboards/carbon_metrics.json | 173 + .../graphite/dashboards/metrictank.json | 4781 +++ .../plugins/datasource/graphite/datasource.ts | 686 + .../datasource/graphite/func_editor.ts | 248 + .../app/plugins/datasource/graphite/gfunc.ts | 1204 + .../datasource/graphite/graphite_query.ts | 303 + .../datasource/graphite/img/graphite_logo.png | Bin 0 -> 4451 bytes .../app/plugins/datasource/graphite/lexer.ts | 1455 + .../plugins/datasource/graphite/meta.test.ts | 40 + .../app/plugins/datasource/graphite/meta.ts | 86 + .../app/plugins/datasource/graphite/module.ts | 15 + .../app/plugins/datasource/graphite/parser.ts | 257 + .../graphite/partials/annotations.editor.html | 13 + .../graphite/partials/query.editor.html | 82 + .../plugins/datasource/graphite/plugin.json | 39 + .../plugins/datasource/graphite/query_ctrl.ts | 428 + .../plugins/datasource/graphite/query_help.md | 30 + .../graphite/specs/datasource.test.ts | 505 + .../datasource/graphite/specs/gfunc.test.ts | 170 + .../graphite/specs/graphite_query.test.ts | 77 + .../datasource/graphite/specs/lexer.test.ts | 123 + .../datasource/graphite/specs/parser.test.ts | 181 + .../graphite/specs/query_ctrl.test.ts | 361 + .../app/plugins/datasource/graphite/types.ts | 37 + .../app/plugins/datasource/influxdb/README.md | 9 + .../influxdb/components/ConfigEditor.test.tsx | 69 + .../influxdb/components/ConfigEditor.tsx | 321 + .../influxdb/components/FluxQueryEditor.tsx | 213 + .../influxdb/components/InfluxCheatSheet.tsx | 23 + .../components/InfluxLogsQueryField.test.tsx | 100 + .../components/InfluxLogsQueryField.tsx | 167 + .../influxdb/components/InfluxStartPage.tsx | 9 + .../components/VariableQueryEditor.tsx | 48 + .../__snapshots__/ConfigEditor.test.tsx.snap | 1185 + .../datasource/influxdb/datasource.mock.ts | 54 + .../plugins/datasource/influxdb/datasource.ts | 559 + .../datasource/influxdb/img/influxdb_logo.svg | 26 + .../datasource/influxdb/influx_query_model.ts | 289 + .../datasource/influxdb/influx_series.ts | 199 + .../app/plugins/datasource/influxdb/module.ts | 20 + .../influxdb/partials/annotations.editor.html | 24 + .../influxdb/partials/query.editor.html | 240 + .../plugins/datasource/influxdb/plugin.json | 28 + .../datasource/influxdb/query_builder.ts | 119 + .../plugins/datasource/influxdb/query_ctrl.ts | 423 + .../plugins/datasource/influxdb/query_help.md | 27 + .../plugins/datasource/influxdb/query_part.ts | 460 + .../datasource/influxdb/response_parser.ts | 57 + .../influxdb/specs/datasource.test.ts | 176 + .../influxdb/specs/influx_query_model.test.ts | 398 + .../influxdb/specs/influx_series.test.ts | 296 + .../influxdb/specs/query_builder.test.ts | 163 + .../influxdb/specs/query_ctrl.test.ts | 178 + .../influxdb/specs/query_part.test.ts | 188 + .../influxdb/specs/response_parser.test.ts | 172 + .../app/plugins/datasource/influxdb/types.ts | 55 + .../datasource/jaeger/ConfigEditor.tsx | 21 + .../datasource/jaeger/QueryField.test.tsx | 139 + .../plugins/datasource/jaeger/QueryField.tsx | 239 + .../datasource/jaeger/datasource.test.ts | 249 + .../plugins/datasource/jaeger/datasource.ts | 134 + .../datasource/jaeger/img/jaeger_logo.svg | 236 + .../app/plugins/datasource/jaeger/module.ts | 8 + .../app/plugins/datasource/jaeger/plugin.json | 35 + .../jaeger/responseTransform.test.ts | 49 + .../datasource/jaeger/responseTransform.ts | 54 + .../plugins/datasource/jaeger/testResponse.ts | 49 + public/app/plugins/datasource/jaeger/types.ts | 49 + .../loki/LokiAnnotationsQueryCtrl.tsx | 20 + public/app/plugins/datasource/loki/README.md | 3 + .../components/AnnotationsQueryEditor.tsx | 44 + .../loki/components/LokiCheatSheet.tsx | 133 + .../LokiExploreQueryEditor.test.tsx | 99 + .../components/LokiExploreQueryEditor.tsx | 30 + .../datasource/loki/components/LokiLabel.tsx | 120 + .../loki/components/LokiLabelBrowser.test.tsx | 241 + .../loki/components/LokiLabelBrowser.tsx | 487 + .../loki/components/LokiOptionFields.test.tsx | 35 + .../loki/components/LokiOptionFields.tsx | 131 + .../loki/components/LokiQueryEditor.test.tsx | 62 + .../loki/components/LokiQueryEditor.tsx | 59 + .../loki/components/LokiQueryField.tsx | 16 + .../loki/components/LokiQueryFieldForm.tsx | 194 + .../LokiExploreQueryEditor.test.tsx.snap | 112 + .../LokiQueryEditor.test.tsx.snap | 99 + .../loki/configuration/ConfigEditor.test.tsx | 28 + .../loki/configuration/ConfigEditor.tsx | 55 + .../loki/configuration/DebugSection.test.tsx | 71 + .../loki/configuration/DebugSection.tsx | 124 + .../loki/configuration/DerivedField.test.tsx | 66 + .../loki/configuration/DerivedField.tsx | 143 + .../loki/configuration/DerivedFields.test.tsx | 86 + .../loki/configuration/DerivedFields.tsx | 104 + .../loki/configuration/MaxLinesField.tsx | 36 + .../datasource/loki/datasource.test.ts | 587 + .../app/plugins/datasource/loki/datasource.ts | 595 + .../datasource/loki/img/grafana_icon.svg | 57 + .../plugins/datasource/loki/img/loki_icon.svg | 116 + .../datasource/loki/language_provider.test.ts | 297 + .../datasource/loki/language_provider.ts | 493 + .../datasource/loki/live_streams.test.ts | 211 + .../plugins/datasource/loki/live_streams.ts | 75 + public/app/plugins/datasource/loki/mocks.ts | 53 + public/app/plugins/datasource/loki/module.ts | 15 + .../loki/partials/annotations.editor.html | 7 + .../app/plugins/datasource/loki/plugin.json | 38 + .../datasource/loki/query_utils.test.ts | 31 + .../plugins/datasource/loki/query_utils.ts | 52 + .../loki/result_transformer.test.ts | 313 + .../datasource/loki/result_transformer.ts | 531 + .../plugins/datasource/loki/syntax.test.ts | 49 + public/app/plugins/datasource/loki/syntax.ts | 242 + public/app/plugins/datasource/loki/types.ts | 130 + .../datasource/mixed/MixedDataSource.test.ts | 100 + .../datasource/mixed/MixedDataSource.ts | 116 + public/app/plugins/datasource/mixed/README.md | 3 + public/app/plugins/datasource/mixed/module.ts | 2 + .../app/plugins/datasource/mixed/plugin.json | 13 + public/app/plugins/datasource/mssql/README.md | 12 + .../plugins/datasource/mssql/config_ctrl.ts | 33 + .../plugins/datasource/mssql/datasource.ts | 196 + .../datasource/mssql/img/sql_server_logo.svg | 115 + public/app/plugins/datasource/mssql/module.ts | 33 + .../mssql/partials/annotations.editor.html | 54 + .../datasource/mssql/partials/config.html | 125 + .../mssql/partials/query.editor.html | 90 + .../app/plugins/datasource/mssql/plugin.json | 26 + .../plugins/datasource/mssql/query_ctrl.ts | 71 + .../datasource/mssql/response_parser.ts | 160 + .../datasource/mssql/specs/datasource.test.ts | 336 + public/app/plugins/datasource/mssql/types.ts | 7 + public/app/plugins/datasource/mysql/README.md | 14 + .../plugins/datasource/mysql/datasource.ts | 223 + .../datasource/mysql/img/mysql_logo.svg | 27 + .../plugins/datasource/mysql/meta_query.ts | 142 + public/app/plugins/datasource/mysql/module.ts | 48 + .../plugins/datasource/mysql/mysql_query.ts | 235 + .../mysql/partials/annotations.editor.html | 54 + .../datasource/mysql/partials/config.html | 113 + .../mysql/partials/query.editor.html | 190 + .../app/plugins/datasource/mysql/plugin.json | 26 + .../plugins/datasource/mysql/query_ctrl.ts | 634 + .../datasource/mysql/response_parser.ts | 169 + .../datasource/mysql/specs/datasource.test.ts | 368 + .../app/plugins/datasource/mysql/sql_part.ts | 86 + public/app/plugins/datasource/mysql/types.ts | 13 + .../app/plugins/datasource/opentsdb/README.md | 7 + .../opentsdb/components/ConfigEditor.tsx | 20 + .../opentsdb/components/OpenTsdbDetails.tsx | 82 + .../datasource/opentsdb/datasource.d.ts | 2 + .../plugins/datasource/opentsdb/datasource.ts | 559 + .../datasource/opentsdb/img/opentsdb_logo.png | Bin 0 -> 9599 bytes .../app/plugins/datasource/opentsdb/module.ts | 13 + .../opentsdb/partials/annotations.editor.html | 7 + .../opentsdb/partials/query.editor.html | 262 + .../plugins/datasource/opentsdb/plugin.json | 23 + .../plugins/datasource/opentsdb/query_ctrl.ts | 222 + .../opentsdb/specs/datasource.test.ts | 141 + .../opentsdb/specs/query_ctrl.test.ts | 93 + .../app/plugins/datasource/opentsdb/types.ts | 11 + .../app/plugins/datasource/postgres/README.md | 12 + .../datasource/postgres/config_ctrl.ts | 89 + .../plugins/datasource/postgres/datasource.ts | 211 + .../postgres/img/postgresql_logo.svg | 22 + .../plugins/datasource/postgres/meta_query.ts | 168 + .../plugins/datasource/postgres/mode-sql.js | 114 + .../app/plugins/datasource/postgres/module.ts | 32 + .../postgres/partials/annotations.editor.html | 54 + .../datasource/postgres/partials/config.html | 195 + .../postgres/partials/query.editor.html | 191 + .../plugins/datasource/postgres/plugin.json | 26 + .../datasource/postgres/postgres_query.ts | 297 + .../plugins/datasource/postgres/query_ctrl.ts | 668 + .../datasource/postgres/response_parser.ts | 149 + .../postgres/specs/datasource.test.ts | 585 + .../postgres/specs/postgres_query.test.ts | 171 + .../plugins/datasource/postgres/sql_part.ts | 137 + .../app/plugins/datasource/postgres/types.ts | 13 + .../plugins/datasource/prometheus/README.md | 7 + .../prometheus/add_label_to_query.test.ts | 129 + .../prometheus/add_label_to_query.ts | 143 + .../prometheus/components/PromCheatSheet.tsx | 48 + .../components/PromExemplarField.tsx | 66 + .../components/PromExploreExtraField.test.tsx | 40 + .../components/PromExploreExtraField.tsx | 88 + .../PromExploreQueryEditor.test.tsx | 101 + .../components/PromExploreQueryEditor.tsx | 81 + .../prometheus/components/PromLink.test.tsx | 67 + .../prometheus/components/PromLink.tsx | 63 + .../components/PromQueryEditor.test.tsx | 53 + .../prometheus/components/PromQueryEditor.tsx | 201 + .../components/PromQueryField.test.tsx | 270 + .../prometheus/components/PromQueryField.tsx | 391 + .../PromExploreQueryEditor.test.tsx.snap | 92 + .../PromQueryEditor.test.tsx.snap | 197 + .../prometheus/configuration/ConfigEditor.tsx | 24 + .../configuration/ExemplarSetting.tsx | 97 + .../configuration/ExemplarsSettings.tsx | 53 + .../configuration/PromSettings.test.tsx | 140 + .../prometheus/configuration/PromSettings.tsx | 167 + .../prometheus/configuration/mocks.ts | 12 + .../prometheus/dashboards/grafana_stats.json | 1054 + .../dashboards/prometheus_2_stats.json | 1337 + .../dashboards/prometheus_stats.json | 756 + .../datasource/prometheus/datasource.test.ts | 2004 ++ .../datasource/prometheus/datasource.ts | 867 + .../prometheus/img/prometheus_logo.svg | 1 + .../prometheus/language_provider.test.ts | 570 + .../prometheus/language_provider.ts | 476 + .../prometheus/language_utils.test.ts | 144 + .../datasource/prometheus/language_utils.ts | 213 + .../prometheus/metric_find_query.test.ts | 253 + .../prometheus/metric_find_query.ts | 182 + .../datasource/prometheus/module.test.ts | 14 + .../plugins/datasource/prometheus/module.ts | 20 + .../partials/annotations.editor.html | 39 + .../plugins/datasource/prometheus/plugin.json | 47 + .../datasource/prometheus/promql.test.ts | 22 + .../plugins/datasource/prometheus/promql.ts | 442 + .../datasource/prometheus/query_hints.test.ts | 144 + .../datasource/prometheus/query_hints.ts | 129 + .../prometheus/result_transformer.test.ts | 587 + .../prometheus/result_transformer.ts | 473 + .../plugins/datasource/prometheus/types.ts | 148 + .../datasource/prometheus/variables.ts | 56 + .../plugins/datasource/tempo/ConfigEditor.tsx | 21 + .../plugins/datasource/tempo/QueryField.tsx | 35 + .../datasource/tempo/datasource.test.ts | 53 + .../plugins/datasource/tempo/datasource.ts | 63 + .../datasource/tempo/img/tempo_logo.svg | 1 + public/app/plugins/datasource/tempo/module.ts | 8 + .../app/plugins/datasource/tempo/plugin.json | 31 + .../datasource/testdata/ConfigEditor.tsx | 15 + .../plugins/datasource/testdata/LogIpsum.ts | 162 + .../datasource/testdata/QueryEditor.test.tsx | 80 + .../datasource/testdata/QueryEditor.tsx | 243 + .../datasource/testdata/TestInfoTab.tsx | 32 + .../testdata/__mocks__/scenarios.ts | 117 + .../testdata/components/CSVWaveEditor.tsx | 43 + .../testdata/components/GrafanaLiveEditor.tsx | 37 + .../components/ManualEntryEditor.test.tsx | 87 + .../testdata/components/ManualEntryEditor.tsx | 94 + .../testdata/components/NodeGraphEditor.tsx | 42 + .../components/PredictablePulseEditor.tsx | 56 + .../testdata/components/RandomWalkEditor.tsx | 42 + .../components/StreamingClientEditor.tsx | 69 + .../datasource/testdata/components/index.ts | 3 + .../plugins/datasource/testdata/constants.ts | 27 + .../testdata/dashboards/streaming.json | 211 + .../plugins/datasource/testdata/datasource.ts | 229 + .../datasource/testdata/img/testdata.svg | 1 + .../datasource/testdata/metricTree.test.ts | 24 + .../plugins/datasource/testdata/metricTree.ts | 79 + .../plugins/datasource/testdata/module.tsx | 22 + .../datasource/testdata/nodeGraphUtils.ts | 155 + .../plugins/datasource/testdata/plugin.json | 35 + .../plugins/datasource/testdata/runStreams.ts | 238 + .../testdata/testData/serviceMapResponse.ts | 416 + .../app/plugins/datasource/testdata/types.ts | 52 + .../plugins/datasource/testdata/variables.ts | 16 + .../datasource/zipkin/ConfigEditor.tsx | 21 + .../datasource/zipkin/QueryField.test.tsx | 82 + .../plugins/datasource/zipkin/QueryField.tsx | 237 + .../plugins/datasource/zipkin/constants.ts | 1 + .../datasource/zipkin/datasource.test.ts | 55 + .../plugins/datasource/zipkin/datasource.ts | 104 + .../datasource/zipkin/img/zipkin-logo.svg | 1 + .../app/plugins/datasource/zipkin/module.ts | 8 + .../app/plugins/datasource/zipkin/plugin.json | 31 + public/app/plugins/datasource/zipkin/types.ts | 25 + .../datasource/zipkin/utils/testData.ts | 186 + .../zipkin/utils/transforms.test.ts | 8 + .../datasource/zipkin/utils/transforms.ts | 115 + public/app/plugins/panel/alertlist/README.md | 9 + .../app/plugins/panel/alertlist/editor.html | 64 + .../alertlist/img/icn-singlestat-panel.svg | 1 + .../app/plugins/panel/alertlist/module.html | 50 + public/app/plugins/panel/alertlist/module.ts | 204 + .../app/plugins/panel/alertlist/plugin.json | 19 + .../panel/annolist/AnnoListPanel.test.tsx | 264 + .../plugins/panel/annolist/AnnoListPanel.tsx | 247 + .../panel/annolist/AnnotationListItem.tsx | 151 + .../panel/annolist/AnnotationListItemTags.tsx | 50 + public/app/plugins/panel/annolist/README.md | 6 + .../panel/annolist/img/icn-annolist-panel.svg | 1 + public/app/plugins/panel/annolist/module.tsx | 80 + public/app/plugins/panel/annolist/plugin.json | 20 + public/app/plugins/panel/annolist/types.ts | 29 + .../plugins/panel/barchart/BarChartPanel.tsx | 84 + .../plugins/panel/barchart/img/barchart.svg | 1 + public/app/plugins/panel/barchart/module.tsx | 150 + public/app/plugins/panel/barchart/plugin.json | 18 + .../panel/bargauge/BarGaugeMigrations.test.ts | 102 + .../panel/bargauge/BarGaugeMigrations.ts | 7 + .../panel/bargauge/BarGaugePanel.test.tsx | 103 + .../plugins/panel/bargauge/BarGaugePanel.tsx | 119 + .../panel/bargauge/img/icon_bar_gauge.svg | 1 + public/app/plugins/panel/bargauge/module.tsx | 30 + public/app/plugins/panel/bargauge/plugin.json | 16 + public/app/plugins/panel/bargauge/types.ts | 13 + .../app/plugins/panel/dashlist/DashList.tsx | 164 + public/app/plugins/panel/dashlist/README.md | 9 + .../panel/dashlist/img/icn-dashlist-panel.svg | 1 + public/app/plugins/panel/dashlist/module.tsx | 88 + public/app/plugins/panel/dashlist/plugin.json | 19 + public/app/plugins/panel/dashlist/styles.ts | 58 + public/app/plugins/panel/dashlist/types.ts | 10 + .../panel/gauge/GaugeMigrations.test.ts | 166 + .../plugins/panel/gauge/GaugeMigrations.ts | 28 + public/app/plugins/panel/gauge/GaugePanel.tsx | 80 + .../GaugeMigrations.test.ts.snap | 16 + .../plugins/panel/gauge/img/icon_gauge.svg | 1 + public/app/plugins/panel/gauge/module.tsx | 26 + public/app/plugins/panel/gauge/plugin.json | 16 + public/app/plugins/panel/gauge/types.ts | 13 + .../panel/gettingstarted/GettingStarted.tsx | 212 + .../plugins/panel/gettingstarted/README.md | 2 + .../gettingstarted/components/DocsCard.tsx | 66 + .../panel/gettingstarted/components/Step.tsx | 68 + .../components/TutorialCard.tsx | 72 + .../gettingstarted/components/sharedStyles.ts | 49 + .../img/Onboarding_Panel_dark.svg | 1 + .../img/Onboarding_Panel_light.svg | 1 + .../gettingstarted/img/icn-dashlist-panel.svg | 119 + .../plugins/panel/gettingstarted/module.ts | 5 + .../plugins/panel/gettingstarted/plugin.json | 19 + .../app/plugins/panel/gettingstarted/steps.ts | 101 + .../app/plugins/panel/gettingstarted/types.ts | 29 + .../panel/graph/GraphContextMenuCtrl.ts | 67 + .../panel/graph/GraphMigrations.test.ts | 150 + .../plugins/panel/graph/GraphMigrations.ts | 24 + .../app/plugins/panel/graph/Legend/Legend.tsx | 341 + .../panel/graph/Legend/LegendSeriesItem.tsx | 231 + public/app/plugins/panel/graph/README.md | 7 + public/app/plugins/panel/graph/align_yaxes.ts | 160 + .../app/plugins/panel/graph/axes_editor.html | 195 + public/app/plugins/panel/graph/axes_editor.ts | 87 + .../app/plugins/panel/graph/data_processor.ts | 187 + public/app/plugins/panel/graph/graph.ts | 949 + .../plugins/panel/graph/graph_tooltip.d.ts | 2 + .../app/plugins/panel/graph/graph_tooltip.ts | 306 + public/app/plugins/panel/graph/histogram.ts | 73 + .../panel/graph/img/icn-graph-panel.svg | 1 + .../plugins/panel/graph/jquery.flot.events.ts | 704 + public/app/plugins/panel/graph/module.ts | 409 + public/app/plugins/panel/graph/plugin.json | 17 + .../panel/graph/series_overrides_ctrl.ts | 168 + .../__snapshots__/data_processor.test.ts.snap | 288 + .../panel/graph/specs/align_yaxes.test.ts | 348 + .../panel/graph/specs/data_processor.test.ts | 74 + .../plugins/panel/graph/specs/graph.test.ts | 1352 + .../panel/graph/specs/graph_ctrl.test.ts | 149 + .../panel/graph/specs/graph_tooltip.test.ts | 267 + .../panel/graph/specs/histogram.test.ts | 84 + .../graph/specs/series_override_ctrl.test.ts | 41 + .../graph/specs/threshold_manager.test.ts | 148 + .../graph/specs/time_region_manager.test.ts | 371 + .../app/plugins/panel/graph/tab_display.html | 160 + .../app/plugins/panel/graph/tab_legend.html | 134 + .../panel/graph/tab_series_overrides.html | 51 + .../plugins/panel/graph/tab_thresholds.html | 1 + .../plugins/panel/graph/tab_time_regions.html | 1 + public/app/plugins/panel/graph/template.ts | 22 + .../plugins/panel/graph/threshold_manager.ts | 260 + .../plugins/panel/graph/thresholds_form.html | 106 + .../plugins/panel/graph/thresholds_form.ts | 84 + .../panel/graph/time_region_manager.ts | 261 + .../panel/graph/time_regions_form.html | 89 + .../plugins/panel/graph/time_regions_form.ts | 78 + public/app/plugins/panel/graph/types.ts | 14 + public/app/plugins/panel/graph/utils.test.ts | 17 + public/app/plugins/panel/graph/utils.ts | 24 + public/app/plugins/panel/heatmap/README.md | 7 + .../app/plugins/panel/heatmap/axes_editor.ts | 50 + .../app/plugins/panel/heatmap/color_legend.ts | 330 + .../app/plugins/panel/heatmap/color_scale.ts | 27 + .../plugins/panel/heatmap/display_editor.ts | 22 + .../app/plugins/panel/heatmap/heatmap_ctrl.ts | 371 + .../panel/heatmap/heatmap_data_converter.ts | 489 + .../plugins/panel/heatmap/heatmap_tooltip.ts | 275 + .../panel/heatmap/img/icn-heatmap-panel.svg | 1 + public/app/plugins/panel/heatmap/module.html | 14 + public/app/plugins/panel/heatmap/module.ts | 4 + .../panel/heatmap/partials/axes_editor.html | 105 + .../heatmap/partials/display_editor.html | 99 + public/app/plugins/panel/heatmap/plugin.json | 21 + public/app/plugins/panel/heatmap/rendering.ts | 836 + .../panel/heatmap/specs/heatmap_ctrl.test.ts | 88 + .../specs/heatmap_data_converter.test.ts | 396 + public/app/plugins/panel/heatmap/types.ts | 42 + .../plugins/panel/live/LiveChannelEditor.tsx | 172 + public/app/plugins/panel/live/LivePanel.tsx | 331 + public/app/plugins/panel/live/img/live.svg | 10 + public/app/plugins/panel/live/module.tsx | 37 + public/app/plugins/panel/live/plugin.json | 20 + public/app/plugins/panel/live/types.ts | 15 + public/app/plugins/panel/logs/LogsPanel.tsx | 43 + .../plugins/panel/logs/img/icn-logs-panel.svg | 1 + public/app/plugins/panel/logs/module.tsx | 63 + public/app/plugins/panel/logs/plugin.json | 16 + public/app/plugins/panel/logs/types.ts | 9 + public/app/plugins/panel/news/NewsPanel.tsx | 140 + public/app/plugins/panel/news/constants.ts | 2 + public/app/plugins/panel/news/img/news.svg | 1 + public/app/plugins/panel/news/module.tsx | 25 + public/app/plugins/panel/news/plugin.json | 20 + public/app/plugins/panel/news/rss.ts | 32 + public/app/plugins/panel/news/types.ts | 32 + public/app/plugins/panel/news/utils.test.ts | 68 + public/app/plugins/panel/news/utils.ts | 36 + .../panel/nodeGraph/NodeGraphPanel.tsx | 23 + .../panel/nodeGraph/img/icn-node-graph.svg | 1 + public/app/plugins/panel/nodeGraph/module.tsx | 5 + .../app/plugins/panel/nodeGraph/plugin.json | 17 + public/app/plugins/panel/nodeGraph/types.ts | 1 + .../panel/piechart/PieChartOptionsBox.tsx | 35 + .../plugins/panel/piechart/PieChartPanel.tsx | 46 + .../panel/piechart/img/icon_piechart.svg | 1 + .../plugins/panel/piechart/migrations.test.ts | 74 + .../app/plugins/panel/piechart/migrations.ts | 119 + public/app/plugins/panel/piechart/module.tsx | 89 + public/app/plugins/panel/piechart/plugin.json | 17 + public/app/plugins/panel/piechart/types.ts | 7 + public/app/plugins/panel/pluginlist/README.md | 3 + .../pluginlist/img/icn-dashlist-panel.svg | 1 + .../app/plugins/panel/pluginlist/module.html | 32 + public/app/plugins/panel/pluginlist/module.ts | 79 + .../app/plugins/panel/pluginlist/plugin.json | 19 + public/app/plugins/panel/singlestat/README.md | 9 + .../app/plugins/panel/singlestat/editor.html | 193 + .../singlestat/img/icn-singlestat-panel.svg | 83 + .../plugins/panel/singlestat/mappings.html | 74 + .../app/plugins/panel/singlestat/module.html | 3 + public/app/plugins/panel/singlestat/module.ts | 759 + .../app/plugins/panel/singlestat/plugin.json | 18 + .../panel/singlestat/specs/singlestat.test.ts | 457 + .../singlestat/specs/singlestat_panel.test.ts | 70 + public/app/plugins/panel/stat/README.md | 9 + .../plugins/panel/stat/StatMigrations.test.ts | 64 + .../app/plugins/panel/stat/StatMigrations.ts | 34 + public/app/plugins/panel/stat/StatPanel.tsx | 115 + .../panel/stat/img/icn-singlestat-panel.svg | 1 + public/app/plugins/panel/stat/module.tsx | 68 + public/app/plugins/panel/stat/plugin.json | 17 + public/app/plugins/panel/stat/types.ts | 141 + public/app/plugins/panel/table-old/README.md | 9 + .../panel/table-old/column_options.html | 305 + .../plugins/panel/table-old/column_options.ts | 168 + .../app/plugins/panel/table-old/editor.html | 85 + public/app/plugins/panel/table-old/editor.ts | 100 + .../panel/table-old/img/icn-table-panel.svg | 1 + .../app/plugins/panel/table-old/module.html | 25 + public/app/plugins/panel/table-old/module.ts | 271 + .../app/plugins/panel/table-old/plugin.json | 19 + .../app/plugins/panel/table-old/renderer.ts | 391 + .../panel/table-old/specs/renderer.test.ts | 550 + .../table-old/specs/transformers.test.ts | 556 + .../plugins/panel/table-old/transformers.ts | 295 + public/app/plugins/panel/table-old/types.ts | 42 + public/app/plugins/panel/table/README.md | 9 + public/app/plugins/panel/table/TablePanel.tsx | 149 + .../__snapshots__/migrations.test.ts.snap | 219 + .../panel/table/img/icn-table-panel.svg | 1 + .../plugins/panel/table/migrations.test.ts | 132 + public/app/plugins/panel/table/migrations.ts | 244 + public/app/plugins/panel/table/module.tsx | 69 + public/app/plugins/panel/table/plugin.json | 17 + public/app/plugins/panel/table/types.ts | 17 + public/app/plugins/panel/text/README.md | 5 + public/app/plugins/panel/text/TextPanel.tsx | 89 + .../plugins/panel/text/TextPanelEditor.tsx | 61 + .../plugins/panel/text/img/icn-text-panel.svg | 1 + public/app/plugins/panel/text/module.tsx | 35 + public/app/plugins/panel/text/plugin.json | 18 + .../text/textPanelMigrationHandler.test.ts | 86 + .../panel/text/textPanelMigrationHandler.ts | 28 + public/app/plugins/panel/text/types.ts | 5 + .../panel/timeseries/FillBelowToEditor.tsx | 47 + .../timeseries/HideSeriesConfigEditor.tsx | 32 + .../plugins/panel/timeseries/LayoutBuilder.ts | 52 + .../panel/timeseries/LineStyleEditor.tsx | 130 + public/app/plugins/panel/timeseries/README.md | 3 + .../timeseries/ScaleDistributionEditor.tsx | 63 + .../panel/timeseries/TimeSeriesPanel.tsx | 72 + .../__snapshots__/migrations.test.ts.snap | 251 + public/app/plugins/panel/timeseries/config.ts | 304 + .../panel/timeseries/img/icn-graph-panel.svg | 1 + .../timeseries/img/icn-timeseries-panel.svg | 1 + .../panel/timeseries/migrations.test.ts | 411 + .../plugins/panel/timeseries/migrations.ts | 394 + .../app/plugins/panel/timeseries/module.tsx | 26 + .../colorSeriesConfigFactory.test.ts | 151 + .../overrides/colorSeriesConfigFactory.ts | 74 + .../overrides/hideSeriesConfigFactory.test.ts | 419 + .../overrides/hideSeriesConfigFactory.ts | 175 + .../app/plugins/panel/timeseries/plugin.json | 17 + .../timeseries/plugins/AnnotationMarker.tsx | 133 + .../timeseries/plugins/AnnotationsPlugin.tsx | 128 + .../timeseries/plugins/ContextMenuPlugin.tsx | 171 + .../timeseries/plugins/ExemplarMarker.tsx | 214 + .../timeseries/plugins/ExemplarsPlugin.tsx | 72 + public/app/plugins/panel/timeseries/types.ts | 14 + public/app/plugins/panel/welcome/README.md | 2 + public/app/plugins/panel/welcome/Welcome.tsx | 109 + .../panel/welcome/img/background_light.svg | 78 + .../panel/welcome/img/icn-dashlist-panel.svg | 119 + public/app/plugins/panel/welcome/module.ts | 4 + public/app/plugins/panel/welcome/plugin.json | 19 + public/app/plugins/panel/xychart/README.md | 4 + .../plugins/panel/xychart/XYChartPanel.tsx | 69 + .../plugins/panel/xychart/XYDimsEditor.tsx | 166 + public/app/plugins/panel/xychart/dims.ts | 102 + .../plugins/panel/xychart/img/icn-xychart.svg | 1 + public/app/plugins/panel/xychart/module.tsx | 38 + public/app/plugins/panel/xychart/plugin.json | 17 + public/app/plugins/panel/xychart/types.ts | 13 + public/app/plugins/sdk.ts | 7 + public/app/routes/GrafanaCtrl.ts | 324 + public/app/routes/ReactContainer.tsx | 82 + public/app/routes/dashboard_loaders.ts | 88 + public/app/routes/registry.ts | 17 + public/app/routes/routes.ts | 590 + public/app/store/configureStore.ts | 82 + public/app/store/store.ts | 28 + public/app/types/acl.ts | 122 + public/app/types/alerting.ts | 176 + public/app/types/angular.ts | 5 + public/app/types/apiKeys.ts | 21 + public/app/types/appEvent.ts | 21 + public/app/types/appNotifications.ts | 26 + public/app/types/application.ts | 3 + .../types/dangerously-set-html-content.d.ts | 11 + public/app/types/dashboard.ts | 87 + public/app/types/datasources.ts | 32 + public/app/types/events.ts | 162 + public/app/types/explore.ts | 210 + public/app/types/folders.ts | 29 + public/app/types/index.ts | 21 + public/app/types/jquery/jquery.d.ts | 17 + public/app/types/ldap.ts | 76 + public/app/types/location.ts | 10 + public/app/types/organization.ts | 8 + public/app/types/plugins.ts | 39 + public/app/types/query.ts | 21 + public/app/types/sanitize-url.d.ts | 3 + public/app/types/store.ts | 56 + public/app/types/svg.d.ts | 4 + public/app/types/teams.ts | 39 + public/app/types/templates.ts | 5 + public/app/types/user.ts | 116 + public/dashboards/default.json | 172 + public/dashboards/home.json | 73 + public/dashboards/scripted.js | 88 + public/dashboards/scripted_async.js | 76 + public/dashboards/scripted_templated.js | 99 + public/dashboards/template_vars.json | 265 + public/emails/README.md | 2 + public/emails/alert_notification.html | 371 + public/emails/alert_notification_example.html | 359 + public/emails/invited_to_org.html | 284 + public/emails/new_user_invite.html | 285 + public/emails/reset_password.html | 279 + public/emails/signup_started.html | 283 + public/emails/welcome_on_signup.html | 286 + public/fonts/FontAwesome.otf | Bin 0 -> 134801 bytes public/fonts/fontawesome-webfont.eot | Bin 0 -> 165719 bytes public/fonts/fontawesome-webfont.svg | 2671 ++ public/fonts/fontawesome-webfont.ttf | Bin 0 -> 165525 bytes public/fonts/fontawesome-webfont.woff | Bin 0 -> 98019 bytes public/fonts/fontawesome-webfont.woff2 | Bin 0 -> 77159 bytes public/fonts/grafana-icons.eot | Bin 0 -> 29927 bytes public/fonts/grafana-icons.svg | 63 + public/fonts/grafana-icons.ttf | Bin 0 -> 29739 bytes public/fonts/grafana-icons.woff | Bin 0 -> 29815 bytes ...O5bWGqF5A9baEERJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 5740 bytes ...CpsQm3Vp6mXoaTRWV49_lSm1NYrwo-zkhivY.woff2 | Bin 0 -> 2392 bytes ...CpsQm3Vp6mXoaTT0LW-43aMEzIO6XUTLjad8.woff2 | Bin 0 -> 12412 bytes ...CpsQm3Vp6mXoaTZX5f-9o1vgP2EXwfjgl7AY.woff2 | Bin 0 -> 9956 bytes ...CpsQm3Vp6mXoaTa-j2U0lmluP9RWlSytm3ho.woff2 | Bin 0 -> 17520 bytes ...CpsQm3Vp6mXoaTaaRobkAwv3vxw3jMhVENGA.woff2 | Bin 0 -> 8416 bytes ...CpsQm3Vp6mXoaTegdm0LZdjqr5-oayXSOefg.woff2 | Bin 0 -> 16152 bytes ...CpsQm3Vp6mXoaTf8zf_FOSsgRmwsS7Aa9k2w.woff2 | Bin 0 -> 5736 bytes ...oAWT7BTt32Z01mxJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 16868 bytes ...B6EMdfHrEVqA1KRJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 2332 bytes ...JH_bn48VBG8sNShWV49_lSm1NYrwo-zkhivY.woff2 | Bin 0 -> 2332 bytes ...JH_bn48VBG8sNSj0LW-43aMEzIO6XUTLjad8.woff2 | Bin 0 -> 12692 bytes ...JH_bn48VBG8sNSpX5f-9o1vgP2EXwfjgl7AY.woff2 | Bin 0 -> 9896 bytes ...JH_bn48VBG8sNSq-j2U0lmluP9RWlSytm3ho.woff2 | Bin 0 -> 17607 bytes ...JH_bn48VBG8sNSqaRobkAwv3vxw3jMhVENGA.woff2 | Bin 0 -> 8379 bytes ...JH_bn48VBG8sNSugdm0LZdjqr5-oayXSOefg.woff2 | Bin 0 -> 16163 bytes ...JH_bn48VBG8sNSv8zf_FOSsgRmwsS7Aa9k2w.woff2 | Bin 0 -> 5916 bytes ...TmqiVp7vzi-Q5URJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 9676 bytes ...Brn4kERxqtaUH3VtXRa8TVwTICgirnJhmVJw.woff2 | Bin 0 -> 15572 bytes ...OKiLJc3WVjuplzBWV49_lSm1NYrwo-zkhivY.woff2 | Bin 0 -> 2332 bytes ...OKiLJc3WVjuplzD0LW-43aMEzIO6XUTLjad8.woff2 | Bin 0 -> 12640 bytes ...OKiLJc3WVjuplzJX5f-9o1vgP2EXwfjgl7AY.woff2 | Bin 0 -> 9848 bytes ...OKiLJc3WVjuplzK-j2U0lmluP9RWlSytm3ho.woff2 | Bin 0 -> 17880 bytes ...OKiLJc3WVjuplzKaRobkAwv3vxw3jMhVENGA.woff2 | Bin 0 -> 8528 bytes ...OKiLJc3WVjuplzOgdm0LZdjqr5-oayXSOefg.woff2 | Bin 0 -> 16276 bytes ...OKiLJc3WVjuplzP8zf_FOSsgRmwsS7Aa9k2w.woff2 | Bin 0 -> 5832 bytes ...ET9fUeobQW7jkRRJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 12288 bytes ...8I15wypJXxuGMBiYE0-AqJ3nfInTTiDXDjU4.woff2 | Bin 0 -> 11908 bytes ...8I15wypJXxuGMBjTOQ_MqJVwkKsUn0wKzc2I.woff2 | Bin 0 -> 16164 bytes ...8I15wypJXxuGMBjUj_cnvWIuuBMVgbX098Mw.woff2 | Bin 0 -> 9700 bytes ...8I15wypJXxuGMBkbcKLIaa1LC45dFaAfauRA.woff2 | Bin 0 -> 2296 bytes ...8I15wypJXxuGMBmo_sUJ8uO4YLWRInS22T3Y.woff2 | Bin 0 -> 7792 bytes ...8I15wypJXxuGMBo4P5ICox8Kq3LLUNMylGO4.woff2 | Bin 0 -> 14968 bytes ...8I15wypJXxuGMBr6up8jxqWt8HVA3mDhkV_0.woff2 | Bin 0 -> 5504 bytes ...2726on7jbcb_pAhJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 8160 bytes ...Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 1468 bytes ...OANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 1496 bytes .../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2 | Bin 0 -> 14599 bytes ...8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 11788 bytes ...TrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 5464 bytes ...OpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 5208 bytes ...aF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2 | Bin 0 -> 12728 bytes ...aF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2 | Bin 0 -> 16756 bytes ...aF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2 | Bin 0 -> 10828 bytes ...aF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2 | Bin 0 -> 1508 bytes ...aF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2 | Bin 0 -> 7820 bytes ...aF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2 | Bin 0 -> 16111 bytes ...aF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2 | Bin 0 -> 5564 bytes ...0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 10448 bytes ...eo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2 | Bin 0 -> 14748 bytes ...C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 16880 bytes ...YbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 15164 bytes ...nQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 7836 bytes ...GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 15514 bytes ...g_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 9848 bytes ...H69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 5216 bytes ...uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 7112 bytes ...QWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 9920 bytes ...lrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 11712 bytes ...r2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2 | Bin 0 -> 1484 bytes ...0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2 | Bin 0 -> 16204 bytes ...SK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2 | Bin 0 -> 12636 bytes public/img/alert_howto_new.png | Bin 0 -> 2669 bytes public/img/angle_gradient_light_rev.png | Bin 0 -> 46140 bytes public/img/angle_gradient_rev.png | Bin 0 -> 2047 bytes public/img/apple-touch-icon.png | Bin 0 -> 15717 bytes public/img/background_tease.jpg | Bin 0 -> 177483 bytes public/img/browserconfig.xml | 9 + public/img/check_radio_sheet.png | Bin 0 -> 3668 bytes public/img/checkbox.png | Bin 0 -> 1070 bytes public/img/checkbox_white.png | Bin 0 -> 550 bytes public/img/critical.svg | 15 + public/img/cubes.png | Bin 0 -> 21592 bytes public/img/envelope.png | Bin 0 -> 186 bytes public/img/fav32.png | Bin 0 -> 405 bytes public/img/glyphicons-halflings-white.png | Bin 0 -> 8621 bytes public/img/glyphicons-halflings.png | Bin 0 -> 12585 bytes public/img/grab_dark.svg | 15 + public/img/grab_light.svg | 15 + public/img/grafana_com_auth_icon.svg | 57 + public/img/grafana_enterprise_typelogo.svg | 51 + public/img/grafana_icon.svg | 1 + public/img/grafana_mask_icon.svg | 12 + public/img/grafana_mask_icon_white.svg | 12 + public/img/grafana_net_logo.svg | 1 + public/img/grafana_typelogo.svg | 27 + public/img/grafanaconline.png | Bin 0 -> 9909 bytes public/img/graph404.svg | 72 + public/img/heatmap_bg_test.svg | 4620 +++ public/img/icn-app.svg | 36 + public/img/icn-dashboard-tiny.svg | 49 + public/img/icn-dashboard.svg | 36 + public/img/icn-datasource.svg | 28 + public/img/icn-panel.svg | 23 + public/img/icn-plugins-tiny.svg | 38 + public/img/icn-row.svg | 25 + .../icons_dark_theme/icon_add_annotation.svg | 31 + .../icon_add_annotation_alt.svg | 31 + .../icon_add_data_sources.svg | 31 + .../img/icons_dark_theme/icon_add_folder.svg | 29 + public/img/icons_dark_theme/icon_add_link.svg | 36 + .../icon_add_notification_channel.svg | 31 + .../img/icons_dark_theme/icon_add_panel.svg | 32 + public/img/icons_dark_theme/icon_add_team.svg | 35 + public/img/icons_dark_theme/icon_add_user.svg | 30 + .../icons_dark_theme/icon_add_variable.svg | 54 + public/img/icons_dark_theme/icon_advanced.svg | 33 + .../icons_dark_theme/icon_advanced_active.svg | 69 + public/img/icons_dark_theme/icon_alert.svg | 16 + .../img/icons_dark_theme/icon_alert_alt.svg | 108 + .../img/icons_dark_theme/icon_alert_off.svg | 17 + .../img/icons_dark_theme/icon_alert_rules.svg | 14 + public/img/icons_dark_theme/icon_alerting.svg | 26 + .../icons_dark_theme/icon_alerting_active.svg | 43 + .../img/icons_dark_theme/icon_annotation.svg | 18 + .../icons_dark_theme/icon_annotation_alt.svg | 24 + public/img/icons_dark_theme/icon_apikeys.svg | 14 + .../img/icons_dark_theme/icon_arrow_left.svg | 1 + .../icon_arrow_left_circle.svg | 1 + public/img/icons_dark_theme/icon_cog.svg | 15 + .../img/icons_dark_theme/icon_dashboard.svg | 18 + .../icons_dark_theme/icon_dashboard_fav.svg | 30 + .../icons_dark_theme/icon_dashboard_list.svg | 26 + .../icons_dark_theme/icon_data_sources.svg | 15 + public/img/icons_dark_theme/icon_editor.svg | 19 + public/img/icons_dark_theme/icon_explore.svg | 24 + public/img/icons_dark_theme/icon_home.svg | 11 + .../icon_import_dashboard.svg | 30 + public/img/icons_dark_theme/icon_json.svg | 34 + public/img/icons_dark_theme/icon_link.svg | 22 + .../icons_dark_theme/icon_new_dashboard.svg | 32 + .../icon_notification_channels.svg | 14 + public/img/icons_dark_theme/icon_org.svg | 40 + public/img/icons_dark_theme/icon_playlist.svg | 17 + public/img/icons_dark_theme/icon_plugins.svg | 11 + .../img/icons_dark_theme/icon_preferences.svg | 17 + public/img/icons_dark_theme/icon_query.svg | 25 + .../icons_dark_theme/icon_query_active.svg | 46 + public/img/icons_dark_theme/icon_question.svg | 17 + public/img/icons_dark_theme/icon_remove.svg | 1 + public/img/icons_dark_theme/icon_reports.svg | 3 + public/img/icons_dark_theme/icon_shield.svg | 37 + public/img/icons_dark_theme/icon_sitemap.svg | 13 + .../img/icons_dark_theme/icon_snapshots.svg | 24 + public/img/icons_dark_theme/icon_team.svg | 20 + public/img/icons_dark_theme/icon_user.svg | 15 + public/img/icons_dark_theme/icon_variable.svg | 34 + public/img/icons_dark_theme/icon_viewer.svg | 17 + .../img/icons_dark_theme/icon_visualize.svg | 20 + .../icon_visualize_active.svg | 38 + public/img/icons_dark_theme/icon_zoom_out.svg | 15 + .../icons_light_theme/icon_add_annotation.svg | 31 + .../icon_add_annotation_alt.svg | 32 + .../icon_add_data_sources.svg | 32 + .../img/icons_light_theme/icon_add_folder.svg | 28 + .../img/icons_light_theme/icon_add_link.svg | 34 + .../icon_add_notification_channel.svg | 29 + .../img/icons_light_theme/icon_add_panel.svg | 32 + .../img/icons_light_theme/icon_add_team.svg | 36 + .../img/icons_light_theme/icon_add_user.svg | 28 + .../icons_light_theme/icon_add_variable.svg | 52 + .../img/icons_light_theme/icon_advanced.svg | 33 + .../icon_advanced_active.svg | 70 + public/img/icons_light_theme/icon_alert.svg | 16 + .../img/icons_light_theme/icon_alert_alt.svg | 108 + .../img/icons_light_theme/icon_alert_off.svg | 17 + .../icons_light_theme/icon_alert_rules.svg | 14 + .../img/icons_light_theme/icon_alerting.svg | 26 + .../icon_alerting_active.svg | 43 + .../img/icons_light_theme/icon_annotation.svg | 15 + .../icons_light_theme/icon_annotation_alt.svg | 24 + public/img/icons_light_theme/icon_apikeys.svg | 14 + .../img/icons_light_theme/icon_arrow_left.svg | 1 + .../icon_arrow_left_circle.svg | 1 + public/img/icons_light_theme/icon_cog.svg | 15 + .../img/icons_light_theme/icon_dashboard.svg | 18 + .../icons_light_theme/icon_dashboard_fav.svg | 28 + .../icons_light_theme/icon_dashboard_list.svg | 26 + .../icons_light_theme/icon_data_sources.svg | 15 + public/img/icons_light_theme/icon_editor.svg | 19 + public/img/icons_light_theme/icon_explore.svg | 24 + public/img/icons_light_theme/icon_home.svg | 11 + .../icon_import_dashboard.svg | 30 + public/img/icons_light_theme/icon_json.svg | 34 + public/img/icons_light_theme/icon_link.svg | 20 + .../icons_light_theme/icon_new_dashboard.svg | 32 + .../icon_notification_channels.svg | 14 + public/img/icons_light_theme/icon_org.svg | 40 + .../img/icons_light_theme/icon_playlist.svg | 17 + public/img/icons_light_theme/icon_plugins.svg | 11 + .../icons_light_theme/icon_preferences.svg | 17 + public/img/icons_light_theme/icon_query.svg | 25 + .../icons_light_theme/icon_query_active.svg | 46 + .../img/icons_light_theme/icon_question.svg | 16 + public/img/icons_light_theme/icon_remove.svg | 4 + public/img/icons_light_theme/icon_reports.svg | 3 + public/img/icons_light_theme/icon_shield.svg | 36 + public/img/icons_light_theme/icon_sitemap.svg | 13 + .../img/icons_light_theme/icon_snapshots.svg | 22 + public/img/icons_light_theme/icon_team.svg | 20 + public/img/icons_light_theme/icon_user.svg | 15 + .../img/icons_light_theme/icon_variable.svg | 33 + public/img/icons_light_theme/icon_viewer.svg | 17 + .../img/icons_light_theme/icon_visualize.svg | 22 + .../icon_visualize_active.svg | 38 + .../img/icons_light_theme/icon_zoom_out.svg | 15 + public/img/licensing/checkmark.svg | 3 + public/img/licensing/customer_support.svg | 5 + public/img/licensing/handinhand_support.svg | 11 + public/img/licensing/header_dark.svg | 53 + public/img/licensing/header_light.svg | 52 + public/img/licensing/plugin_enterprise.svg | 5 + public/img/licensing/sla.svg | 12 + public/img/light.png | Bin 0 -> 13306 bytes public/img/load.gif | Bin 0 -> 723 bytes public/img/load_big.gif | Bin 0 -> 2892 bytes public/img/loading-pulse.svg | 38 + public/img/login_background_dark.svg | 12745 ++++++++ public/img/login_background_light.svg | 6774 ++++ public/img/logo_transparent_200x.png | Bin 0 -> 8030 bytes public/img/logo_transparent_200x75.png | Bin 0 -> 8030 bytes public/img/logo_transparent_400x.png | Bin 0 -> 17505 bytes public/img/microsoft_auth_icon.svg | 1 + public/img/mixed_styles.png | Bin 0 -> 29915 bytes public/img/mstile-150x150.png | Bin 0 -> 9009 bytes public/img/okta_logo_white.png | Bin 0 -> 112599 bytes public/img/online.svg | 12 + public/img/page_header_line.png | Bin 0 -> 22189 bytes public/img/panel-tabs/alert-selected.svg | 9 + public/img/panel-tabs/alert.svg | 9 + public/img/panel-tabs/general-selected.svg | 31 + public/img/panel-tabs/general.svg | 8 + public/img/panel-tabs/queries-selected.svg | 31 + public/img/panel-tabs/queries.svg | 8 + .../img/panel-tabs/visualization-selected.svg | 34 + public/img/panel-tabs/visualization.svg | 7 + public/img/plugin-default-logo_dark.svg | 26 + public/img/plugin-default-logo_light.svg | 24 + public/img/plugins/appdynamics.svg | 17 + public/img/plugins/datadog.png | Bin 0 -> 8008 bytes public/img/plugins/dynatrace.png | Bin 0 -> 2327 bytes public/img/plugins/mongodb.svg | 2 + public/img/plugins/newrelic.svg | 2 + public/img/plugins/oracle.png | Bin 0 -> 74401 bytes public/img/plugins/servicenow.svg | 22 + public/img/plugins/snowflake.svg | 2 + public/img/plugins/splunk_logo_128.png | Bin 0 -> 4018 bytes public/img/plugins/wavefront.svg | 17 + public/img/rendering_error.png | Bin 0 -> 3160 bytes public/img/rendering_limit.png | Bin 0 -> 3858 bytes public/img/rendering_plugin_not_installed.png | Bin 0 -> 9094 bytes public/img/rendering_timeout.png | Bin 0 -> 3381 bytes public/img/transparent.png | Bin 0 -> 69 bytes public/img/user_profile.png | Bin 0 -> 1486 bytes public/img/warn-tiny.svg | 16 + public/img/warn.svg | 22 + public/robots.txt | 2 + public/sass/.sass-lint.yml | 100 + public/sass/_grafana.scss | 109 + public/sass/_old_responsive.scss | 10 + public/sass/_variables.dark.generated.scss | 406 + public/sass/_variables.generated.scss | 239 + public/sass/_variables.light.generated.scss | 395 + public/sass/base/_code.scss | 56 + public/sass/base/_font_awesome.scss | 18 + public/sass/base/_fonts.scss | 259 + public/sass/base/_forms.scss | 247 + public/sass/base/_grafana_icons.scss | 149 + public/sass/base/_grid.scss | 31 + public/sass/base/_icons.scss | 279 + public/sass/base/_normalize.scss | 425 + public/sass/base/_reboot.scss | 341 + public/sass/base/_type.scss | 403 + public/sass/base/font-awesome/_animated.scss | 34 + .../base/font-awesome/_bordered-pulled.scss | 41 + public/sass/base/font-awesome/_core.scss | 11 + .../sass/base/font-awesome/_fixed-width.scss | 6 + public/sass/base/font-awesome/_icons.scss | 2139 ++ public/sass/base/font-awesome/_larger.scss | 21 + public/sass/base/font-awesome/_list.scss | 21 + public/sass/base/font-awesome/_mixins.scss | 58 + public/sass/base/font-awesome/_path.scss | 15 + .../base/font-awesome/_rotated-flipped.scss | 30 + .../base/font-awesome/_screen-reader.scss | 9 + public/sass/base/font-awesome/_stacked.scss | 27 + public/sass/base/font-awesome/_variables.scss | 799 + public/sass/components/_add_data_source.scss | 90 + public/sass/components/_alerts.scss | 87 + public/sass/components/_buttons.scss | 292 + public/sass/components/_cards.scss | 217 + public/sass/components/_code_editor.scss | 84 + public/sass/components/_dashboard_grid.scss | 106 + public/sass/components/_dashboard_list.scss | 33 + .../sass/components/_dashboard_settings.scss | 103 + public/sass/components/_drop.scss | 94 + public/sass/components/_dropdown.scss | 334 + public/sass/components/_filter-controls.scss | 33 + public/sass/components/_filter-list.scss | 137 + public/sass/components/_filter-table.scss | 91 + public/sass/components/_footer.scss | 61 + public/sass/components/_gf-form.scss | 476 + public/sass/components/_infobox.scss | 47 + public/sass/components/_json_explorer.scss | 122 + public/sass/components/_jsontree.scss | 76 + public/sass/components/_modals.scss | 164 + public/sass/components/_navbar.scss | 32 + public/sass/components/_navs.scss | 96 + public/sass/components/_old_stuff.scss | 19 + public/sass/components/_page_header.scss | 165 + public/sass/components/_page_loader.scss | 16 + public/sass/components/_panel_alertlist.scss | 11 + public/sass/components/_panel_dashlist.scss | 37 + public/sass/components/_panel_editor.scss | 70 + .../components/_panel_gettingstarted.scss | 173 + public/sass/components/_panel_graph.scss | 431 + public/sass/components/_panel_header.scss | 202 + public/sass/components/_panel_heatmap.scss | 92 + public/sass/components/_panel_piechart.scss | 40 + public/sass/components/_panel_pluginlist.scss | 68 + public/sass/components/_panel_singlestat.scss | 31 + public/sass/components/_panel_table.scss | 151 + public/sass/components/_panel_text.scss | 0 public/sass/components/_popover-box.scss | 31 + public/sass/components/_query_editor.scss | 129 + public/sass/components/_query_part.scss | 29 + public/sass/components/_row.scss | 86 + public/sass/components/_scrollbar.scss | 294 + public/sass/components/_search.scss | 166 + public/sass/components/_shortcuts.scss | 44 + public/sass/components/_sidemenu.scss | 284 + public/sass/components/_slate_editor.scss | 162 + public/sass/components/_submenu.scss | 148 + public/sass/components/_switch.scss | 139 + public/sass/components/_tabbed_view.scss | 56 + public/sass/components/_tables_lists.scss | 43 + public/sass/components/_tabs.scss | 60 + public/sass/components/_tags.scss | 58 + public/sass/components/_tagsinput.scss | 87 + .../sass/components/_toggle_button_group.scss | 39 + public/sass/components/_toolbar.scss | 51 + public/sass/components/_tooltip.scss | 118 + public/sass/components/_typeahead.scss | 9 + public/sass/components/_view_states.scss | 66 + public/sass/components/edit_sidemenu.scss | 58 + public/sass/fonts.scss | 1 + public/sass/grafana.dark.scss | 3 + public/sass/grafana.light.scss | 3 + public/sass/icons.json | 52 + public/sass/layout/_lists.scss | 13 + public/sass/layout/_page.scss | 165 + public/sass/mixins/_animations.scss | 49 + public/sass/mixins/_breakpoints.scss | 86 + public/sass/mixins/_buttons.scss | 71 + public/sass/mixins/_drop_element.scss | 285 + public/sass/mixins/_forms.scss | 64 + public/sass/mixins/_grid-framework.scss | 48 + public/sass/mixins/_grid.scss | 74 + public/sass/mixins/_hover.scss | 67 + public/sass/mixins/_mixins.scss | 370 + public/sass/pages/_admin.scss | 27 + public/sass/pages/_alerting.scss | 159 + public/sass/pages/_dashboard.scss | 137 + public/sass/pages/_errorpage.scss | 106 + public/sass/pages/_explore.scss | 397 + public/sass/pages/_history.scss | 213 + public/sass/pages/_login.scss | 5 + public/sass/pages/_playlist.scss | 130 + public/sass/pages/_plugins.scss | 33 + public/sass/pages/_signup.scss | 16 + public/sass/utils/_angular.scss | 5 + public/sass/utils/_flex.scss | 58 + public/sass/utils/_hacks.scss | 25 + public/sass/utils/_spacings.scss | 49 + public/sass/utils/_utils.scss | 116 + public/sass/utils/_validation.scss | 8 + public/sass/utils/_widths.scss | 32 + public/test/.jshintrc | 41 + public/test/core/redux/mocks.ts | 12 + public/test/core/redux/reducerTester.test.ts | 56 + public/test/core/redux/reducerTester.ts | 112 + public/test/core/redux/reduxTester.ts | 146 + public/test/core/thunk/thunkTester.ts | 44 + .../test/core/utils/silenceConsoleOutput.ts | 17 + public/test/core/utils/version.test.ts | 54 + public/test/helpers/convertToStoreState.ts | 12 + public/test/helpers/createFetchResponse.ts | 15 + public/test/helpers/fieldConfig.ts | 103 + public/test/helpers/getDashboardModel.ts | 6 + public/test/helpers/getQueryOptions.ts | 27 + .../helpers/getTemplateSrvDependencies.ts | 9 + public/test/helpers/initTemplateSrv.ts | 12 + public/test/jest-setup.ts | 52 + public/test/jest-shim.ts | 17 + public/test/lib/common.ts | 15 + public/test/matchers/index.ts | 9 + public/test/matchers/toEmitValues.test.ts | 139 + public/test/matchers/toEmitValues.ts | 88 + public/test/matchers/toEmitValuesWith.test.ts | 153 + public/test/matchers/toEmitValuesWith.ts | 56 + public/test/matchers/types.ts | 13 + public/test/matchers/utils.ts | 62 + public/test/mocks/angular.ts | 17 + public/test/mocks/backend_srv.ts | 5 + public/test/mocks/common.ts | 55 + public/test/mocks/dashboard-mock.js | 25 + public/test/mocks/datasource_srv.ts | 53 + public/test/mocks/monaco.ts | 1 + public/test/mocks/style.ts | 1 + public/test/mocks/svg.ts | 1 + public/test/setupTests.ts | 4 + public/test/specs/helpers.ts | 219 + public/views/error-template.html | 57 + public/views/index-template.html | 303 + scripts/build/build.sh | 158 + scripts/build/ci-build-windows/Dockerfile | 36 + scripts/build/ci-build-windows/Magefile.go | 27 + scripts/build/ci-build-windows/README.md | 15 + scripts/build/ci-build/.gitignore | 1 + scripts/build/ci-build/Dockerfile | 181 + scripts/build/ci-build/Makefile | 54 + scripts/build/ci-build/README.md | 28 + scripts/build/ci-build/bootstrap.sh | 5 + scripts/build/ci-build/build-deploy.sh | 11 + scripts/build/ci-deploy/Dockerfile | 44 + scripts/build/ci-deploy/build-deploy.sh | 8 + scripts/build/ci-e2e/Dockerfile | 11 + scripts/build/ci-msi-build/Dockerfile | 13 + scripts/build/ci-msi-build/README.md | 44 + scripts/build/ci-msi-build/ci-msi-build-ee.sh | 29 + .../build/ci-msi-build/ci-msi-build-oss.sh | 34 + .../build/ci-msi-build/msigenerator/Makefile | 5 + .../msigenerator/cache/nssm-2.24.zip | Bin 0 -> 351790 bytes .../ci-msi-build/msigenerator/ci-wrapper.sh | 28 + .../msigenerator/docker-compose.yml | 13 + .../msigenerator/generator/build.py | 356 + .../msigenerator/generator/utils.py | 126 + .../msigenerator/light.exe.config | 13 + .../msigenerator/requirements.txt | 3 + .../images/grafana_dialog_background.bmp | Bin 0 -> 615402 bytes .../images/grafana_dialog_background.png | Bin 0 -> 267052 bytes .../resources/images/grafana_icon.ico | Bin 0 -> 260157 bytes .../resources/images/grafana_top_banner.bmp | Bin 0 -> 114514 bytes .../resources/images/grafana_top_banner.png | Bin 0 -> 13240 bytes .../images/grafana_top_banner_white.bmp | Bin 0 -> 114514 bytes .../resources/license/EE_LICENSE.rtf | 1332 + .../msigenerator/resources/license/LICENSE.md | 202 + .../resources/license/LICENSE.rtf | 206 + .../templates/common/grafana-firewall.wxs.j2 | 19 + .../templates/common/grafana-service.wxs.j2 | 59 + .../templates/common/product.wxs.j2 | 54 + .../oracle/oracle-environment.wxs.j2 | 12 + .../ci-msi-build/msigenerator/wrapper.sh | 3 + scripts/build/ci-msi-build/testbuild.sh | 11 + scripts/build/ci-wix/Dockerfile | 18 + scripts/build/ci-wix/Magefile.go | 27 + scripts/build/ci-wix/README.md | 15 + scripts/build/gpg-test-vars.sh | 5 + scripts/build/old_appveyor.yml | 40 + scripts/build/prepare_signing_key.sh | 5 + scripts/build/publish.sh | 18 + scripts/build/release-packages.sh | 74 + .../release_publisher/externalrelease.go | 71 + scripts/build/release_publisher/main.go | 80 + scripts/build/release_publisher/publisher.go | 312 + .../build/release_publisher/publisher_test.go | 202 + ...nterprise-5.4.0-123pre1.linux-amd64.tar.gz | 0 ...se-5.4.0-123pre1.linux-amd64.tar.gz.sha256 | 1 + ...enterprise-5.4.0-123pre1.windows-amd64.msi | 0 ...ise-5.4.0-123pre1.windows-amd64.msi.sha256 | 1 + ...enterprise-5.4.0-123pre1.windows-amd64.zip | 0 ...ise-5.4.0-123pre1.windows-amd64.zip.sha256 | 1 + ...rafana-enterprise-5.4.0-123pre1.x86_64.rpm | 0 ...enterprise-5.4.0-123pre1.x86_64.rpm.sha256 | 1 + ...grafana-enterprise_5.4.0-123pre1_amd64.deb | 0 ...-enterprise_5.4.0-123pre1_amd64.deb.sha256 | 1 + scripts/build/rpmmacros | 5 + scripts/build/sign_packages.sh | 25 + scripts/build/update_repo/README.md | 29 + scripts/build/update_repo/aptly.conf | 27 + scripts/build/update_repo/init-deb-repo.sh | 15 + scripts/build/update_repo/load-signing-key.sh | 5 + scripts/build/update_repo/publish-deb.sh | 30 + scripts/build/update_repo/publish-rpm.sh | 41 + scripts/build/update_repo/sign-rpm-repo.sh | 7 + .../update_repo/test-publish-deb-repo.sh | 3 + .../update_repo/test-publish-rpm-repo.sh | 3 + .../build/update_repo/test-update-deb-repo.sh | 5 + .../build/update_repo/test-update-rpm-repo.sh | 5 + scripts/build/update_repo/unlock-gpg-key.sh | 7 + scripts/build/update_repo/update-deb.sh | 57 + scripts/build/update_repo/update-rpm.sh | 57 + scripts/build/verify_signed_packages.sh | 24 + scripts/ci-check-strict.sh | 13 + scripts/ci-frontend-metrics.sh | 30 + scripts/ci-job-failed.sh | 16 + scripts/ci-job-started.sh | 6 + scripts/ci-job-succeeded.sh | 16 + scripts/ci-reference-docs-build.sh | 23 + scripts/ci-reference-docs-lint.sh | 44 + scripts/circle-release-canary-packages.sh | 34 + scripts/circle-test-backend.sh | 13 + scripts/circle-test-cache-servers.sh | 9 + scripts/circle-test-frontend.sh | 20 + scripts/circle-test-mysql.sh | 10 + scripts/circle-test-postgres.sh | 10 + scripts/cli/generateSassVariableFiles.ts | 39 + scripts/cli/tsconfig.json | 6 + scripts/go/configs/.golangci.toml | 132 + scripts/go/configs/revive-strict.toml | 26 + scripts/go/configs/revive.toml | 25 + scripts/go/go.mod | 13 + scripts/go/go.sum | 751 + scripts/go/tools.go | 9 + scripts/helpers/exit-if-fail.sh | 13 + scripts/import_many_dashboards.sh | 8 + scripts/lib.star | 1071 + scripts/master.star | 163 + scripts/mixin-check.sh | 7 + scripts/pr.star | 88 + scripts/protobuf-check.sh | 25 + scripts/release.star | 216 + scripts/revive-strict | 15 + scripts/tag_release.sh | 41 + scripts/trigger_docker_build.sh | 23 + scripts/trigger_grafana_packer.sh | 10 + scripts/trigger_windows_build.sh | 27 + scripts/verify-repo-update/Dockerfile.deb | 15 + scripts/verify-repo-update/Dockerfile.rpm | 8 + scripts/verify-repo-update/deb-ee-beta.list | 1 + scripts/verify-repo-update/deb-ee-stable.list | 1 + scripts/verify-repo-update/deb-oss-beta.list | 1 + .../verify-repo-update/deb-oss-stable.list | 1 + scripts/verify-repo-update/rpm-ee-beta.list | 9 + scripts/verify-repo-update/rpm-ee-stable.list | 9 + scripts/verify-repo-update/rpm-oss-beta.list | 9 + .../verify-repo-update/rpm-oss-stable.list | 9 + scripts/verify-repo-update/verify-repos.sh | 58 + scripts/version.star | 11 + scripts/webpack/dependencies.js | 14 + scripts/webpack/loaders/blobUrl.js | 8 + scripts/webpack/loaders/compile.js | 101 + scripts/webpack/postcss.config.js | 9 + scripts/webpack/sass.rule.js | 33 + scripts/webpack/webpack.common.js | 223 + scripts/webpack/webpack.dev.js | 133 + scripts/webpack/webpack.hot.js | 138 + scripts/webpack/webpack.prod.js | 129 + tools/.gitkeep | 0 tsconfig.json | 25 + yarn.lock | 26898 ++++++++++++++++ 5760 files changed, 793879 insertions(+) create mode 100644 .babelrc create mode 100644 .bra.toml create mode 100644 .browserslistrc create mode 100644 .circleci/config.yml create mode 100644 .dockerignore create mode 100644 .drone.star create mode 100644 .drone.yml create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/1-bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/2-feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/3-accessibility.md create mode 100644 .github/ISSUE_TEMPLATE/4-grafana_ui_component.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/bot.md create mode 100644 .github/commands.json create mode 100644 .github/dependabot.yml create mode 100644 .github/metrics-collector.json create mode 100644 .github/stale.yml create mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/bump-version.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/commands.yml create mode 100644 .github/workflows/github-release.yml create mode 100644 .github/workflows/metrics-collector.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/update-changelog.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.js create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG_ARCHIVE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 Dockerfile.ubuntu create mode 100644 GOVERNANCE.md create mode 100644 ISSUE_TRIAGE.md create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 Makefile create mode 100644 NOTICE.md create mode 100644 PLUGIN_DEV.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 UPGRADING_DEPENDENCIES.md create mode 100644 WORKFLOW.md create mode 100644 api-extractor.json create mode 100644 build.go create mode 100644 conf/defaults.ini create mode 100644 conf/ldap.toml create mode 100644 conf/ldap_multiple.toml create mode 100644 conf/provisioning/dashboards/sample.yaml create mode 100644 conf/provisioning/datasources/sample.yaml create mode 100644 conf/provisioning/notifiers/sample.yaml create mode 100644 conf/provisioning/plugins/sample.yaml create mode 100644 conf/sample.ini create mode 100644 contribute/README.md create mode 100644 contribute/architecture/README.md create mode 100644 contribute/architecture/communication.md create mode 100644 contribute/architecture/database.md create mode 100644 contribute/architecture/frontend-data-requests.md create mode 100644 contribute/architecture/services.md create mode 100644 contribute/create-pull-request.md create mode 100644 contribute/developer-guide.md create mode 100644 contribute/documentation.md create mode 100644 contribute/engineering/terminology.md create mode 100644 contribute/style-guides/backend.md create mode 100644 contribute/style-guides/code-comments.md create mode 100644 contribute/style-guides/documentation-markdown-guide.md create mode 100644 contribute/style-guides/documentation-style-guide.md create mode 100644 contribute/style-guides/e2e-core.md create mode 100644 contribute/style-guides/e2e-plugins.md create mode 100644 contribute/style-guides/e2e.md create mode 100644 contribute/style-guides/frontend.md create mode 100644 contribute/style-guides/redux.md create mode 100644 contribute/style-guides/storybook.md create mode 100644 contribute/style-guides/styling.md create mode 100644 contribute/style-guides/themes.md create mode 100644 contribute/templates/README.md create mode 100644 contribute/templates/doc-concept-template.md create mode 100644 contribute/templates/doc-reference-template.md create mode 100644 contribute/templates/doc-task-template.md create mode 100644 contribute/triage-issues.md create mode 100644 dashboard-schemas/Dashboard.cue create mode 100644 dashboard-schemas/README.md create mode 100644 dashboard-schemas/cue.mod/module.cue create mode 100644 dashboard-schemas/go.mod create mode 100644 dashboard-schemas/go.sum create mode 100644 dashboard-schemas/main.go create mode 100644 dashboard-schemas/main_test.go create mode 100644 dashboard-schemas/panels/Gauge.cue create mode 100644 dashboard-schemas/panels/Graph.cue create mode 100644 dashboard-schemas/panels/Row.cue create mode 100644 dashboard-schemas/panels/gridPos.cue create mode 100644 dashboard-schemas/panels/link.cue create mode 100644 dashboard-schemas/panels/mapping.cue create mode 100644 dashboard-schemas/panels/override.cue create mode 100644 dashboard-schemas/panels/panel.cue create mode 100644 dashboard-schemas/panels/thresholds.cue create mode 100644 dashboard-schemas/targets/Prometheus.cue create mode 100644 dashboard-schemas/transformations/CalculateField.cue create mode 100644 dashboard-schemas/transformations/Organize.cue create mode 100644 dashboard-schemas/variables/Custom.cue create mode 100644 dashboard-schemas/variables/Datasource.cue create mode 100644 dashboard-schemas/variables/Query.cue create mode 100644 dashboard-schemas/variables/variable.cue create mode 100644 devenv/README.md create mode 100755 devenv/benchmarks/ab/ab_test.sh create mode 100644 devenv/bulk-dashboards/bulk-dashboards.yaml create mode 100644 devenv/bulk-dashboards/bulkdash.jsonnet create mode 100644 devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml create mode 100644 devenv/bulk_alerting_dashboards/dashboard.libsonnet create mode 100644 devenv/bulk_alerting_dashboards/datasources.jsonnet create mode 100755 devenv/create_docker_compose.sh create mode 100644 devenv/dashboards.yaml create mode 100644 devenv/datasources.yaml create mode 100644 devenv/datasources_docker.yaml create mode 100644 devenv/dev-dashboards-without-uid/panel_tests_graph.json create mode 100644 devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json create mode 100644 devenv/dev-dashboards-without-uid/panel_tests_polystat.json create mode 100644 devenv/dev-dashboards/alerting/testdata_alerts.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_compare.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v2.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v5.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v6.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v6_filebeat.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v7.json create mode 100644 devenv/dev-dashboards/datasource-elasticsearch/elasticsearch_v7_filebeat.json create mode 100644 devenv/dev-dashboards/datasource-influxdb/influxdb-logs.json create mode 100644 devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json create mode 100644 devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json create mode 100644 devenv/dev-dashboards/datasource-mssql/mssql_unittest.json create mode 100644 devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json create mode 100644 devenv/dev-dashboards/datasource-mysql/mysql_unittest.json create mode 100644 devenv/dev-dashboards/datasource-opentsdb/opentsdb.json create mode 100644 devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json create mode 100644 devenv/dev-dashboards/datasource-postgres/postgres_unittest.json create mode 100644 devenv/dev-dashboards/datasource-testdata/bar-gauge-demo2.json create mode 100644 devenv/dev-dashboards/datasource-testdata/demo1.json create mode 100644 devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json create mode 100644 devenv/dev-dashboards/datasource-testdata/new_features_in_v74.json create mode 100644 devenv/dev-dashboards/feature-templating/global-variables-and-interpolation.json create mode 100644 devenv/dev-dashboards/feature-templating/templating-dashboard-links-and-variables.json create mode 100644 devenv/dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-datalinks.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-nested-variables-drilldown.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-nested-variables.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-repeating.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-variables-textbox.json create mode 100644 devenv/dev-dashboards/feature-templating/testdata-variables-that-update-on-time-change.json create mode 100644 devenv/dev-dashboards/home.json create mode 100644 devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json create mode 100644 devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json create mode 100644 devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json create mode 100644 devenv/dev-dashboards/panel-common/auto_decimals.json create mode 100644 devenv/dev-dashboards/panel-common/color_modes.json create mode 100644 devenv/dev-dashboards/panel-common/lazy_loading.json create mode 100644 devenv/dev-dashboards/panel-common/linked-viz.json create mode 100644 devenv/dev-dashboards/panel-common/panels_without_title.json create mode 100644 devenv/dev-dashboards/panel-common/shared_queries.json create mode 100644 devenv/dev-dashboards/panel-gauge/gauge-multi-series.json create mode 100644 devenv/dev-dashboards/panel-gauge/gauge_tests.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-gradient-area.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-hue-gradients.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-nulls.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-soft-limits.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-time.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng-yaxis-ticks.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-ng.json create mode 100644 devenv/dev-dashboards/panel-graph/graph-time-regions.json create mode 100644 devenv/dev-dashboards/panel-graph/graph_tests.json create mode 100644 devenv/dev-dashboards/panel-graph/graph_y_axis.json create mode 100644 devenv/dev-dashboards/panel-library/panel-library.json create mode 100644 devenv/dev-dashboards/panel-piechart/panel_test_piechart.json create mode 100644 devenv/dev-dashboards/panel-polystat/polystat_test.json create mode 100644 devenv/dev-dashboards/panel-singlestat/singlestat_test.json create mode 100644 devenv/dev-dashboards/panel-stat/panel-stat-tests.json create mode 100644 devenv/dev-dashboards/panel-table/table_tests.json create mode 100644 devenv/dev-dashboards/panel-table/table_tests_new.json create mode 100644 devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json create mode 100644 devenv/dev-dashboards/scenarios/time_zone_support.json create mode 100644 devenv/docker/blocks/alert_webhook_listener/Dockerfile create mode 100644 devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml create mode 100644 devenv/docker/blocks/alert_webhook_listener/main.go create mode 100644 devenv/docker/blocks/apache_proxy/Dockerfile create mode 100644 devenv/docker/blocks/apache_proxy/docker-compose.yaml create mode 100644 devenv/docker/blocks/apache_proxy/ports.conf create mode 100644 devenv/docker/blocks/apache_proxy/proxy.conf create mode 100644 devenv/docker/blocks/apache_proxy_mac/Dockerfile create mode 100644 devenv/docker/blocks/apache_proxy_mac/docker-compose.yaml create mode 100644 devenv/docker/blocks/apache_proxy_mac/ports.conf create mode 100644 devenv/docker/blocks/apache_proxy_mac/proxy.conf create mode 100644 devenv/docker/blocks/collectd/Dockerfile create mode 100644 devenv/docker/blocks/collectd/README.md create mode 100644 devenv/docker/blocks/collectd/collectd.conf.tpl create mode 100644 devenv/docker/blocks/collectd/docker-compose.yaml create mode 100644 devenv/docker/blocks/collectd/etc_mtab create mode 100644 devenv/docker/blocks/collectd/start_container create mode 100644 devenv/docker/blocks/elastic/docker-compose.yaml create mode 100644 devenv/docker/blocks/elastic/elasticsearch.yml create mode 100644 devenv/docker/blocks/elastic1/docker-compose.yaml create mode 100644 devenv/docker/blocks/elastic1/elasticsearch.yml create mode 100644 devenv/docker/blocks/elastic5/docker-compose.yaml create mode 100644 devenv/docker/blocks/elastic5/elasticsearch.yml create mode 100644 devenv/docker/blocks/elastic6/docker-compose.yaml create mode 100644 devenv/docker/blocks/elastic6/elasticsearch.yml create mode 100644 devenv/docker/blocks/elastic6/filebeat.yml create mode 100644 devenv/docker/blocks/elastic7/docker-compose.yaml create mode 100644 devenv/docker/blocks/elastic7/elasticsearch.yml create mode 100644 devenv/docker/blocks/elastic7/filebeat.yml create mode 100644 devenv/docker/blocks/elastic7/metricbeat.yml create mode 100644 devenv/docker/blocks/freeipa/docker-compose.yaml create mode 100644 devenv/docker/blocks/freeipa/ldap_freeipa.toml create mode 100644 devenv/docker/blocks/freeipa/notes.md create mode 100644 devenv/docker/blocks/grafana/.env create mode 100644 devenv/docker/blocks/grafana/docker-compose.yaml create mode 100644 devenv/docker/blocks/graphite/Dockerfile create mode 100644 devenv/docker/blocks/graphite/docker-compose.yaml create mode 100644 devenv/docker/blocks/graphite/files/carbon.conf create mode 100644 devenv/docker/blocks/graphite/files/events_views.py create mode 100644 devenv/docker/blocks/graphite/files/initial_data.json create mode 100644 devenv/docker/blocks/graphite/files/local_settings.py create mode 100644 devenv/docker/blocks/graphite/files/my_htpasswd create mode 100644 devenv/docker/blocks/graphite/files/nginx.conf create mode 100644 devenv/docker/blocks/graphite/files/statsd_config.js create mode 100644 devenv/docker/blocks/graphite/files/storage-aggregation.conf create mode 100644 devenv/docker/blocks/graphite/files/storage-schemas.conf create mode 100644 devenv/docker/blocks/graphite/files/supervisord.conf create mode 100644 devenv/docker/blocks/graphite1/Dockerfile create mode 100644 devenv/docker/blocks/graphite1/big-dashboard.json create mode 100644 devenv/docker/blocks/graphite1/conf/etc/logrotate.d/graphite-statsd create mode 100755 devenv/docker/blocks/graphite1/conf/etc/my_init.d/01_conf_init.sh create mode 100644 devenv/docker/blocks/graphite1/conf/etc/nginx/nginx.conf create mode 100644 devenv/docker/blocks/graphite1/conf/etc/nginx/sites-enabled/graphite-statsd.conf create mode 100755 devenv/docker/blocks/graphite1/conf/etc/service/carbon-aggregator/run create mode 100755 devenv/docker/blocks/graphite1/conf/etc/service/carbon/run create mode 100755 devenv/docker/blocks/graphite1/conf/etc/service/graphite/run create mode 100755 devenv/docker/blocks/graphite1/conf/etc/service/nginx/run create mode 100755 devenv/docker/blocks/graphite1/conf/etc/service/statsd/run create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/blacklist.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/graphTemplates.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/relay-rules.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/rewrite-rules.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-aggregation.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-schemas.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/conf/whitelist.conf create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/app_settings.py create mode 100644 devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/local_settings.py create mode 100644 devenv/docker/blocks/graphite1/conf/opt/statsd/config.js create mode 100755 devenv/docker/blocks/graphite1/conf/usr/local/bin/django_admin_init.exp create mode 100755 devenv/docker/blocks/graphite1/conf/usr/local/bin/manage.sh create mode 100644 devenv/docker/blocks/graphite1/docker-compose.yaml create mode 100644 devenv/docker/blocks/graphite11/big-dashboard.json create mode 100644 devenv/docker/blocks/graphite11/docker-compose.yaml create mode 100644 devenv/docker/blocks/influxdb/.env create mode 100644 devenv/docker/blocks/influxdb/docker-compose.yaml create mode 100644 devenv/docker/blocks/influxdb/influxdb.conf create mode 100644 devenv/docker/blocks/influxdb/telegraf.conf create mode 100644 devenv/docker/blocks/influxdb2/docker-compose.yaml create mode 100644 devenv/docker/blocks/influxdb2/influxdb.conf create mode 100644 devenv/docker/blocks/influxdb2/telegraf.conf create mode 100644 devenv/docker/blocks/jaeger/docker-compose.yaml create mode 100644 devenv/docker/blocks/loki/config.yaml create mode 100644 devenv/docker/blocks/loki/docker-compose.yaml create mode 100644 devenv/docker/blocks/memcached/docker-compose.yaml create mode 100644 devenv/docker/blocks/mssql/build/Dockerfile create mode 100644 devenv/docker/blocks/mssql/build/entrypoint.sh create mode 100755 devenv/docker/blocks/mssql/build/setup.sh create mode 100644 devenv/docker/blocks/mssql/build/setup.sql.template create mode 100644 devenv/docker/blocks/mssql/docker-compose.yaml create mode 100644 devenv/docker/blocks/mssql_tests/docker-compose.yaml create mode 100644 devenv/docker/blocks/multiple-openldap/admins-ldap-server.Dockerfile create mode 100644 devenv/docker/blocks/multiple-openldap/admins-ldap-server/modules/memberof.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/admins-ldap-server/prepopulate/1_units.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/admins-ldap-server/prepopulate/2_users.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/admins-ldap-server/prepopulate/3_groups.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/docker-compose.yaml create mode 100755 devenv/docker/blocks/multiple-openldap/entrypoint.sh create mode 100644 devenv/docker/blocks/multiple-openldap/ldap-server.Dockerfile create mode 100644 devenv/docker/blocks/multiple-openldap/ldap-server/modules/memberof.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/ldap-server/prepopulate/1_units.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/ldap-server/prepopulate/2_users.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/ldap-server/prepopulate/3_groups.ldif create mode 100644 devenv/docker/blocks/multiple-openldap/ldap_dev.toml create mode 100644 devenv/docker/blocks/multiple-openldap/notes.md create mode 100755 devenv/docker/blocks/multiple-openldap/prepopulate.sh create mode 100644 devenv/docker/blocks/mysql/.env create mode 100644 devenv/docker/blocks/mysql/config create mode 100644 devenv/docker/blocks/mysql/docker-compose.yaml create mode 100644 devenv/docker/blocks/mysql_opendata/Dockerfile create mode 100644 devenv/docker/blocks/mysql_opendata/docker-compose.yaml create mode 100644 devenv/docker/blocks/mysql_opendata/import_csv.sql create mode 100644 devenv/docker/blocks/mysql_tests/.env create mode 100644 devenv/docker/blocks/mysql_tests/Dockerfile create mode 100644 devenv/docker/blocks/mysql_tests/docker-compose.yaml create mode 100644 devenv/docker/blocks/mysql_tests/setup.sql create mode 100644 devenv/docker/blocks/nginx_proxy/Dockerfile create mode 100644 devenv/docker/blocks/nginx_proxy/docker-compose.yaml create mode 100755 devenv/docker/blocks/nginx_proxy/htpasswd create mode 100644 devenv/docker/blocks/nginx_proxy/nginx.conf create mode 100644 devenv/docker/blocks/nginx_proxy/nginx_login_only.conf create mode 100644 devenv/docker/blocks/nginx_proxy_mac/Dockerfile create mode 100644 devenv/docker/blocks/nginx_proxy_mac/docker-compose.yaml create mode 100755 devenv/docker/blocks/nginx_proxy_mac/htpasswd create mode 100644 devenv/docker/blocks/nginx_proxy_mac/nginx.conf create mode 100644 devenv/docker/blocks/nginx_proxy_mac/nginx_login_only.conf create mode 100644 devenv/docker/blocks/openldap/Dockerfile create mode 100644 devenv/docker/blocks/openldap/docker-compose.yaml create mode 100755 devenv/docker/blocks/openldap/entrypoint.sh create mode 100644 devenv/docker/blocks/openldap/ldap.conf create mode 100644 devenv/docker/blocks/openldap/ldap_dev.toml create mode 100644 devenv/docker/blocks/openldap/ldap_posix_dev.toml create mode 100644 devenv/docker/blocks/openldap/modules/memberof.ldif create mode 100644 devenv/docker/blocks/openldap/notes.md create mode 100755 devenv/docker/blocks/openldap/prepopulate.sh create mode 100644 devenv/docker/blocks/openldap/prepopulate/1_units.ldif create mode 100644 devenv/docker/blocks/openldap/prepopulate/2_users.ldif create mode 100644 devenv/docker/blocks/openldap/prepopulate/3_groups.ldif create mode 100644 devenv/docker/blocks/opentsdb/docker-compose.yaml create mode 100644 devenv/docker/blocks/postgres/.env create mode 100644 devenv/docker/blocks/postgres/docker-compose.yaml create mode 100644 devenv/docker/blocks/postgres_tests/.env create mode 100644 devenv/docker/blocks/postgres_tests/Dockerfile create mode 100644 devenv/docker/blocks/postgres_tests/docker-compose.yaml create mode 100644 devenv/docker/blocks/postgres_tests/setup.sql create mode 100644 devenv/docker/blocks/prometheus/Dockerfile create mode 100644 devenv/docker/blocks/prometheus/alert.rules create mode 100644 devenv/docker/blocks/prometheus/docker-compose.yaml create mode 100644 devenv/docker/blocks/prometheus/prometheus.yml create mode 100644 devenv/docker/blocks/prometheus2/Dockerfile create mode 100644 devenv/docker/blocks/prometheus2/alert.yml create mode 100644 devenv/docker/blocks/prometheus2/docker-compose.yaml create mode 100644 devenv/docker/blocks/prometheus2/prometheus.yml create mode 100644 devenv/docker/blocks/prometheus2/recording.yml create mode 100644 devenv/docker/blocks/prometheus_basic_auth_proxy/Dockerfile create mode 100644 devenv/docker/blocks/prometheus_basic_auth_proxy/docker-compose.yaml create mode 100755 devenv/docker/blocks/prometheus_basic_auth_proxy/htpasswd create mode 100644 devenv/docker/blocks/prometheus_basic_auth_proxy/nginx.conf create mode 100644 devenv/docker/blocks/prometheus_random_data/Dockerfile create mode 100644 devenv/docker/blocks/redis/docker-compose.yaml create mode 100644 devenv/docker/blocks/saml/docker-compose.yaml create mode 100644 devenv/docker/blocks/saml/notes.md create mode 100644 devenv/docker/blocks/saml/users.php create mode 100644 devenv/docker/blocks/sensugo/backend.yml create mode 100644 devenv/docker/blocks/sensugo/docker-compose.yaml create mode 100644 devenv/docker/blocks/sensugo/notes.md create mode 100644 devenv/docker/blocks/slow_proxy/Dockerfile create mode 100644 devenv/docker/blocks/slow_proxy/docker-compose.yaml create mode 100644 devenv/docker/blocks/slow_proxy/main.go create mode 100644 devenv/docker/blocks/slow_proxy_mac/Dockerfile create mode 100644 devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml create mode 100644 devenv/docker/blocks/slow_proxy_mac/main.go create mode 100644 devenv/docker/blocks/smtp/Dockerfile create mode 100755 devenv/docker/blocks/smtp/bootstrap.sh create mode 100644 devenv/docker/blocks/smtp/docker-compose.yaml create mode 100644 devenv/docker/blocks/traefik/configs/subpath_no_rewrite.yml create mode 100644 devenv/docker/blocks/traefik/docker-compose.yml create mode 100644 devenv/docker/blocks/traefik/traefik.yml create mode 100644 devenv/docker/blocks/zipkin/docker-compose.yaml create mode 100644 devenv/docker/buildcontainer/Dockerfile create mode 100755 devenv/docker/buildcontainer/build.sh create mode 100755 devenv/docker/buildcontainer/build_circle.sh create mode 100755 devenv/docker/buildcontainer/run_circle.sh create mode 100644 devenv/docker/compose_header.yml create mode 100644 devenv/docker/debtest/Dockerfile create mode 100755 devenv/docker/debtest/build.sh create mode 100644 devenv/docker/ha_test/.gitignore create mode 100644 devenv/docker/ha_test/README.md create mode 100755 devenv/docker/ha_test/alerts.sh create mode 100644 devenv/docker/ha_test/docker-compose.yaml create mode 100644 devenv/docker/ha_test/fluentd/fluentd.conf create mode 100644 devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet create mode 100644 devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json create mode 100644 devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json create mode 100644 devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml create mode 100644 devenv/docker/ha_test/prometheus/prometheus.yml create mode 100644 devenv/docker/loadtest/README.md create mode 100644 devenv/docker/loadtest/annotations_by_tag_test.js create mode 100644 devenv/docker/loadtest/auth_key_test.js create mode 100644 devenv/docker/loadtest/auth_proxy_test.js create mode 100644 devenv/docker/loadtest/auth_token_slow_test.js create mode 100644 devenv/docker/loadtest/auth_token_test.js create mode 100644 devenv/docker/loadtest/modules/client.js create mode 100644 devenv/docker/loadtest/modules/util.js create mode 100755 devenv/docker/loadtest/run.sh create mode 100755 devenv/docker/rpmtest/build.sh create mode 100644 devenv/e2e-api-tests/clearState.test.ts create mode 100644 devenv/e2e-api-tests/client.ts create mode 100644 devenv/e2e-api-tests/dashboard.test.ts create mode 100644 devenv/e2e-api-tests/folder.test.ts create mode 100644 devenv/e2e-api-tests/jest.js create mode 100644 devenv/e2e-api-tests/search.test.ts create mode 100644 devenv/e2e-api-tests/setup.ts create mode 100644 devenv/e2e-api-tests/tsconfig.json create mode 100644 devenv/e2e-api-tests/user.test.ts create mode 100644 devenv/local-npm/conf/nginx/Dockerfile create mode 100644 devenv/local-npm/conf/nginx/sites-enabled/verdaccio-conf create mode 100644 devenv/local-npm/docker-compose.yaml create mode 100755 devenv/setup.sh create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/README.md create mode 100644 docs/logo-horizontal.png create mode 100755 docs/sources/_index.md create mode 100644 docs/sources/administration/_index.md create mode 100644 docs/sources/administration/cli.md create mode 100644 docs/sources/administration/configuration.md create mode 100644 docs/sources/administration/configure-docker.md create mode 100755 docs/sources/administration/image_rendering.md create mode 100644 docs/sources/administration/jaeger-instrumentation.md create mode 100644 docs/sources/administration/preferences/_index.md create mode 100644 docs/sources/administration/preferences/change-grafana-name.md create mode 100644 docs/sources/administration/preferences/change-grafana-theme.md create mode 100644 docs/sources/administration/preferences/change-grafana-timezone.md create mode 100644 docs/sources/administration/preferences/change-home-dashboard.md create mode 100644 docs/sources/administration/provisioning.md create mode 100644 docs/sources/administration/security.md create mode 100644 docs/sources/administration/set-up-for-high-availability.md create mode 100644 docs/sources/administration/view-server/_index.md create mode 100644 docs/sources/administration/view-server/internal-metrics.md create mode 100644 docs/sources/administration/view-server/view-server-settings.md create mode 100644 docs/sources/administration/view-server/view-server-stats.md create mode 100644 docs/sources/alerting/_index.md create mode 100644 docs/sources/alerting/add-notification-template.md create mode 100644 docs/sources/alerting/create-alerts.md create mode 100644 docs/sources/alerting/notifications.md create mode 100644 docs/sources/alerting/pause-an-alert-rule.md create mode 100644 docs/sources/alerting/troubleshoot-alerts.md create mode 100644 docs/sources/alerting/view-alerts.md create mode 100644 docs/sources/auth/_index.md create mode 100755 docs/sources/auth/auth-proxy.md create mode 100644 docs/sources/auth/azuread.md create mode 100755 docs/sources/auth/enhanced_ldap.md create mode 100755 docs/sources/auth/generic-oauth.md create mode 100644 docs/sources/auth/github.md create mode 100644 docs/sources/auth/gitlab.md create mode 100644 docs/sources/auth/google.md create mode 100644 docs/sources/auth/grafana.md create mode 100644 docs/sources/auth/jwt.md create mode 100644 docs/sources/auth/ldap.md create mode 100644 docs/sources/auth/okta.md create mode 100644 docs/sources/auth/overview.md create mode 100644 docs/sources/auth/saml.md create mode 100644 docs/sources/auth/team-sync.md create mode 100644 docs/sources/basics/_index.md create mode 100644 docs/sources/basics/glossary.md create mode 100644 docs/sources/basics/intro-histograms.md create mode 100644 docs/sources/basics/timeseries-dimensions.md create mode 100644 docs/sources/basics/timeseries.md create mode 100644 docs/sources/best-practices/_index.md create mode 100644 docs/sources/best-practices/best-practices-for-creating-dashboards.md create mode 100644 docs/sources/best-practices/best-practices-for-managing-dashboards.md create mode 100644 docs/sources/best-practices/common-observability-strategies.md create mode 100644 docs/sources/best-practices/dashboard-management-maturity-levels.md create mode 100644 docs/sources/dashboards/_index.md create mode 100644 docs/sources/dashboards/annotations.md create mode 100644 docs/sources/dashboards/dashboard_folders.md create mode 100644 docs/sources/dashboards/dashboard_history.md create mode 100644 docs/sources/dashboards/export-import.md create mode 100644 docs/sources/dashboards/json-model.md create mode 100644 docs/sources/dashboards/playlist.md create mode 100644 docs/sources/dashboards/reporting.md create mode 100644 docs/sources/dashboards/scripted-dashboards.md create mode 100644 docs/sources/dashboards/search.md create mode 100644 docs/sources/dashboards/shortcuts.md create mode 100644 docs/sources/dashboards/time-range-controls.md create mode 100644 docs/sources/datasources/_index.md create mode 100644 docs/sources/datasources/add-a-data-source.md create mode 100644 docs/sources/datasources/azuremonitor.md create mode 100644 docs/sources/datasources/cloudwatch.md create mode 100644 docs/sources/datasources/elasticsearch.md create mode 100644 docs/sources/datasources/google-cloud-monitoring/_index.md create mode 100644 docs/sources/datasources/google-cloud-monitoring/preconfig-cloud-monitoring-dashboards.md create mode 100644 docs/sources/datasources/graphite.md create mode 100644 docs/sources/datasources/influxdb.md create mode 100644 docs/sources/datasources/influxdb/_index.md create mode 100644 docs/sources/datasources/influxdb/influxdb-flux.md create mode 100644 docs/sources/datasources/influxdb/influxdb-templates.md create mode 100644 docs/sources/datasources/influxdb/provision-influxdb.md create mode 100644 docs/sources/datasources/jaeger.md create mode 100644 docs/sources/datasources/loki.md create mode 100644 docs/sources/datasources/mssql.md create mode 100644 docs/sources/datasources/mysql.md create mode 100644 docs/sources/datasources/opentsdb.md create mode 100644 docs/sources/datasources/postgres.md create mode 100644 docs/sources/datasources/prometheus.md create mode 100644 docs/sources/datasources/tempo.md create mode 100644 docs/sources/datasources/testdata.md create mode 100644 docs/sources/datasources/zipkin.md create mode 100644 docs/sources/developers/_index.md create mode 100644 docs/sources/developers/cla.md create mode 100644 docs/sources/developers/contribute.md create mode 100644 docs/sources/developers/plugins/_index.md create mode 100644 docs/sources/developers/plugins/add-authentication-for-data-source-plugins.md create mode 100644 docs/sources/developers/plugins/add-query-editor-help.md create mode 100644 docs/sources/developers/plugins/add-support-for-annotations.md create mode 100644 docs/sources/developers/plugins/add-support-for-explore-queries.md create mode 100644 docs/sources/developers/plugins/add-support-for-variables.md create mode 100644 docs/sources/developers/plugins/backend/_index.md create mode 100644 docs/sources/developers/plugins/backend/grafana-plugin-sdk-for-go.md create mode 100644 docs/sources/developers/plugins/backend/plugin-protocol.md create mode 100644 docs/sources/developers/plugins/build-a-logs-data-source-plugin.md create mode 100644 docs/sources/developers/plugins/build-a-streaming-data-source-plugin.md create mode 100644 docs/sources/developers/plugins/custom-panel-option-editors.md create mode 100644 docs/sources/developers/plugins/data-frames.md create mode 100644 docs/sources/developers/plugins/error-handling.md create mode 100644 docs/sources/developers/plugins/legacy/_index.md create mode 100644 docs/sources/developers/plugins/legacy/apps.md create mode 100644 docs/sources/developers/plugins/legacy/data-sources.md create mode 100644 docs/sources/developers/plugins/legacy/defaults-and-editor-mode.md create mode 100644 docs/sources/developers/plugins/legacy/panels.md create mode 100644 docs/sources/developers/plugins/legacy/review-guidelines.md create mode 100644 docs/sources/developers/plugins/legacy/snapshot-mode.md create mode 100644 docs/sources/developers/plugins/legacy/style-guide.md create mode 100644 docs/sources/developers/plugins/metadata.md create mode 100644 docs/sources/developers/plugins/metadata.md.tpl create mode 100644 docs/sources/developers/plugins/migration-guide.md create mode 100644 docs/sources/developers/plugins/package-a-plugin.md create mode 100644 docs/sources/developers/plugins/plugin.schema.json create mode 100644 docs/sources/developers/plugins/sign-a-plugin.md create mode 100644 docs/sources/developers/plugins/working-with-data-frames.md create mode 100644 docs/sources/enterprise/_index.md create mode 100644 docs/sources/enterprise/auditing.md create mode 100644 docs/sources/enterprise/datasource_permissions.md create mode 100644 docs/sources/enterprise/enhanced_ldap.md create mode 100644 docs/sources/enterprise/enterprise-configuration.md create mode 100644 docs/sources/enterprise/export-pdf.md create mode 100644 docs/sources/enterprise/license/_index.md create mode 100644 docs/sources/enterprise/license/activate-license.md create mode 100644 docs/sources/enterprise/license/license-expiration.md create mode 100644 docs/sources/enterprise/license/license-restrictions.md create mode 100644 docs/sources/enterprise/query-caching.md create mode 100644 docs/sources/enterprise/reporting.md create mode 100644 docs/sources/enterprise/request-security.md create mode 100644 docs/sources/enterprise/saml.md create mode 100644 docs/sources/enterprise/team-sync.md create mode 100644 docs/sources/enterprise/usage-insights/_index.md create mode 100644 docs/sources/enterprise/usage-insights/dashboard-datasource-insights.md create mode 100644 docs/sources/enterprise/usage-insights/export-logs.md create mode 100644 docs/sources/enterprise/usage-insights/improved-search.md create mode 100644 docs/sources/enterprise/usage-insights/presence-indicator.md create mode 100644 docs/sources/enterprise/vault.md create mode 100644 docs/sources/enterprise/white-labeling.md create mode 100644 docs/sources/explore/_index.md create mode 100644 docs/sources/explore/logs-integration.md create mode 100644 docs/sources/explore/query-management.md create mode 100644 docs/sources/explore/trace-integration.md create mode 100644 docs/sources/getting-started/_index.md create mode 100644 docs/sources/getting-started/getting-started-influxdb.md create mode 100644 docs/sources/getting-started/getting-started-prometheus.md create mode 100644 docs/sources/getting-started/getting-started-sql.md create mode 100644 docs/sources/getting-started/getting-started.md create mode 100644 docs/sources/getting-started/glossary.md create mode 100644 docs/sources/getting-started/intro-histograms.md create mode 100644 docs/sources/getting-started/timeseries-dimensions.md create mode 100644 docs/sources/getting-started/timeseries.md create mode 100644 docs/sources/http_api/_index.md create mode 100644 docs/sources/http_api/admin.md create mode 100644 docs/sources/http_api/alerting.md create mode 100644 docs/sources/http_api/alerting_notification_channels.md create mode 100644 docs/sources/http_api/annotations.md create mode 100755 docs/sources/http_api/auth.md create mode 100644 docs/sources/http_api/create-api-tokens-for-org.md create mode 100644 docs/sources/http_api/curl-examples.md create mode 100644 docs/sources/http_api/dashboard.md create mode 100644 docs/sources/http_api/dashboard_permissions.md create mode 100644 docs/sources/http_api/dashboard_versions.md create mode 100644 docs/sources/http_api/data_source.md create mode 100644 docs/sources/http_api/datasource_permissions.md create mode 100644 docs/sources/http_api/external_group_sync.md create mode 100644 docs/sources/http_api/folder.md create mode 100644 docs/sources/http_api/folder_dashboard_search.md create mode 100644 docs/sources/http_api/folder_permissions.md create mode 100644 docs/sources/http_api/licensing.md create mode 100644 docs/sources/http_api/org.md create mode 100644 docs/sources/http_api/other.md create mode 100644 docs/sources/http_api/playlist.md create mode 100644 docs/sources/http_api/preferences.md create mode 100644 docs/sources/http_api/reporting.md create mode 100644 docs/sources/http_api/short_url.md create mode 100644 docs/sources/http_api/snapshot.md create mode 100644 docs/sources/http_api/team.md create mode 100644 docs/sources/http_api/user.md create mode 100644 docs/sources/installation/_index.md create mode 100644 docs/sources/installation/debian.md create mode 100755 docs/sources/installation/docker.md create mode 100644 docs/sources/installation/kubernetes.md create mode 100755 docs/sources/installation/mac.md create mode 100644 docs/sources/installation/requirements.md create mode 100644 docs/sources/installation/restart-grafana.md create mode 100755 docs/sources/installation/rpm.md create mode 100755 docs/sources/installation/upgrading.md create mode 100644 docs/sources/installation/windows.md create mode 100644 docs/sources/linking/_index.md create mode 100644 docs/sources/linking/dashboard-links.md create mode 100644 docs/sources/linking/data-link-variables.md create mode 100644 docs/sources/linking/data-links.md create mode 100644 docs/sources/linking/linking-overview.md create mode 100644 docs/sources/linking/panel-links.md create mode 100644 docs/sources/manage-users/_index.md create mode 100644 docs/sources/manage-users/manage-teams/index.md create mode 100644 docs/sources/manage-users/org-admin/index.md create mode 100644 docs/sources/manage-users/server-admin/_index.md create mode 100644 docs/sources/manage-users/server-admin/server-admin-manage-orgs.md create mode 100644 docs/sources/manage-users/server-admin/server-admin-manage-users.md create mode 100644 docs/sources/manage-users/user-admin/_index.md create mode 100644 docs/sources/manage-users/user-admin/change-your-password.md create mode 100644 docs/sources/manage-users/user-admin/switch-org.md create mode 100644 docs/sources/manage-users/user-admin/user-profile.md create mode 100644 docs/sources/menu.yaml create mode 100644 docs/sources/panels/_index.md create mode 100644 docs/sources/panels/add-a-panel.md create mode 100644 docs/sources/panels/calculations-list.md create mode 100644 docs/sources/panels/expressions.md create mode 100644 docs/sources/panels/field-options/_index.md create mode 100644 docs/sources/panels/field-options/configure-all-fields.md create mode 100644 docs/sources/panels/field-options/configure-specific-fields.md create mode 100644 docs/sources/panels/field-options/standard-field-options.md create mode 100644 docs/sources/panels/inspect-panel.md create mode 100644 docs/sources/panels/panel-editor.md create mode 100644 docs/sources/panels/queries.md create mode 100644 docs/sources/panels/share-query-results.md create mode 100644 docs/sources/panels/thresholds.md create mode 100644 docs/sources/panels/transformations/_index.md create mode 100644 docs/sources/panels/transformations/apply-transformations.md create mode 100644 docs/sources/panels/transformations/types-options.md create mode 100644 docs/sources/panels/visualizations/_index.md create mode 100644 docs/sources/panels/visualizations/alert-list-panel.md create mode 100644 docs/sources/panels/visualizations/bar-gauge-panel.md create mode 100644 docs/sources/panels/visualizations/dashboard-list-panel.md create mode 100644 docs/sources/panels/visualizations/gauge-panel.md create mode 100644 docs/sources/panels/visualizations/graph-panel.md create mode 100644 docs/sources/panels/visualizations/heatmap.md create mode 100644 docs/sources/panels/visualizations/logs-panel.md create mode 100644 docs/sources/panels/visualizations/news-panel.md create mode 100644 docs/sources/panels/visualizations/node-graph.md create mode 100644 docs/sources/panels/visualizations/panel-legend.md create mode 100644 docs/sources/panels/visualizations/pie-chart-panel.md create mode 100644 docs/sources/panels/visualizations/stat-panel.md create mode 100644 docs/sources/panels/visualizations/table/_index.md create mode 100644 docs/sources/panels/visualizations/table/filter-table-columns.md create mode 100644 docs/sources/panels/visualizations/table/table-field-options.md create mode 100644 docs/sources/panels/visualizations/text-panel.md create mode 100644 docs/sources/panels/visualizations/time-series/_index.md create mode 100644 docs/sources/panels/visualizations/time-series/change-axis-display.md create mode 100644 docs/sources/panels/visualizations/time-series/graph-time-series-as-bars.md create mode 100644 docs/sources/panels/visualizations/time-series/graph-time-series-as-lines.md create mode 100644 docs/sources/panels/visualizations/time-series/graph-time-series-as-points.md create mode 100644 docs/sources/permissions/_index.md create mode 100644 docs/sources/permissions/dashboard-folder-permissions.md create mode 100644 docs/sources/permissions/dashboard_folder_permissions.md create mode 100644 docs/sources/permissions/datasource_permissions.md create mode 100644 docs/sources/permissions/organization_roles.md create mode 100644 docs/sources/permissions/restricting-access.md create mode 100644 docs/sources/plugins/_index.md create mode 100644 docs/sources/plugins/installation.md create mode 100644 docs/sources/plugins/plugin-signatures.md create mode 100644 docs/sources/release-notes/_index.md create mode 100644 docs/sources/release-notes/release-notes-7-3-0.md create mode 100644 docs/sources/release-notes/release-notes-7-3-1.md create mode 100644 docs/sources/release-notes/release-notes-7-3-10.md create mode 100644 docs/sources/release-notes/release-notes-7-3-2.md create mode 100644 docs/sources/release-notes/release-notes-7-3-3.md create mode 100644 docs/sources/release-notes/release-notes-7-3-4.md create mode 100644 docs/sources/release-notes/release-notes-7-3-5.md create mode 100644 docs/sources/release-notes/release-notes-7-3-6.md create mode 100644 docs/sources/release-notes/release-notes-7-3-7.md create mode 100644 docs/sources/release-notes/release-notes-7-4-0.md create mode 100644 docs/sources/release-notes/release-notes-7-4-1.md create mode 100644 docs/sources/release-notes/release-notes-7-4-2.md create mode 100644 docs/sources/release-notes/release-notes-7-4-3.md create mode 100644 docs/sources/release-notes/release-notes-7-4-5.md create mode 100644 docs/sources/release-notes/release-notes-7-5-0-beta1.md create mode 100644 docs/sources/release-notes/release-notes-7-5-0-beta2.md create mode 100644 docs/sources/release-notes/release-notes-7-5-0.md create mode 100644 docs/sources/release-notes/release-notes-7-5-1.md create mode 100644 docs/sources/release-notes/release-notes-7-5-2.md create mode 100644 docs/sources/release-notes/release-notes-7-5-3.md create mode 100644 docs/sources/release-notes/release-notes-7-5-4.md create mode 100644 docs/sources/release-notes/release-notes-v7-4-2.md create mode 100644 docs/sources/shared/basics/grafana-cloud.md create mode 100644 docs/sources/shared/basics/grafana-enterprise.md create mode 100644 docs/sources/shared/basics/what-is-grafana.md create mode 100644 docs/sources/shared/example.md create mode 100644 docs/sources/shared/getting-started/first-step.md create mode 100644 docs/sources/shared/index.md create mode 100644 docs/sources/shared/influxdb/intro.md create mode 100644 docs/sources/shared/manage-users/view-server-org-list-and-edit.md create mode 100644 docs/sources/shared/manage-users/view-server-org-list.md create mode 100644 docs/sources/shared/manage-users/view-server-user-list-search.md create mode 100644 docs/sources/shared/manage-users/view-server-user-list.md create mode 100644 docs/sources/shared/manage-users/view-team-list.md create mode 100644 docs/sources/shared/preferences/navigate-to-the-dashboard-list.md create mode 100644 docs/sources/shared/preferences/navigate-user-preferences-list.md create mode 100644 docs/sources/shared/preferences/org-preferences-list.md create mode 100644 docs/sources/shared/preferences/select-home-dashboard-list.md create mode 100644 docs/sources/shared/preferences/select-timezone-list.md create mode 100644 docs/sources/shared/preferences/select-ui-theme-list.md create mode 100644 docs/sources/shared/preferences/some-tasks-require-permissions.md create mode 100644 docs/sources/shared/test.md create mode 100644 docs/sources/shared/view-server-org-list.md create mode 100644 docs/sources/shared/view-server-user-list-search.md create mode 100644 docs/sources/shared/visualizations/legend-mode.md create mode 100644 docs/sources/sharing/_index.md create mode 100644 docs/sources/sharing/playlists.md create mode 100644 docs/sources/sharing/share-dashboard.md create mode 100644 docs/sources/sharing/share-panel.md create mode 100644 docs/sources/troubleshooting/_index.md create mode 100644 docs/sources/troubleshooting/diagnostics.md create mode 100644 docs/sources/troubleshooting/troubleshoot-dashboards.md create mode 100644 docs/sources/troubleshooting/troubleshoot-queries.md create mode 100644 docs/sources/variables/_index.md create mode 100644 docs/sources/variables/advanced-variable-format-options.md create mode 100644 docs/sources/variables/filter-variables-with-regex.md create mode 100644 docs/sources/variables/formatting-multi-value-variables.md create mode 100644 docs/sources/variables/inspect-variable.md create mode 100644 docs/sources/variables/manage-variable.md create mode 100644 docs/sources/variables/repeat-panels-or-rows.md create mode 100644 docs/sources/variables/syntax.md create mode 100644 docs/sources/variables/variable-examples.md create mode 100644 docs/sources/variables/variable-selection-options.md create mode 100644 docs/sources/variables/variable-types/_index.md create mode 100644 docs/sources/variables/variable-types/add-ad-hoc-filters.md create mode 100644 docs/sources/variables/variable-types/add-constant-variable.md create mode 100644 docs/sources/variables/variable-types/add-custom-variable.md create mode 100644 docs/sources/variables/variable-types/add-data-source-variable.md create mode 100644 docs/sources/variables/variable-types/add-interval-variable.md create mode 100644 docs/sources/variables/variable-types/add-query-variable.md create mode 100644 docs/sources/variables/variable-types/add-text-box-variable.md create mode 100644 docs/sources/variables/variable-types/chained-variables.md create mode 100644 docs/sources/variables/variable-types/global-variables.md create mode 100644 docs/sources/variables/variable-value-tags.md create mode 100644 docs/sources/whatsnew/_index.md create mode 100644 docs/sources/whatsnew/whats-new-in-v2-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v2-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v2-5.md create mode 100644 docs/sources/whatsnew/whats-new-in-v2-6.md create mode 100644 docs/sources/whatsnew/whats-new-in-v3-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v3-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-2.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-3.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-4.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-5.md create mode 100644 docs/sources/whatsnew/whats-new-in-v4-6.md create mode 100644 docs/sources/whatsnew/whats-new-in-v5-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v5-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v5-2.md create mode 100644 docs/sources/whatsnew/whats-new-in-v5-3.md create mode 100644 docs/sources/whatsnew/whats-new-in-v5-4.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-2.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-3.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-4.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-5.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-6.md create mode 100644 docs/sources/whatsnew/whats-new-in-v6-7.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-0.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-1.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-2.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-3.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-4.md create mode 100644 docs/sources/whatsnew/whats-new-in-v7-5.md create mode 100755 e2e/kill-server create mode 100755 e2e/run-suite create mode 100644 e2e/shared/smokeTestScenario.ts create mode 100755 e2e/start-and-run-suite create mode 100755 e2e/start-server create mode 100644 e2e/suite1/specs/1-smoketests.spec.ts create mode 100644 e2e/suite1/specs/bar-gauge.spec.ts create mode 100644 e2e/suite1/specs/dashboard-templating.spec.ts create mode 100644 e2e/suite1/specs/dashboard-time-zone.spec.ts create mode 100644 e2e/suite1/specs/explore.spec.ts create mode 100644 e2e/suite1/specs/gauge.spec.ts create mode 100644 e2e/suite1/specs/inspect-drawer.spec.ts create mode 100644 e2e/suite1/specs/panelEdit_base.spec.ts create mode 100644 e2e/suite1/specs/panelEdit_queries.spec.ts create mode 100644 e2e/suite1/specs/panelEdit_transforms.spec.ts create mode 100644 e2e/suite1/specs/query-editor.spec.ts create mode 100644 e2e/suite1/specs/select-focus.spec.ts create mode 100644 e2e/suite1/specs/solo-route.spec.ts create mode 100644 e2e/suite1/specs/templating-dashboard-links-and-variables.ts create mode 100644 e2e/suite1/specs/trace-view-scrolling.spec.ts create mode 100644 e2e/suite1/specs/variables/load-options-from-url.ts create mode 100644 e2e/suite1/specs/variables/new-query-variable.ts create mode 100644 e2e/suite1/specs/variables/set-options-from-ui.ts create mode 100644 e2e/suite1/specs/variables/textbox-variables.ts create mode 100644 e2e/suite1/tsconfig.json create mode 100644 e2e/variables create mode 100755 e2e/verify-release create mode 100644 e2e/verify/specs/smoketests.spec.ts create mode 100644 e2e/verify/tsconfig.json create mode 100755 e2e/wait-for-grafana create mode 100644 emails/README.md create mode 100644 emails/assets/css/ink.css create mode 100644 emails/assets/css/style.css create mode 100644 emails/grunt/aliases.yaml create mode 100644 emails/grunt/assemble.js create mode 100644 emails/grunt/clean.js create mode 100644 emails/grunt/premailer.js create mode 100644 emails/grunt/processhtml.js create mode 100644 emails/grunt/replace.js create mode 100644 emails/grunt/uncss.js create mode 100644 emails/grunt/watch.js create mode 100644 emails/gruntfile.js create mode 100644 emails/package.json create mode 100644 emails/templates/alert_notification.html create mode 100644 emails/templates/invited_to_org.html create mode 100644 emails/templates/layouts/default.html create mode 100644 emails/templates/new_user_invite.html create mode 100644 emails/templates/reset_password.html create mode 100644 emails/templates/signup_started.html create mode 100644 emails/templates/welcome_on_signup.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 grafana-mixin/.gitignore create mode 100644 grafana-mixin/Makefile create mode 100644 grafana-mixin/README.md create mode 100644 grafana-mixin/alerts/alerts.yaml create mode 100644 grafana-mixin/dashboards/grafana-overview.json create mode 100644 grafana-mixin/mixin.libsonnet create mode 100644 grafana-mixin/rules/rules.yaml create mode 100755 grafana-mixin/scripts/build.sh create mode 100644 grafana-mixin/scripts/common.sh create mode 100755 grafana-mixin/scripts/format.sh create mode 100755 grafana-mixin/scripts/lint.sh create mode 100644 jest.config.js create mode 100644 latest.json create mode 100644 lerna.json create mode 100644 package.json create mode 100644 packages/README.md create mode 100644 packages/grafana-data/.eslintrc create mode 100644 packages/grafana-data/CHANGELOG.md create mode 100644 packages/grafana-data/README.md create mode 100644 packages/grafana-data/api-extractor.json create mode 100644 packages/grafana-data/index.js create mode 100644 packages/grafana-data/package.json create mode 100644 packages/grafana-data/rollup.config.ts create mode 100644 packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts create mode 100644 packages/grafana-data/src/dataframe/ArrayDataFrame.ts create mode 100644 packages/grafana-data/src/dataframe/ArrowDataFrame.test.ts create mode 100644 packages/grafana-data/src/dataframe/ArrowDataFrame.ts create mode 100644 packages/grafana-data/src/dataframe/CircularDataFrame.ts create mode 100644 packages/grafana-data/src/dataframe/DataFrameView.test.ts create mode 100644 packages/grafana-data/src/dataframe/DataFrameView.ts create mode 100644 packages/grafana-data/src/dataframe/FieldCache.test.ts create mode 100644 packages/grafana-data/src/dataframe/FieldCache.ts create mode 100644 packages/grafana-data/src/dataframe/MutableDataFrame.test.ts create mode 100644 packages/grafana-data/src/dataframe/MutableDataFrame.ts create mode 100644 packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap create mode 100644 packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow create mode 100644 packages/grafana-data/src/dataframe/dimensions.ts create mode 100644 packages/grafana-data/src/dataframe/frameComparisons.test.ts create mode 100644 packages/grafana-data/src/dataframe/frameComparisons.ts create mode 100644 packages/grafana-data/src/dataframe/index.ts create mode 100644 packages/grafana-data/src/dataframe/processDataFrame.test.ts create mode 100644 packages/grafana-data/src/dataframe/processDataFrame.ts create mode 100644 packages/grafana-data/src/dataframe/utils.ts create mode 100644 packages/grafana-data/src/datetime/common.ts create mode 100644 packages/grafana-data/src/datetime/datemath.test.ts create mode 100644 packages/grafana-data/src/datetime/datemath.ts create mode 100644 packages/grafana-data/src/datetime/formats.test.ts create mode 100644 packages/grafana-data/src/datetime/formats.ts create mode 100644 packages/grafana-data/src/datetime/formatter.test.ts create mode 100644 packages/grafana-data/src/datetime/formatter.ts create mode 100644 packages/grafana-data/src/datetime/index.ts create mode 100644 packages/grafana-data/src/datetime/moment_wrapper.ts create mode 100644 packages/grafana-data/src/datetime/parser.test.ts create mode 100644 packages/grafana-data/src/datetime/parser.ts create mode 100644 packages/grafana-data/src/datetime/rangeutil.test.ts create mode 100644 packages/grafana-data/src/datetime/rangeutil.ts create mode 100644 packages/grafana-data/src/datetime/timezones.test.ts create mode 100644 packages/grafana-data/src/datetime/timezones.ts create mode 100644 packages/grafana-data/src/events/EventBus.test.ts create mode 100644 packages/grafana-data/src/events/EventBus.ts create mode 100644 packages/grafana-data/src/events/eventFactory.ts create mode 100644 packages/grafana-data/src/events/index.ts create mode 100644 packages/grafana-data/src/events/types.ts create mode 100644 packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx create mode 100644 packages/grafana-data/src/field/displayProcessor.test.ts create mode 100644 packages/grafana-data/src/field/displayProcessor.ts create mode 100644 packages/grafana-data/src/field/fieldColor.test.ts create mode 100644 packages/grafana-data/src/field/fieldColor.ts create mode 100644 packages/grafana-data/src/field/fieldComparers.ts create mode 100644 packages/grafana-data/src/field/fieldDisplay.test.ts create mode 100644 packages/grafana-data/src/field/fieldDisplay.ts create mode 100644 packages/grafana-data/src/field/fieldOverrides.test.ts create mode 100644 packages/grafana-data/src/field/fieldOverrides.ts create mode 100644 packages/grafana-data/src/field/fieldState.test.ts create mode 100644 packages/grafana-data/src/field/fieldState.ts create mode 100644 packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx create mode 100644 packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts create mode 100644 packages/grafana-data/src/field/index.ts create mode 100644 packages/grafana-data/src/field/overrides/processors.ts create mode 100644 packages/grafana-data/src/field/scale.test.ts create mode 100644 packages/grafana-data/src/field/scale.ts create mode 100644 packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts create mode 100644 packages/grafana-data/src/field/templateProxies.test.ts create mode 100644 packages/grafana-data/src/field/templateProxies.ts create mode 100644 packages/grafana-data/src/field/thresholds.test.ts create mode 100644 packages/grafana-data/src/field/thresholds.ts create mode 100644 packages/grafana-data/src/index.ts create mode 100644 packages/grafana-data/src/panel/PanelPlugin.test.tsx create mode 100644 packages/grafana-data/src/panel/PanelPlugin.ts create mode 100644 packages/grafana-data/src/panel/registryFactories.ts create mode 100644 packages/grafana-data/src/text/index.ts create mode 100644 packages/grafana-data/src/text/markdown.test.ts create mode 100644 packages/grafana-data/src/text/markdown.ts create mode 100644 packages/grafana-data/src/text/sanitize.ts create mode 100644 packages/grafana-data/src/text/string.test.ts create mode 100644 packages/grafana-data/src/text/string.ts create mode 100644 packages/grafana-data/src/text/text.test.ts create mode 100644 packages/grafana-data/src/text/text.ts create mode 100644 packages/grafana-data/src/transformations/fieldReducer.test.ts create mode 100644 packages/grafana-data/src/transformations/fieldReducer.ts create mode 100644 packages/grafana-data/src/transformations/index.ts create mode 100644 packages/grafana-data/src/transformations/matchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts create mode 100644 packages/grafana-data/src/transformations/matchers/ids.ts create mode 100644 packages/grafana-data/src/transformations/matchers/matchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/nameMatcher.ts create mode 100644 packages/grafana-data/src/transformations/matchers/predicates.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/predicates.ts create mode 100644 packages/grafana-data/src/transformations/matchers/refIdMatcher.ts create mode 100644 packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts create mode 100644 packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts create mode 100644 packages/grafana-data/src/transformations/standardTransformersRegistry.ts create mode 100644 packages/grafana-data/src/transformations/transformDataFrame.ts create mode 100644 packages/grafana-data/src/transformations/transformers.ts create mode 100644 packages/grafana-data/src/transformations/transformers/calculateField.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/calculateField.ts create mode 100644 packages/grafana-data/src/transformations/transformers/concat.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/concat.ts create mode 100644 packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/ensureColumns.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filter.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filter.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByName.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByName.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByRefId.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByValue.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/filterByValue.ts create mode 100644 packages/grafana-data/src/transformations/transformers/groupBy.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/groupBy.ts create mode 100644 packages/grafana-data/src/transformations/transformers/ids.ts create mode 100644 packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/joinDataFrames.ts create mode 100644 packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/labelsToFields.ts create mode 100644 packages/grafana-data/src/transformations/transformers/merge.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/merge.ts create mode 100644 packages/grafana-data/src/transformations/transformers/noop.ts create mode 100644 packages/grafana-data/src/transformations/transformers/order.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/order.ts create mode 100644 packages/grafana-data/src/transformations/transformers/organize.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/organize.ts create mode 100644 packages/grafana-data/src/transformations/transformers/reduce.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/reduce.ts create mode 100644 packages/grafana-data/src/transformations/transformers/rename.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/rename.ts create mode 100644 packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/renameByRegex.ts create mode 100644 packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/seriesToColumns.ts create mode 100644 packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/seriesToRows.ts create mode 100644 packages/grafana-data/src/transformations/transformers/sortBy.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/sortBy.ts create mode 100644 packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts create mode 100644 packages/grafana-data/src/types/ScopedVars.ts create mode 100644 packages/grafana-data/src/types/annotations.ts create mode 100644 packages/grafana-data/src/types/app.ts create mode 100644 packages/grafana-data/src/types/config.ts create mode 100644 packages/grafana-data/src/types/data.ts create mode 100644 packages/grafana-data/src/types/dataFrame.ts create mode 100644 packages/grafana-data/src/types/dataLink.ts create mode 100644 packages/grafana-data/src/types/datasource.ts create mode 100644 packages/grafana-data/src/types/displayValue.ts create mode 100644 packages/grafana-data/src/types/explore.ts create mode 100644 packages/grafana-data/src/types/fieldColor.ts create mode 100644 packages/grafana-data/src/types/fieldOverrides.ts create mode 100644 packages/grafana-data/src/types/flot.ts create mode 100644 packages/grafana-data/src/types/geometry.ts create mode 100644 packages/grafana-data/src/types/graph.ts create mode 100644 packages/grafana-data/src/types/index.ts create mode 100644 packages/grafana-data/src/types/legacyEvents.ts create mode 100644 packages/grafana-data/src/types/live.ts create mode 100644 packages/grafana-data/src/types/logs.ts create mode 100644 packages/grafana-data/src/types/navModel.ts create mode 100644 packages/grafana-data/src/types/options.ts create mode 100644 packages/grafana-data/src/types/orgs.ts create mode 100644 packages/grafana-data/src/types/panel.ts create mode 100644 packages/grafana-data/src/types/plugin.ts create mode 100644 packages/grafana-data/src/types/select.ts create mode 100644 packages/grafana-data/src/types/templateVars.ts create mode 100644 packages/grafana-data/src/types/theme.ts create mode 100644 packages/grafana-data/src/types/thresholds.ts create mode 100644 packages/grafana-data/src/types/time.ts create mode 100644 packages/grafana-data/src/types/trace.ts create mode 100644 packages/grafana-data/src/types/transformations.ts create mode 100644 packages/grafana-data/src/types/valueMapping.ts create mode 100644 packages/grafana-data/src/types/variables.ts create mode 100644 packages/grafana-data/src/types/vector.ts create mode 100644 packages/grafana-data/src/utils/OptionsUIBuilders.ts create mode 100644 packages/grafana-data/src/utils/Registry.test.ts create mode 100644 packages/grafana-data/src/utils/Registry.ts create mode 100644 packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap create mode 100644 packages/grafana-data/src/utils/binaryOperators.ts create mode 100644 packages/grafana-data/src/utils/csv.test.ts create mode 100644 packages/grafana-data/src/utils/csv.ts create mode 100644 packages/grafana-data/src/utils/dataLinks.test.ts create mode 100644 packages/grafana-data/src/utils/dataLinks.ts create mode 100644 packages/grafana-data/src/utils/datasource.ts create mode 100644 packages/grafana-data/src/utils/deprecationWarning.test.ts create mode 100644 packages/grafana-data/src/utils/deprecationWarning.ts create mode 100644 packages/grafana-data/src/utils/docs.ts create mode 100644 packages/grafana-data/src/utils/fieldParser.ts create mode 100644 packages/grafana-data/src/utils/flotPairs.test.ts create mode 100644 packages/grafana-data/src/utils/flotPairs.ts create mode 100644 packages/grafana-data/src/utils/index.ts create mode 100644 packages/grafana-data/src/utils/labels.test.ts create mode 100644 packages/grafana-data/src/utils/labels.ts create mode 100644 packages/grafana-data/src/utils/location.test.ts create mode 100644 packages/grafana-data/src/utils/location.ts create mode 100644 packages/grafana-data/src/utils/logs.test.ts create mode 100644 packages/grafana-data/src/utils/logs.ts create mode 100644 packages/grafana-data/src/utils/namedColorsPalette.test.ts create mode 100644 packages/grafana-data/src/utils/namedColorsPalette.ts create mode 100644 packages/grafana-data/src/utils/object.ts create mode 100644 packages/grafana-data/src/utils/series.test.ts create mode 100644 packages/grafana-data/src/utils/series.ts create mode 100644 packages/grafana-data/src/utils/testdata/roundtrip.csv create mode 100644 packages/grafana-data/src/utils/testdata/simple.csv create mode 100644 packages/grafana-data/src/utils/testdata/testTheme.ts create mode 100644 packages/grafana-data/src/utils/testdata/withHeaders.csv create mode 100644 packages/grafana-data/src/utils/tests/mockStandardProperties.ts create mode 100644 packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts create mode 100644 packages/grafana-data/src/utils/url.test.ts create mode 100644 packages/grafana-data/src/utils/url.ts create mode 100644 packages/grafana-data/src/utils/valueMappings.test.ts create mode 100644 packages/grafana-data/src/utils/valueMappings.ts create mode 100644 packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts create mode 100644 packages/grafana-data/src/valueFormats/arithmeticFormatters.ts create mode 100644 packages/grafana-data/src/valueFormats/categories.ts create mode 100644 packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts create mode 100644 packages/grafana-data/src/valueFormats/dateTimeFormatters.ts create mode 100644 packages/grafana-data/src/valueFormats/index.ts create mode 100644 packages/grafana-data/src/valueFormats/symbolFormatters.test.ts create mode 100644 packages/grafana-data/src/valueFormats/symbolFormatters.ts create mode 100644 packages/grafana-data/src/valueFormats/valueFormats.test.ts create mode 100644 packages/grafana-data/src/valueFormats/valueFormats.ts create mode 100644 packages/grafana-data/src/vector/AppendedVectors.test.ts create mode 100644 packages/grafana-data/src/vector/AppendedVectors.ts create mode 100644 packages/grafana-data/src/vector/ArrayVector.ts create mode 100644 packages/grafana-data/src/vector/AsNumberVector.ts create mode 100644 packages/grafana-data/src/vector/BinaryOperationVector.test.ts create mode 100644 packages/grafana-data/src/vector/BinaryOperationVector.ts create mode 100644 packages/grafana-data/src/vector/CircularVector.test.ts create mode 100644 packages/grafana-data/src/vector/CircularVector.ts create mode 100644 packages/grafana-data/src/vector/ConstantVector.test.ts create mode 100644 packages/grafana-data/src/vector/ConstantVector.ts create mode 100644 packages/grafana-data/src/vector/FormattedVector.ts create mode 100644 packages/grafana-data/src/vector/FunctionalVector.ts create mode 100644 packages/grafana-data/src/vector/IndexVector.ts create mode 100644 packages/grafana-data/src/vector/RowVector.ts create mode 100644 packages/grafana-data/src/vector/SortedVector.ts create mode 100644 packages/grafana-data/src/vector/index.ts create mode 100644 packages/grafana-data/src/vector/vectorToArray.ts create mode 100644 packages/grafana-data/tsconfig.build.json create mode 100644 packages/grafana-data/tsconfig.json create mode 100644 packages/grafana-data/typings/jest/index.d.ts create mode 100644 packages/grafana-e2e-selectors/CHANGELOG.md create mode 100644 packages/grafana-e2e-selectors/README.md create mode 100644 packages/grafana-e2e-selectors/api-extractor.json create mode 100644 packages/grafana-e2e-selectors/index.js create mode 100644 packages/grafana-e2e-selectors/package.json create mode 100644 packages/grafana-e2e-selectors/rollup.config.ts create mode 100644 packages/grafana-e2e-selectors/src/index.ts create mode 100644 packages/grafana-e2e-selectors/src/selectors/components.ts create mode 100644 packages/grafana-e2e-selectors/src/selectors/index.ts create mode 100644 packages/grafana-e2e-selectors/src/selectors/pages.ts create mode 100644 packages/grafana-e2e-selectors/src/types/index.ts create mode 100644 packages/grafana-e2e-selectors/src/types/selectors.ts create mode 100644 packages/grafana-e2e-selectors/tsconfig.build.json create mode 100644 packages/grafana-e2e-selectors/tsconfig.json create mode 100644 packages/grafana-e2e/.gitignore create mode 100644 packages/grafana-e2e/CHANGELOG.md create mode 100644 packages/grafana-e2e/README.md create mode 100644 packages/grafana-e2e/api-extractor.json create mode 100755 packages/grafana-e2e/bin/grafana-e2e.js create mode 100644 packages/grafana-e2e/cli.js create mode 100644 packages/grafana-e2e/cypress.json create mode 100644 packages/grafana-e2e/cypress/fixtures/example.json create mode 100644 packages/grafana-e2e/cypress/fixtures/long-trace-response.json create mode 100644 packages/grafana-e2e/cypress/plugins/compareScreenshots.js create mode 100644 packages/grafana-e2e/cypress/plugins/extendConfig.js create mode 100644 packages/grafana-e2e/cypress/plugins/index.js create mode 100644 packages/grafana-e2e/cypress/plugins/readProvisions.js create mode 100644 packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js create mode 100644 packages/grafana-e2e/cypress/support/commands.ts create mode 100644 packages/grafana-e2e/cypress/support/index.d.ts create mode 100644 packages/grafana-e2e/cypress/support/index.ts create mode 100644 packages/grafana-e2e/cypress/tsconfig.json create mode 100644 packages/grafana-e2e/index.js create mode 100644 packages/grafana-e2e/package.json create mode 100644 packages/grafana-e2e/rollup.config.ts create mode 100644 packages/grafana-e2e/src/components/index.ts create mode 100644 packages/grafana-e2e/src/flows/addDashboard.ts create mode 100644 packages/grafana-e2e/src/flows/addDataSource.ts create mode 100644 packages/grafana-e2e/src/flows/addPanel.ts create mode 100644 packages/grafana-e2e/src/flows/assertSuccessNotification.ts create mode 100644 packages/grafana-e2e/src/flows/configurePanel.ts create mode 100644 packages/grafana-e2e/src/flows/deleteDashboard.ts create mode 100644 packages/grafana-e2e/src/flows/deleteDataSource.ts create mode 100644 packages/grafana-e2e/src/flows/editPanel.ts create mode 100644 packages/grafana-e2e/src/flows/explore.ts create mode 100644 packages/grafana-e2e/src/flows/index.ts create mode 100644 packages/grafana-e2e/src/flows/login.ts create mode 100644 packages/grafana-e2e/src/flows/openDashboard.ts create mode 100644 packages/grafana-e2e/src/flows/openPanelMenuItem.ts create mode 100644 packages/grafana-e2e/src/flows/revertAllChanges.ts create mode 100644 packages/grafana-e2e/src/flows/saveDashboard.ts create mode 100644 packages/grafana-e2e/src/flows/selectOption.ts create mode 100644 packages/grafana-e2e/src/flows/setDashboardTimeRange.ts create mode 100644 packages/grafana-e2e/src/flows/setTimeRange.ts create mode 100644 packages/grafana-e2e/src/index.ts create mode 100644 packages/grafana-e2e/src/support/index.ts create mode 100644 packages/grafana-e2e/src/support/localStorage.ts create mode 100644 packages/grafana-e2e/src/support/scenario.ts create mode 100644 packages/grafana-e2e/src/support/scenarioContext.ts create mode 100644 packages/grafana-e2e/src/support/selector.ts create mode 100644 packages/grafana-e2e/src/support/types.ts create mode 100644 packages/grafana-e2e/src/support/url.ts create mode 100644 packages/grafana-e2e/src/typings/index.ts create mode 100644 packages/grafana-e2e/src/typings/undo.ts create mode 100644 packages/grafana-e2e/test/cypress/integration/0.cli.ts create mode 100644 packages/grafana-e2e/test/cypress/integration/1.api.ts create mode 100644 packages/grafana-e2e/test/cypress/tsconfig.json create mode 100644 packages/grafana-e2e/tsconfig.build.json create mode 100644 packages/grafana-e2e/tsconfig.json create mode 100644 packages/grafana-runtime/.eslintrc create mode 100644 packages/grafana-runtime/CHANGELOG.md create mode 100644 packages/grafana-runtime/README.md create mode 100644 packages/grafana-runtime/api-extractor.json create mode 100644 packages/grafana-runtime/index.js create mode 100644 packages/grafana-runtime/package.json create mode 100644 packages/grafana-runtime/rollup.config.ts create mode 100644 packages/grafana-runtime/src/config.ts create mode 100644 packages/grafana-runtime/src/index.ts create mode 100644 packages/grafana-runtime/src/measurement/collector.test.ts create mode 100644 packages/grafana-runtime/src/measurement/collector.ts create mode 100644 packages/grafana-runtime/src/measurement/index.ts create mode 100644 packages/grafana-runtime/src/measurement/query.ts create mode 100644 packages/grafana-runtime/src/measurement/types.ts create mode 100644 packages/grafana-runtime/src/services/AngularLoader.ts create mode 100644 packages/grafana-runtime/src/services/EchoSrv.ts create mode 100644 packages/grafana-runtime/src/services/LocationSrv.ts create mode 100644 packages/grafana-runtime/src/services/backendSrv.ts create mode 100644 packages/grafana-runtime/src/services/dataSourceSrv.ts create mode 100644 packages/grafana-runtime/src/services/index.ts create mode 100644 packages/grafana-runtime/src/services/legacyAngularInjector.ts create mode 100644 packages/grafana-runtime/src/services/live.ts create mode 100644 packages/grafana-runtime/src/services/templateSrv.ts create mode 100644 packages/grafana-runtime/src/types/analytics.ts create mode 100644 packages/grafana-runtime/src/types/index.ts create mode 100644 packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts create mode 100644 packages/grafana-runtime/src/utils/DataSourceWithBackend.ts create mode 100644 packages/grafana-runtime/src/utils/analytics.ts create mode 100644 packages/grafana-runtime/src/utils/logging.ts create mode 100644 packages/grafana-runtime/src/utils/plugin.ts create mode 100644 packages/grafana-runtime/src/utils/queryResponse.test.ts create mode 100644 packages/grafana-runtime/src/utils/queryResponse.ts create mode 100644 packages/grafana-runtime/tsconfig.build.json create mode 100644 packages/grafana-runtime/tsconfig.json create mode 100644 packages/grafana-toolkit/.eslintrc create mode 100644 packages/grafana-toolkit/CHANGELOG.md create mode 100644 packages/grafana-toolkit/README.md create mode 100755 packages/grafana-toolkit/bin/grafana-toolkit.js create mode 100644 packages/grafana-toolkit/config/circleci/config.yml create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci/README.md create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh create mode 100644 packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml create mode 100755 packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh create mode 100644 packages/grafana-toolkit/package.json create mode 100644 packages/grafana-toolkit/src/cli/index.d.ts create mode 100644 packages/grafana-toolkit/src/cli/index.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/changelog.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/cherrypick.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/component.create.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/core.start.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/package.build.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.build.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.create.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.dev.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.tests.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.update.ts create mode 100755 packages/grafana-toolkit/src/cli/tasks/plugin.utils.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/create.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/test_exclude_image_suffix_in_manifest.png create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/task.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/template.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts create mode 100644 packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts create mode 100644 packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts create mode 100644 packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts create mode 100644 packages/grafana-toolkit/src/cli/templates/index.ts create mode 100644 packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts create mode 100644 packages/grafana-toolkit/src/cli/tsconfig.json create mode 100644 packages/grafana-toolkit/src/cli/utils/execTask.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/githubClient.test.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/githubClient.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/githubRelease.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/pascalCase.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/prompt.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/rmdir.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/useSpinner.ts create mode 100644 packages/grafana-toolkit/src/config/eslint.plugin.json create mode 100644 packages/grafana-toolkit/src/config/index.ts create mode 100644 packages/grafana-toolkit/src/config/jest.plugin.config.local.js create mode 100644 packages/grafana-toolkit/src/config/jest.plugin.config.test.ts create mode 100644 packages/grafana-toolkit/src/config/jest.plugin.config.ts create mode 100644 packages/grafana-toolkit/src/config/mocks/jestSetup/noOverrides/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/jestSetup/overrides/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/jestSetup/unsupportedOverrides/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.css create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.scss create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/light.css create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/missing-theme-file/src/styles/light.css create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/no-theme-files/src/styles/whatever.css create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/dark.css create mode 100644 packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/light.css create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.js create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.js create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json create mode 100644 packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.js create mode 100644 packages/grafana-toolkit/src/config/prettier.plugin.config.json create mode 100644 packages/grafana-toolkit/src/config/prettier.plugin.rc.js create mode 100644 packages/grafana-toolkit/src/config/styles.mock.js create mode 100644 packages/grafana-toolkit/src/config/tsconfig.plugin.json create mode 100644 packages/grafana-toolkit/src/config/tsconfig.plugin.local.json create mode 100644 packages/grafana-toolkit/src/config/utils/getPluginId.ts create mode 100644 packages/grafana-toolkit/src/config/utils/pluginValidation.test.ts create mode 100644 packages/grafana-toolkit/src/config/utils/pluginValidation.ts create mode 100644 packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts create mode 100644 packages/grafana-toolkit/src/config/webpack.plugin.config.ts create mode 100644 packages/grafana-toolkit/src/config/webpack/loaders.test.ts create mode 100644 packages/grafana-toolkit/src/config/webpack/loaders.ts create mode 100644 packages/grafana-toolkit/src/plugins/env.ts create mode 100644 packages/grafana-toolkit/src/plugins/index.ts create mode 100644 packages/grafana-toolkit/src/plugins/manifest.ts create mode 100644 packages/grafana-toolkit/src/plugins/types.ts create mode 100644 packages/grafana-toolkit/src/plugins/utils.ts create mode 100644 packages/grafana-toolkit/src/plugins/workflow.ts create mode 100644 packages/grafana-toolkit/tsconfig.json create mode 100644 packages/grafana-ui/.eslintrc create mode 100644 packages/grafana-ui/.storybook/main.ts create mode 100644 packages/grafana-ui/.storybook/manager-head.html create mode 100644 packages/grafana-ui/.storybook/preview.ts create mode 100644 packages/grafana-ui/.storybook/static/fav32.png create mode 100644 packages/grafana-ui/.storybook/static/grafana_icon.svg create mode 100644 packages/grafana-ui/.storybook/storybookTheme.ts create mode 100644 packages/grafana-ui/.storybook/tsconfig.json create mode 100644 packages/grafana-ui/CHANGELOG.md create mode 100644 packages/grafana-ui/README.md create mode 100644 packages/grafana-ui/api-extractor.json create mode 100644 packages/grafana-ui/index.js create mode 100644 packages/grafana-ui/package.json create mode 100644 packages/grafana-ui/rollup.config.ts create mode 100644 packages/grafana-ui/src/Intro.story.mdx create mode 100644 packages/grafana-ui/src/components/Alert/Alert.mdx create mode 100644 packages/grafana-ui/src/components/Alert/Alert.story.tsx create mode 100644 packages/grafana-ui/src/components/Alert/Alert.tsx create mode 100644 packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.mdx create mode 100644 packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.story.tsx create mode 100644 packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx create mode 100644 packages/grafana-ui/src/components/Badge/Badge.mdx create mode 100644 packages/grafana-ui/src/components/Badge/Badge.story.tsx create mode 100644 packages/grafana-ui/src/components/Badge/Badge.tsx create mode 100644 packages/grafana-ui/src/components/BarChart/BarChart.story.tsx create mode 100644 packages/grafana-ui/src/components/BarChart/BarChart.tsx create mode 100644 packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/BarChart/bars.ts create mode 100644 packages/grafana-ui/src/components/BarChart/distribute.ts create mode 100644 packages/grafana-ui/src/components/BarChart/quadtree.ts create mode 100644 packages/grafana-ui/src/components/BarChart/types.ts create mode 100644 packages/grafana-ui/src/components/BarChart/utils.test.ts create mode 100644 packages/grafana-ui/src/components/BarChart/utils.ts create mode 100644 packages/grafana-ui/src/components/BarGauge/BarGauge.mdx create mode 100644 packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx create mode 100644 packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx create mode 100644 packages/grafana-ui/src/components/BarGauge/BarGauge.tsx create mode 100644 packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/BigValue/BigValue.mdx create mode 100644 packages/grafana-ui/src/components/BigValue/BigValue.story.tsx create mode 100644 packages/grafana-ui/src/components/BigValue/BigValue.test.tsx create mode 100644 packages/grafana-ui/src/components/BigValue/BigValue.tsx create mode 100644 packages/grafana-ui/src/components/BigValue/BigValueLayout.test.tsx create mode 100644 packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx create mode 100644 packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/Button/Button.mdx create mode 100644 packages/grafana-ui/src/components/Button/Button.story.tsx create mode 100644 packages/grafana-ui/src/components/Button/Button.tsx create mode 100644 packages/grafana-ui/src/components/Button/ButtonGroup.tsx create mode 100644 packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx create mode 100644 packages/grafana-ui/src/components/Button/ToolbarButton.story.tsx create mode 100644 packages/grafana-ui/src/components/Button/ToolbarButton.tsx create mode 100644 packages/grafana-ui/src/components/Button/ToolbarButtonRow.tsx create mode 100644 packages/grafana-ui/src/components/Button/index.ts create mode 100644 packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx create mode 100644 packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx create mode 100644 packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss create mode 100644 packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx create mode 100644 packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx create mode 100644 packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/Card/Card.mdx create mode 100644 packages/grafana-ui/src/components/Card/Card.story.tsx create mode 100644 packages/grafana-ui/src/components/Card/Card.test.tsx create mode 100644 packages/grafana-ui/src/components/Card/Card.tsx create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.mdx create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.story.tsx create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.test.tsx create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.tsx create mode 100644 packages/grafana-ui/src/components/Cascader/optionMappings.ts create mode 100644 packages/grafana-ui/src/components/Chart/Tooltip.tsx create mode 100644 packages/grafana-ui/src/components/Chart/TooltipContainer.tsx create mode 100644 packages/grafana-ui/src/components/Chart/index.tsx create mode 100644 packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx create mode 100644 packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx create mode 100644 packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx create mode 100644 packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx create mode 100644 packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx create mode 100644 packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx create mode 100644 packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx create mode 100644 packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx create mode 100644 packages/grafana-ui/src/components/Collapse/Collapse.mdx create mode 100644 packages/grafana-ui/src/components/Collapse/Collapse.story.tsx create mode 100644 packages/grafana-ui/src/components/Collapse/Collapse.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx create mode 100644 packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts create mode 100644 packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx create mode 100644 packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.mdx create mode 100644 packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.story.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx create mode 100644 packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx create mode 100644 packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx create mode 100644 packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx create mode 100644 packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx create mode 100644 packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx create mode 100644 packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/SelectionReference.ts create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.mdx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.story.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/HttpProxySettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/SigV4AuthSettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx create mode 100644 packages/grafana-ui/src/components/DataSourceSettings/types.ts create mode 100644 packages/grafana-ui/src/components/Drawer/Drawer.mdx create mode 100644 packages/grafana-ui/src/components/Drawer/Drawer.story.tsx create mode 100644 packages/grafana-ui/src/components/Drawer/Drawer.tsx create mode 100644 packages/grafana-ui/src/components/Drawer/_Drawer.scss create mode 100644 packages/grafana-ui/src/components/Dropdown/ButtonSelect.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx create mode 100644 packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.mdx create mode 100644 packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.story.tsx create mode 100644 packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx create mode 100644 packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.mdx create mode 100644 packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.story.tsx create mode 100644 packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.test.tsx create mode 100644 packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 packages/grafana-ui/src/components/ErrorBoundary/ErrorWithStack.tsx create mode 100644 packages/grafana-ui/src/components/FileUpload/FileUpload.mdx create mode 100644 packages/grafana-ui/src/components/FileUpload/FileUpload.story.tsx create mode 100644 packages/grafana-ui/src/components/FileUpload/FileUpload.test.tsx create mode 100644 packages/grafana-ui/src/components/FileUpload/FileUpload.tsx create mode 100644 packages/grafana-ui/src/components/FilterPill/FilterPill.mdx create mode 100644 packages/grafana-ui/src/components/FilterPill/FilterPill.story.tsx create mode 100644 packages/grafana-ui/src/components/FilterPill/FilterPill.tsx create mode 100644 packages/grafana-ui/src/components/FormField/FormField.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/FormField/FormField.test.tsx create mode 100644 packages/grafana-ui/src/components/FormField/FormField.tsx create mode 100644 packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/FormLabel/FormLabel.tsx create mode 100644 packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.mdx create mode 100644 packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.story.tsx create mode 100644 packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Checkbox.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Checkbox.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Checkbox.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Field.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Field.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Field.test.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Field.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldArray.mdx create mode 100644 packages/grafana-ui/src/components/Forms/FieldArray.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldArray.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldSet.mdx create mode 100644 packages/grafana-ui/src/components/Forms/FieldSet.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldSet.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldValidationMessage.mdx create mode 100644 packages/grafana-ui/src/components/Forms/FieldValidationMessage.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Form.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Form.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Form.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineField.mdx create mode 100644 packages/grafana-ui/src/components/Forms/InlineField.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineField.test.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineField.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineFieldRow.mdx create mode 100644 packages/grafana-ui/src/components/Forms/InlineFieldRow.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineLabel.mdx create mode 100644 packages/grafana-ui/src/components/Forms/InlineLabel.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineLabel.tsx create mode 100644 packages/grafana-ui/src/components/Forms/InlineSegmentGroup.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Label.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Label.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Label.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Input/Input.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Input/Input.test.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Input/__snapshots__/Input.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/IndicatorsContainer.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/NoOptionsMessage.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/Select.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.test.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/_Select.scss create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Select/__snapshots__/SelectOption.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legend.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Legend.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Legend.tsx create mode 100644 packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx create mode 100644 packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.mdx create mode 100644 packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx create mode 100644 packages/grafana-ui/src/components/Forms/commonStyles.ts create mode 100644 packages/grafana-ui/src/components/Forms/getFormStyles.ts create mode 100644 packages/grafana-ui/src/components/Forms/types.ts create mode 100644 packages/grafana-ui/src/components/Gauge/Gauge.test.tsx create mode 100644 packages/grafana-ui/src/components/Gauge/Gauge.tsx create mode 100644 packages/grafana-ui/src/components/Graph/Graph.story.tsx create mode 100644 packages/grafana-ui/src/components/Graph/Graph.test.tsx create mode 100644 packages/grafana-ui/src/components/Graph/Graph.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/GraphTooltip.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/MultiModeGraphTooltip.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/SeriesTable.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/SingleModeGraphTooltip.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphTooltip/types.ts create mode 100644 packages/grafana-ui/src/components/Graph/GraphWithLegend.story.tsx create mode 100644 packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx create mode 100644 packages/grafana-ui/src/components/Graph/types.ts create mode 100644 packages/grafana-ui/src/components/Graph/utils.test.ts create mode 100644 packages/grafana-ui/src/components/Graph/utils.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/GraphNG.story.tsx create mode 100755 packages/grafana-ui/src/components/GraphNG/GraphNG.tsx create mode 100644 packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/GraphNG/hooks.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/types.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/utils.test.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/utils.ts create mode 100644 packages/grafana-ui/src/components/Icon/Icon.mdx create mode 100644 packages/grafana-ui/src/components/Icon/Icon.story.tsx create mode 100644 packages/grafana-ui/src/components/Icon/Icon.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Apps.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Bell.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Circle.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Cog.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Favorite.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Folder.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/FolderPlus.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Grafana.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Heart.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/HeartBreak.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Import.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/PanelAdd.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/PlusSquare.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/ReusablePanel.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/Shield.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/SquareShape.tsx create mode 100644 packages/grafana-ui/src/components/Icon/assets/index.ts create mode 100644 packages/grafana-ui/src/components/Icon/assets/types.ts create mode 100644 packages/grafana-ui/src/components/Icon/custom/index.tsx create mode 100644 packages/grafana-ui/src/components/IconButton/IconButton.mdx create mode 100644 packages/grafana-ui/src/components/IconButton/IconButton.story.tsx create mode 100644 packages/grafana-ui/src/components/IconButton/IconButton.tsx create mode 100644 packages/grafana-ui/src/components/InfoBox/FeatureInfoBox.tsx create mode 100644 packages/grafana-ui/src/components/InfoBox/InfoBox.mdx create mode 100644 packages/grafana-ui/src/components/InfoBox/InfoBox.story.tsx create mode 100644 packages/grafana-ui/src/components/InfoBox/InfoBox.tsx create mode 100644 packages/grafana-ui/src/components/InfoBox/panelArt_dark.svg create mode 100644 packages/grafana-ui/src/components/InfoBox/panelArt_light.svg create mode 100644 packages/grafana-ui/src/components/InfoTooltip/InfoTooltip.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/InfoTooltip/InfoTooltip.tsx create mode 100644 packages/grafana-ui/src/components/Input/Input.mdx create mode 100644 packages/grafana-ui/src/components/Input/Input.story.tsx create mode 100644 packages/grafana-ui/src/components/Input/Input.tsx create mode 100644 packages/grafana-ui/src/components/InputControl.tsx create mode 100644 packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx create mode 100644 packages/grafana-ui/src/components/JSONFormatter/json_explorer/helpers.ts create mode 100644 packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts create mode 100644 packages/grafana-ui/src/components/Layout/Layout.mdx create mode 100644 packages/grafana-ui/src/components/Layout/Layout.story.tsx create mode 100644 packages/grafana-ui/src/components/Layout/Layout.tsx create mode 100644 packages/grafana-ui/src/components/List/AbstractList.test.tsx create mode 100644 packages/grafana-ui/src/components/List/AbstractList.tsx create mode 100644 packages/grafana-ui/src/components/List/InlineList.tsx create mode 100644 packages/grafana-ui/src/components/List/List.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/List/List.tsx create mode 100644 packages/grafana-ui/src/components/List/__snapshots__/AbstractList.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.mdx create mode 100644 packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.story.tsx create mode 100644 packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetails.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetails.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetailsRow.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogDetailsRow.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabelStats.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabels.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabels.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRow.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowContext.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowContext.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowContextProvider.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowMessage.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRowMessageDetectedFields.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRows.test.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogRows.tsx create mode 100644 packages/grafana-ui/src/components/Logs/getLogRowStyles.ts create mode 100644 packages/grafana-ui/src/components/Logs/logParser.ts create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldNameByRegexMatcherEditor.tsx create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldNameMatcherEditor.tsx create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldNamesMatcherEditor.tsx create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldTypeMatcherEditor.tsx create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx create mode 100644 packages/grafana-ui/src/components/MatchersUI/fieldMatchersUI.ts create mode 100644 packages/grafana-ui/src/components/MatchersUI/types.ts create mode 100644 packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Menu/Menu.tsx create mode 100644 packages/grafana-ui/src/components/Modal/Modal.mdx create mode 100644 packages/grafana-ui/src/components/Modal/Modal.story.tsx create mode 100644 packages/grafana-ui/src/components/Modal/Modal.test.tsx create mode 100644 packages/grafana-ui/src/components/Modal/Modal.tsx create mode 100644 packages/grafana-ui/src/components/Modal/ModalHeader.tsx create mode 100644 packages/grafana-ui/src/components/Modal/ModalTabContent.tsx create mode 100644 packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx create mode 100644 packages/grafana-ui/src/components/Modal/ModalsContext.tsx create mode 100644 packages/grafana-ui/src/components/Modal/getModalStyles.ts create mode 100644 packages/grafana-ui/src/components/Monaco/CodeEditor.mdx create mode 100644 packages/grafana-ui/src/components/Monaco/CodeEditor.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/Monaco/CodeEditor.tsx create mode 100644 packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx create mode 100644 packages/grafana-ui/src/components/Monaco/suggestions.test.ts create mode 100644 packages/grafana-ui/src/components/Monaco/suggestions.ts create mode 100644 packages/grafana-ui/src/components/Monaco/types.ts create mode 100644 packages/grafana-ui/src/components/Monaco/utils.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/Edge.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/Node.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/index.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/layout.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/types.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/usePanning.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useZoom.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/utils.test.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/utils.ts create mode 100644 packages/grafana-ui/src/components/OptionsUI/color.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/links.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/mappings.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/multiSelect.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/number.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/select.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/slider.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/stats.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/string.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/strings.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/thresholds.tsx create mode 100644 packages/grafana-ui/src/components/OptionsUI/units.tsx create mode 100644 packages/grafana-ui/src/components/PageLayout/PageToolbar.story.tsx create mode 100644 packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx create mode 100644 packages/grafana-ui/src/components/Pagination/Pagination.mdx create mode 100644 packages/grafana-ui/src/components/Pagination/Pagination.story.tsx create mode 100644 packages/grafana-ui/src/components/Pagination/Pagination.tsx create mode 100644 packages/grafana-ui/src/components/PieChart/PieChart.story.tsx create mode 100644 packages/grafana-ui/src/components/PieChart/PieChart.tsx create mode 100644 packages/grafana-ui/src/components/Portal/Portal.tsx create mode 100644 packages/grafana-ui/src/components/QueryField/QueryField.story.tsx create mode 100644 packages/grafana-ui/src/components/QueryField/QueryField.test.tsx create mode 100644 packages/grafana-ui/src/components/QueryField/QueryField.tsx create mode 100644 packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.test.tsx create mode 100644 packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx create mode 100644 packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss create mode 100644 packages/grafana-ui/src/components/SecretFormField/SecretFormField.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx create mode 100644 packages/grafana-ui/src/components/Segment/Segment.story.tsx create mode 100644 packages/grafana-ui/src/components/Segment/Segment.tsx create mode 100644 packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx create mode 100644 packages/grafana-ui/src/components/Segment/SegmentAsync.tsx create mode 100644 packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx create mode 100644 packages/grafana-ui/src/components/Segment/SegmentInput.tsx create mode 100644 packages/grafana-ui/src/components/Segment/SegmentSelect.tsx create mode 100644 packages/grafana-ui/src/components/Segment/index.ts create mode 100644 packages/grafana-ui/src/components/Segment/styles.ts create mode 100644 packages/grafana-ui/src/components/Segment/types.ts create mode 100644 packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx create mode 100644 packages/grafana-ui/src/components/Select/DropdownIndicator.tsx create mode 100644 packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx create mode 100644 packages/grafana-ui/src/components/Select/InputControl.tsx create mode 100644 packages/grafana-ui/src/components/Select/MultiValue.tsx create mode 100644 packages/grafana-ui/src/components/Select/Select.mdx create mode 100644 packages/grafana-ui/src/components/Select/Select.story.tsx create mode 100644 packages/grafana-ui/src/components/Select/Select.tsx create mode 100644 packages/grafana-ui/src/components/Select/SelectBase.test.tsx create mode 100644 packages/grafana-ui/src/components/Select/SelectBase.tsx create mode 100644 packages/grafana-ui/src/components/Select/SelectMenu.tsx create mode 100644 packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx create mode 100644 packages/grafana-ui/src/components/Select/SingleValue.tsx create mode 100644 packages/grafana-ui/src/components/Select/ValueContainer.tsx create mode 100644 packages/grafana-ui/src/components/Select/getSelectStyles.ts create mode 100644 packages/grafana-ui/src/components/Select/mockOptions.tsx create mode 100644 packages/grafana-ui/src/components/Select/resetSelectStyles.ts create mode 100644 packages/grafana-ui/src/components/Select/types.ts create mode 100644 packages/grafana-ui/src/components/Select/utils.test.ts create mode 100644 packages/grafana-ui/src/components/Select/utils.ts create mode 100644 packages/grafana-ui/src/components/SetInterval/SetInterval.tsx create mode 100644 packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts create mode 100644 packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts create mode 100644 packages/grafana-ui/src/components/SingleStatShared/index.ts create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.mdx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx create mode 100644 packages/grafana-ui/src/components/Slider/RangeSlider.tsx create mode 100644 packages/grafana-ui/src/components/Slider/Slider.mdx create mode 100644 packages/grafana-ui/src/components/Slider/Slider.story.tsx create mode 100644 packages/grafana-ui/src/components/Slider/Slider.test.tsx create mode 100644 packages/grafana-ui/src/components/Slider/Slider.tsx create mode 100644 packages/grafana-ui/src/components/Slider/_Slider.scss create mode 100644 packages/grafana-ui/src/components/Slider/styles.ts create mode 100644 packages/grafana-ui/src/components/Slider/types.ts create mode 100755 packages/grafana-ui/src/components/Sparkline/Sparkline.tsx create mode 100644 packages/grafana-ui/src/components/Sparkline/utils.ts create mode 100644 packages/grafana-ui/src/components/Spinner/Spinner.mdx create mode 100644 packages/grafana-ui/src/components/Spinner/Spinner.story.tsx create mode 100644 packages/grafana-ui/src/components/Spinner/Spinner.tsx create mode 100644 packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx create mode 100644 packages/grafana-ui/src/components/Switch/Switch.mdx create mode 100644 packages/grafana-ui/src/components/Switch/Switch.story.tsx create mode 100644 packages/grafana-ui/src/components/Switch/Switch.tsx create mode 100644 packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx create mode 100644 packages/grafana-ui/src/components/Table/BarGaugeCell.tsx create mode 100644 packages/grafana-ui/src/components/Table/DefaultCell.tsx create mode 100644 packages/grafana-ui/src/components/Table/Filter.tsx create mode 100644 packages/grafana-ui/src/components/Table/FilterActions.tsx create mode 100644 packages/grafana-ui/src/components/Table/FilterList.tsx create mode 100644 packages/grafana-ui/src/components/Table/FilterPopup.tsx create mode 100644 packages/grafana-ui/src/components/Table/ImageCell.tsx create mode 100644 packages/grafana-ui/src/components/Table/JSONViewCell.tsx create mode 100644 packages/grafana-ui/src/components/Table/Table.mdx create mode 100644 packages/grafana-ui/src/components/Table/Table.story.tsx create mode 100644 packages/grafana-ui/src/components/Table/Table.test.tsx create mode 100644 packages/grafana-ui/src/components/Table/Table.tsx create mode 100644 packages/grafana-ui/src/components/Table/TableCell.tsx create mode 100644 packages/grafana-ui/src/components/Table/styles.ts create mode 100644 packages/grafana-ui/src/components/Table/types.ts create mode 100644 packages/grafana-ui/src/components/Table/utils.test.ts create mode 100644 packages/grafana-ui/src/components/Table/utils.ts create mode 100644 packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.test.tsx create mode 100644 packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.tsx create mode 100644 packages/grafana-ui/src/components/Tabs/Counter.tsx create mode 100644 packages/grafana-ui/src/components/Tabs/Tab.tsx create mode 100644 packages/grafana-ui/src/components/Tabs/TabContent.tsx create mode 100644 packages/grafana-ui/src/components/Tabs/Tabs.story.tsx create mode 100644 packages/grafana-ui/src/components/Tabs/TabsBar.mdx create mode 100644 packages/grafana-ui/src/components/Tabs/TabsBar.tsx create mode 100644 packages/grafana-ui/src/components/Tags/Tag.mdx create mode 100644 packages/grafana-ui/src/components/Tags/Tag.story.tsx create mode 100644 packages/grafana-ui/src/components/Tags/Tag.tsx create mode 100644 packages/grafana-ui/src/components/Tags/TagList.mdx create mode 100644 packages/grafana-ui/src/components/Tags/TagList.story.tsx create mode 100644 packages/grafana-ui/src/components/Tags/TagList.tsx create mode 100644 packages/grafana-ui/src/components/TagsInput/TagItem.tsx create mode 100644 packages/grafana-ui/src/components/TagsInput/TagsInput.mdx create mode 100644 packages/grafana-ui/src/components/TagsInput/TagsInput.story.tsx create mode 100644 packages/grafana-ui/src/components/TagsInput/TagsInput.tsx create mode 100644 packages/grafana-ui/src/components/TextArea/TextArea.mdx create mode 100644 packages/grafana-ui/src/components/TextArea/TextArea.story.tsx create mode 100644 packages/grafana-ui/src/components/TextArea/TextArea.tsx create mode 100644 packages/grafana-ui/src/components/ThemeColors/Colors.story.tsx create mode 100644 packages/grafana-ui/src/components/ThemeColors/Colors.tsx create mode 100644 packages/grafana-ui/src/components/ThemeColors/ThemeColors.story.tsx create mode 100644 packages/grafana-ui/src/components/ThemeColors/ThemeColors.tsx create mode 100644 packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.story.tsx create mode 100644 packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx create mode 100644 packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeOfDayPicker.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangeInput.mdx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangeInput.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangeInput.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker.test.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.test.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerCalendar.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.test.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerFooter.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerTitle.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeForm.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeRangePicker/mapper.ts create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker/TimeZoneDescription.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker/TimeZoneGroup.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker/TimeZoneOffset.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker/TimeZoneOption.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/TimeZonePicker/TimeZoneTitle.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/_TimeOfDayPicker.scss create mode 100644 packages/grafana-ui/src/components/TimePicker/rangeOptions.ts create mode 100644 packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/Popover.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/PopoverController.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/Tooltip.mdx create mode 100644 packages/grafana-ui/src/components/Tooltip/Tooltip.story.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/Tooltip.tsx create mode 100644 packages/grafana-ui/src/components/Tooltip/_Tooltip.scss create mode 100644 packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap create mode 100644 packages/grafana-ui/src/components/Typeahead/Typeahead.test.tsx create mode 100644 packages/grafana-ui/src/components/Typeahead/Typeahead.tsx create mode 100644 packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.test.tsx create mode 100644 packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx create mode 100644 packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx create mode 100644 packages/grafana-ui/src/components/UnitPicker/UnitPicker.mdx create mode 100644 packages/grafana-ui/src/components/UnitPicker/UnitPicker.story.tsx create mode 100644 packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx create mode 100644 packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx create mode 100644 packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx create mode 100644 packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx create mode 100644 packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx create mode 100644 packages/grafana-ui/src/components/ValuePicker/ValuePicker.mdx create mode 100644 packages/grafana-ui/src/components/ValuePicker/ValuePicker.story.tsx create mode 100644 packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx create mode 100644 packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx create mode 100644 packages/grafana-ui/src/components/VizLayout/VizLayout.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/SeriesIcon.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegend.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendSeriesIcon.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendStatsList.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/types.ts create mode 100644 packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx create mode 100644 packages/grafana-ui/src/components/index.scss create mode 100644 packages/grafana-ui/src/components/index.ts create mode 100644 packages/grafana-ui/src/components/transitions/FadeTransition.tsx create mode 100644 packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/Plot.scss create mode 100644 packages/grafana-ui/src/components/uPlot/Plot.test.tsx create mode 100755 packages/grafana-ui/src/components/uPlot/Plot.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/PlotLegend.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/config.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.test.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts create mode 100755 packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts create mode 100644 packages/grafana-ui/src/components/uPlot/config/gradientFills.ts create mode 100644 packages/grafana-ui/src/components/uPlot/context.ts create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/XYCanvas.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/index.ts create mode 100644 packages/grafana-ui/src/components/uPlot/hooks.test.ts create mode 100644 packages/grafana-ui/src/components/uPlot/hooks.ts create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/plugins/index.ts create mode 100755 packages/grafana-ui/src/components/uPlot/types.ts create mode 100644 packages/grafana-ui/src/components/uPlot/utils.test.ts create mode 100755 packages/grafana-ui/src/components/uPlot/utils.ts create mode 100644 packages/grafana-ui/src/index.scss create mode 100644 packages/grafana-ui/src/index.ts create mode 100644 packages/grafana-ui/src/slate-plugins/braces.test.tsx create mode 100644 packages/grafana-ui/src/slate-plugins/braces.ts create mode 100644 packages/grafana-ui/src/slate-plugins/clear.test.tsx create mode 100644 packages/grafana-ui/src/slate-plugins/clear.ts create mode 100644 packages/grafana-ui/src/slate-plugins/clipboard.ts create mode 100644 packages/grafana-ui/src/slate-plugins/indentation.ts create mode 100644 packages/grafana-ui/src/slate-plugins/index.ts create mode 100644 packages/grafana-ui/src/slate-plugins/newline.ts create mode 100644 packages/grafana-ui/src/slate-plugins/runner.test.tsx create mode 100644 packages/grafana-ui/src/slate-plugins/runner.ts create mode 100644 packages/grafana-ui/src/slate-plugins/selection_shortcuts.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/TOKEN_MARK.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/index.ts create mode 100644 packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx create mode 100644 packages/grafana-ui/src/slate-plugins/suggestions.tsx create mode 100644 packages/grafana-ui/src/themes/ThemeContext.test.tsx create mode 100644 packages/grafana-ui/src/themes/ThemeContext.tsx create mode 100644 packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts create mode 100644 packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts create mode 100644 packages/grafana-ui/src/themes/_variables.scss.tmpl.ts create mode 100644 packages/grafana-ui/src/themes/dark.ts create mode 100644 packages/grafana-ui/src/themes/default.ts create mode 100644 packages/grafana-ui/src/themes/getTheme.ts create mode 100644 packages/grafana-ui/src/themes/index.ts create mode 100644 packages/grafana-ui/src/themes/light.ts create mode 100644 packages/grafana-ui/src/themes/mixins.ts create mode 100644 packages/grafana-ui/src/themes/selectThemeVariant.test.ts create mode 100644 packages/grafana-ui/src/themes/selectThemeVariant.ts create mode 100644 packages/grafana-ui/src/themes/stylesFactory.test.ts create mode 100644 packages/grafana-ui/src/themes/stylesFactory.ts create mode 100644 packages/grafana-ui/src/types/completion.ts create mode 100644 packages/grafana-ui/src/types/forms.ts create mode 100644 packages/grafana-ui/src/types/icon.ts create mode 100644 packages/grafana-ui/src/types/index.ts create mode 100644 packages/grafana-ui/src/types/input.ts create mode 100644 packages/grafana-ui/src/types/jquery.d.ts create mode 100644 packages/grafana-ui/src/types/mdx.d.ts create mode 100644 packages/grafana-ui/src/types/orientation.ts create mode 100644 packages/grafana-ui/src/types/size.ts create mode 100644 packages/grafana-ui/src/types/storybook.ts create mode 100644 packages/grafana-ui/src/types/theme.ts create mode 100644 packages/grafana-ui/src/utils/ansicolor.ts create mode 100644 packages/grafana-ui/src/utils/children.ts create mode 100644 packages/grafana-ui/src/utils/colors.ts create mode 100644 packages/grafana-ui/src/utils/dataLinks.ts create mode 100644 packages/grafana-ui/src/utils/dom.ts create mode 100644 packages/grafana-ui/src/utils/generatedFileBanner.ts create mode 100644 packages/grafana-ui/src/utils/index.ts create mode 100644 packages/grafana-ui/src/utils/measureText.ts create mode 100644 packages/grafana-ui/src/utils/renderOrCallToRender.ts create mode 100644 packages/grafana-ui/src/utils/scrollbar.ts create mode 100644 packages/grafana-ui/src/utils/slate.ts create mode 100644 packages/grafana-ui/src/utils/squares.ts create mode 100644 packages/grafana-ui/src/utils/standardEditors.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/DashboardStoryCanvas.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/StoryExample.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/UseState.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/data.ts create mode 100644 packages/grafana-ui/src/utils/storybook/knobs.ts create mode 100644 packages/grafana-ui/src/utils/storybook/noopControl.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/useSize.ts create mode 100644 packages/grafana-ui/src/utils/storybook/withCenteredStory.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/withPaddedStory.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/withStoryContainer.tsx create mode 100644 packages/grafana-ui/src/utils/storybook/withTheme.tsx create mode 100644 packages/grafana-ui/src/utils/tags.ts create mode 100644 packages/grafana-ui/src/utils/typeahead.ts create mode 100644 packages/grafana-ui/src/utils/useAsyncDependency.ts create mode 100644 packages/grafana-ui/src/utils/useClientRect.ts create mode 100644 packages/grafana-ui/src/utils/useComponetInstanceId.ts create mode 100644 packages/grafana-ui/src/utils/useDelayedSwitch.story.internal.tsx create mode 100644 packages/grafana-ui/src/utils/useDelayedSwitch.ts create mode 100644 packages/grafana-ui/src/utils/validate.test.ts create mode 100644 packages/grafana-ui/src/utils/validate.ts create mode 100644 packages/grafana-ui/tsconfig.build.json create mode 100644 packages/grafana-ui/tsconfig.json create mode 100644 packages/jaeger-ui-components/package.json create mode 100644 packages/jaeger-ui-components/src/ScrollManager.test.js create mode 100644 packages/jaeger-ui-components/src/ScrollManager.tsx create mode 100644 packages/jaeger-ui-components/src/Theme.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/TickLabels.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.markers.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx create mode 100644 packages/jaeger-ui-components/src/TracePageHeader/index.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.markers.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianText.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/DetailState.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/Ticks.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/index.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineRow.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/types.tsx create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/utils.test.js create mode 100644 packages/jaeger-ui-components/src/TraceTimelineViewer/utils.tsx create mode 100644 packages/jaeger-ui-components/src/Tween.test.js create mode 100644 packages/jaeger-ui-components/src/Tween.tsx create mode 100644 packages/jaeger-ui-components/src/common/BreakableText.tsx create mode 100644 packages/jaeger-ui-components/src/common/CopyIcon.test.js create mode 100644 packages/jaeger-ui-components/src/common/CopyIcon.tsx create mode 100644 packages/jaeger-ui-components/src/common/ExternalLinks.tsx create mode 100644 packages/jaeger-ui-components/src/common/LabeledList.tsx create mode 100644 packages/jaeger-ui-components/src/common/LoadingIndicator.tsx create mode 100644 packages/jaeger-ui-components/src/common/NewWindowIcon.test.js create mode 100644 packages/jaeger-ui-components/src/common/NewWindowIcon.tsx create mode 100644 packages/jaeger-ui-components/src/common/TraceName.tsx create mode 100644 packages/jaeger-ui-components/src/common/UiFindInput.test.js create mode 100644 packages/jaeger-ui-components/src/common/UiFindInput.tsx create mode 100644 packages/jaeger-ui-components/src/common/__snapshots__/CopyIcon.test.js.snap create mode 100644 packages/jaeger-ui-components/src/common/__snapshots__/NewWindowIcon.test.js.snap create mode 100644 packages/jaeger-ui-components/src/common/__snapshots__/UiFindInput.test.js.snap create mode 100644 packages/jaeger-ui-components/src/constants/default-config.tsx create mode 100644 packages/jaeger-ui-components/src/constants/index.tsx create mode 100644 packages/jaeger-ui-components/src/demo/.eslintrc create mode 100644 packages/jaeger-ui-components/src/demo/trace-generators.js create mode 100644 packages/jaeger-ui-components/src/index.ts create mode 100644 packages/jaeger-ui-components/src/keyboard-mappings.tsx create mode 100644 packages/jaeger-ui-components/src/keyboard-shortcuts.tsx create mode 100644 packages/jaeger-ui-components/src/model/ddg/PathElem.test.js create mode 100644 packages/jaeger-ui-components/src/model/ddg/PathElem.tsx create mode 100644 packages/jaeger-ui-components/src/model/ddg/__snapshots__/PathElem.test.js.snap create mode 100644 packages/jaeger-ui-components/src/model/ddg/sample-paths.test.resources.js create mode 100644 packages/jaeger-ui-components/src/model/ddg/types.tsx create mode 100644 packages/jaeger-ui-components/src/model/link-patterns.test.js create mode 100644 packages/jaeger-ui-components/src/model/link-patterns.tsx create mode 100644 packages/jaeger-ui-components/src/model/span.tsx create mode 100644 packages/jaeger-ui-components/src/model/trace-viewer.ts create mode 100644 packages/jaeger-ui-components/src/model/transform-trace-data.test.js create mode 100644 packages/jaeger-ui-components/src/model/transform-trace-data.tsx create mode 100644 packages/jaeger-ui-components/src/scroll-page.test.js create mode 100644 packages/jaeger-ui-components/src/scroll-page.tsx create mode 100644 packages/jaeger-ui-components/src/selectors/process.js create mode 100644 packages/jaeger-ui-components/src/selectors/process.test.js create mode 100644 packages/jaeger-ui-components/src/selectors/span.js create mode 100644 packages/jaeger-ui-components/src/selectors/span.test.js create mode 100644 packages/jaeger-ui-components/src/selectors/trace.fixture.js create mode 100644 packages/jaeger-ui-components/src/selectors/trace.js create mode 100644 packages/jaeger-ui-components/src/selectors/trace.test.js create mode 100644 packages/jaeger-ui-components/src/types/TDdgState.tsx create mode 100644 packages/jaeger-ui-components/src/types/TNil.tsx create mode 100644 packages/jaeger-ui-components/src/types/TTraceDiffState.tsx create mode 100644 packages/jaeger-ui-components/src/types/TTraceTimeline.tsx create mode 100644 packages/jaeger-ui-components/src/types/api-error.tsx create mode 100644 packages/jaeger-ui-components/src/types/archive.tsx create mode 100644 packages/jaeger-ui-components/src/types/config.tsx create mode 100644 packages/jaeger-ui-components/src/types/embedded.tsx create mode 100644 packages/jaeger-ui-components/src/types/index.tsx create mode 100644 packages/jaeger-ui-components/src/types/search.tsx create mode 100644 packages/jaeger-ui-components/src/types/trace.ts create mode 100644 packages/jaeger-ui-components/src/uberUtilityStyles.ts create mode 100644 packages/jaeger-ui-components/src/uiElementsContext.tsx create mode 100644 packages/jaeger-ui-components/src/url/ReferenceLink.test.js create mode 100644 packages/jaeger-ui-components/src/url/ReferenceLink.tsx create mode 100644 packages/jaeger-ui-components/src/url/externalLinkContext.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.test.js create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/DraggableManager.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/EUpdateTypes.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/README.md create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.css create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/DividerDemo.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.css create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/DraggableManagerDemo.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.css create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/RegionDemo.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/demo-ux.gif create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/demo/index.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/index.tsx create mode 100644 packages/jaeger-ui-components/src/utils/DraggableManager/types.tsx create mode 100644 packages/jaeger-ui-components/src/utils/TreeNode.js create mode 100644 packages/jaeger-ui-components/src/utils/TreeNode.test.js create mode 100644 packages/jaeger-ui-components/src/utils/color-generator.test.js create mode 100644 packages/jaeger-ui-components/src/utils/color-generator.tsx create mode 100644 packages/jaeger-ui-components/src/utils/config/get-config.tsx create mode 100644 packages/jaeger-ui-components/src/utils/date.tsx create mode 100644 packages/jaeger-ui-components/src/utils/filter-spans.test.js create mode 100644 packages/jaeger-ui-components/src/utils/filter-spans.tsx create mode 100644 packages/jaeger-ui-components/src/utils/number.tsx create mode 100644 packages/jaeger-ui-components/src/utils/sort.js create mode 100644 packages/jaeger-ui-components/src/utils/sort.test.js create mode 100644 packages/jaeger-ui-components/src/utils/span-ancestor-ids.test.js create mode 100644 packages/jaeger-ui-components/src/utils/span-ancestor-ids.tsx create mode 100644 packages/jaeger-ui-components/src/utils/test/requestAnimationFrame.js create mode 100644 packages/jaeger-ui-components/tsconfig.json create mode 100644 packages/jaeger-ui-components/typings/custom.d.ts create mode 100644 packaging/conf/nfpm.yaml create mode 100755 packaging/deb/control/postinst create mode 100644 packaging/deb/default/grafana-server create mode 100755 packaging/deb/init.d/grafana-server create mode 100644 packaging/deb/systemd/grafana-server.service create mode 100644 packaging/docker/Dockerfile create mode 100644 packaging/docker/README.md create mode 100755 packaging/docker/build-enterprise.sh create mode 100755 packaging/docker/build.sh create mode 100644 packaging/docker/custom/Dockerfile create mode 100644 packaging/docker/custom/ubuntu.Dockerfile create mode 100755 packaging/docker/push_to_docker_hub.sh create mode 100755 packaging/docker/run.sh create mode 100644 packaging/docker/ubuntu.Dockerfile create mode 100755 packaging/mac/bin/grafana create mode 100755 packaging/publish/publish_both.sh create mode 100755 packaging/publish/publish_testing.sh create mode 100755 packaging/rpm/control/postinst create mode 100755 packaging/rpm/control/posttrans create mode 100755 packaging/rpm/init.d/grafana-server create mode 100644 packaging/rpm/sysconfig/grafana-server create mode 100644 packaging/rpm/systemd/grafana-server.service create mode 100755 packaging/wrappers/grafana-cli create mode 100644 pkg/README.md create mode 100644 pkg/api/admin.go create mode 100644 pkg/api/admin_provisioning.go create mode 100644 pkg/api/admin_users.go create mode 100644 pkg/api/admin_users_test.go create mode 100644 pkg/api/alerting.go create mode 100644 pkg/api/alerting_test.go create mode 100644 pkg/api/annotations.go create mode 100644 pkg/api/annotations_test.go create mode 100644 pkg/api/api.go create mode 100644 pkg/api/apikey.go create mode 100644 pkg/api/app_routes.go create mode 100644 pkg/api/avatar/avatar.go create mode 100644 pkg/api/basic_auth.go create mode 100644 pkg/api/basic_auth_test.go create mode 100644 pkg/api/common_test.go create mode 100644 pkg/api/dashboard.go create mode 100644 pkg/api/dashboard_permission.go create mode 100644 pkg/api/dashboard_permission_test.go create mode 100644 pkg/api/dashboard_snapshot.go create mode 100644 pkg/api/dashboard_snapshot_test.go create mode 100644 pkg/api/dashboard_test.go create mode 100644 pkg/api/dataproxy.go create mode 100644 pkg/api/dataproxy_test.go create mode 100644 pkg/api/datasource/validation.go create mode 100644 pkg/api/datasources.go create mode 100644 pkg/api/datasources_test.go create mode 100644 pkg/api/dtos/acl.go create mode 100644 pkg/api/dtos/alerting.go create mode 100644 pkg/api/dtos/alerting_test.go create mode 100644 pkg/api/dtos/annotations.go create mode 100644 pkg/api/dtos/apikey.go create mode 100644 pkg/api/dtos/dashboard.go create mode 100644 pkg/api/dtos/datasource.go create mode 100644 pkg/api/dtos/folder.go create mode 100644 pkg/api/dtos/index.go create mode 100644 pkg/api/dtos/invite.go create mode 100644 pkg/api/dtos/models.go create mode 100644 pkg/api/dtos/models_test.go create mode 100644 pkg/api/dtos/org.go create mode 100644 pkg/api/dtos/playlist.go create mode 100644 pkg/api/dtos/plugins.go create mode 100644 pkg/api/dtos/prefs.go create mode 100644 pkg/api/dtos/short_url.go create mode 100644 pkg/api/dtos/user.go create mode 100644 pkg/api/dtos/user_token.go create mode 100644 pkg/api/folder.go create mode 100644 pkg/api/folder_permission.go create mode 100644 pkg/api/folder_permission_test.go create mode 100644 pkg/api/folder_test.go create mode 100644 pkg/api/frontend_logging.go create mode 100644 pkg/api/frontend_logging_test.go create mode 100644 pkg/api/frontend_metrics.go create mode 100644 pkg/api/frontendlogging/sentry.go create mode 100644 pkg/api/frontendlogging/source_maps.go create mode 100644 pkg/api/frontendlogging/test-data/foo.js.map create mode 100644 pkg/api/frontendsettings.go create mode 100644 pkg/api/frontendsettings_test.go create mode 100644 pkg/api/grafana_com_proxy.go create mode 100644 pkg/api/health.go create mode 100644 pkg/api/health_test.go create mode 100644 pkg/api/http_server.go create mode 100644 pkg/api/http_server_test.go create mode 100644 pkg/api/index.go create mode 100644 pkg/api/ldap_debug.go create mode 100644 pkg/api/ldap_debug_test.go create mode 100644 pkg/api/login.go create mode 100644 pkg/api/login_oauth.go create mode 100644 pkg/api/login_test.go create mode 100644 pkg/api/metrics.go create mode 100644 pkg/api/org.go create mode 100644 pkg/api/org_invite.go create mode 100644 pkg/api/org_users.go create mode 100644 pkg/api/org_users_test.go create mode 100644 pkg/api/password.go create mode 100644 pkg/api/playlist.go create mode 100644 pkg/api/playlist_play.go create mode 100644 pkg/api/pluginproxy/access_token_provider.go create mode 100644 pkg/api/pluginproxy/access_token_provider_test.go create mode 100644 pkg/api/pluginproxy/ds_auth_provider.go create mode 100644 pkg/api/pluginproxy/ds_proxy.go create mode 100644 pkg/api/pluginproxy/ds_proxy_test.go create mode 100644 pkg/api/pluginproxy/pluginproxy.go create mode 100644 pkg/api/pluginproxy/pluginproxy_test.go create mode 100644 pkg/api/pluginproxy/test-data/access-token-1.json create mode 100644 pkg/api/pluginproxy/test-data/access-token-2.json create mode 100644 pkg/api/pluginproxy/utils.go create mode 100644 pkg/api/pluginproxy/utils_test.go create mode 100644 pkg/api/plugins.go create mode 100644 pkg/api/preferences.go create mode 100644 pkg/api/quota.go create mode 100644 pkg/api/render.go create mode 100644 pkg/api/response/response.go create mode 100644 pkg/api/routing/route_register.go create mode 100644 pkg/api/routing/route_register_test.go create mode 100644 pkg/api/routing/routing.go create mode 100644 pkg/api/search.go create mode 100644 pkg/api/short_url.go create mode 100644 pkg/api/signup.go create mode 100644 pkg/api/stars.go create mode 100644 pkg/api/static/static.go create mode 100644 pkg/api/team.go create mode 100644 pkg/api/team_members.go create mode 100644 pkg/api/team_members_test.go create mode 100644 pkg/api/team_test.go create mode 100644 pkg/api/user.go create mode 100644 pkg/api/user_test.go create mode 100644 pkg/api/user_token.go create mode 100644 pkg/api/user_token_test.go create mode 100644 pkg/api/utils.go create mode 100644 pkg/bus/bus.go create mode 100644 pkg/bus/bus_test.go create mode 100644 pkg/cmd/grafana-cli/commands/commands.go create mode 100644 pkg/cmd/grafana-cli/commands/commandstest/context.go create mode 100644 pkg/cmd/grafana-cli/commands/commandstest/fake_api_client.go create mode 100644 pkg/cmd/grafana-cli/commands/commandstest/fake_ioutil.go create mode 100644 pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go create mode 100644 pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go create mode 100644 pkg/cmd/grafana-cli/commands/install_command.go create mode 100644 pkg/cmd/grafana-cli/commands/install_command_test.go create mode 100644 pkg/cmd/grafana-cli/commands/listremote_command.go create mode 100644 pkg/cmd/grafana-cli/commands/listversions_command.go create mode 100644 pkg/cmd/grafana-cli/commands/ls_command.go create mode 100644 pkg/cmd/grafana-cli/commands/ls_command_test.go create mode 100644 pkg/cmd/grafana-cli/commands/remove_command.go create mode 100644 pkg/cmd/grafana-cli/commands/reset_password_command.go create mode 100644 pkg/cmd/grafana-cli/commands/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip create mode 100644 pkg/cmd/grafana-cli/commands/testdata/plugin-with-absolute-member.zip create mode 100644 pkg/cmd/grafana-cli/commands/testdata/plugin-with-parent-member.zip create mode 100644 pkg/cmd/grafana-cli/commands/testdata/plugin-with-symlink.zip create mode 100644 pkg/cmd/grafana-cli/commands/upgrade_all_command.go create mode 100644 pkg/cmd/grafana-cli/commands/upgrade_all_command_test.go create mode 100644 pkg/cmd/grafana-cli/commands/upgrade_command.go create mode 100644 pkg/cmd/grafana-cli/logger/logger.go create mode 100644 pkg/cmd/grafana-cli/main.go create mode 100644 pkg/cmd/grafana-cli/models/model.go create mode 100644 pkg/cmd/grafana-cli/services/api_client.go create mode 100644 pkg/cmd/grafana-cli/services/api_client_test.go create mode 100644 pkg/cmd/grafana-cli/services/io_util.go create mode 100644 pkg/cmd/grafana-cli/services/services.go create mode 100644 pkg/cmd/grafana-cli/utils/command_line.go create mode 100644 pkg/cmd/grafana-cli/utils/grafana_path.go create mode 100644 pkg/cmd/grafana-server/diagnostics.go create mode 100644 pkg/cmd/grafana-server/diagnostics_test.go create mode 100644 pkg/cmd/grafana-server/main.go create mode 100644 pkg/components/apikeygen/apikeygen.go create mode 100644 pkg/components/apikeygen/apikeygen_test.go create mode 100644 pkg/components/dashdiffs/compare.go create mode 100644 pkg/components/dashdiffs/formatter_basic.go create mode 100644 pkg/components/dashdiffs/formatter_json.go create mode 100644 pkg/components/dashdiffs/formatter_test.go create mode 100644 pkg/components/gtime/gtime.go create mode 100644 pkg/components/gtime/gtime_test.go create mode 100644 pkg/components/imguploader/azureblobuploader.go create mode 100644 pkg/components/imguploader/azureblobuploader_test.go create mode 100644 pkg/components/imguploader/gcs/gcsuploader.go create mode 100644 pkg/components/imguploader/gcs/gcsuploader_test.go create mode 100644 pkg/components/imguploader/imguploader.go create mode 100644 pkg/components/imguploader/imguploader_test.go create mode 100644 pkg/components/imguploader/localuploader.go create mode 100644 pkg/components/imguploader/localuploader_test.go create mode 100644 pkg/components/imguploader/s3uploader.go create mode 100644 pkg/components/imguploader/s3uploader_test.go create mode 100644 pkg/components/imguploader/webdavuploader.go create mode 100644 pkg/components/imguploader/webdavuploader_test.go create mode 100644 pkg/components/null/float.go create mode 100644 pkg/components/securedata/securedata.go create mode 100644 pkg/components/securejsondata/securejsondata.go create mode 100644 pkg/components/simplejson/simplejson.go create mode 100644 pkg/components/simplejson/simplejson_go11.go create mode 100644 pkg/components/simplejson/simplejson_test.go create mode 100644 pkg/events/events.go create mode 100644 pkg/expr/classic/classic.go create mode 100644 pkg/expr/classic/classic_test.go create mode 100644 pkg/expr/classic/evaluator.go create mode 100644 pkg/expr/classic/evaluator_test.go create mode 100644 pkg/expr/classic/reduce.go create mode 100644 pkg/expr/classic/reduce_test.go create mode 100644 pkg/expr/commands.go create mode 100644 pkg/expr/graph.go create mode 100644 pkg/expr/mathexp/exp.go create mode 100644 pkg/expr/mathexp/exp_nan_null_val_test.go create mode 100644 pkg/expr/mathexp/exp_scalar_no_test.go create mode 100644 pkg/expr/mathexp/exp_series_test.go create mode 100644 pkg/expr/mathexp/exp_test.go create mode 100644 pkg/expr/mathexp/funcs.go create mode 100644 pkg/expr/mathexp/funcs_test.go create mode 100644 pkg/expr/mathexp/parse/lex.go create mode 100644 pkg/expr/mathexp/parse/lex_test.go create mode 100644 pkg/expr/mathexp/parse/node.go create mode 100644 pkg/expr/mathexp/parse/parse.go create mode 100644 pkg/expr/mathexp/reduce.go create mode 100644 pkg/expr/mathexp/reduce_test.go create mode 100644 pkg/expr/mathexp/resample.go create mode 100644 pkg/expr/mathexp/resample_test.go create mode 100644 pkg/expr/mathexp/type_series.go create mode 100644 pkg/expr/mathexp/types.go create mode 100644 pkg/expr/mathexp/types_test.go create mode 100644 pkg/expr/mathexp/union_test.go create mode 100644 pkg/expr/nodes.go create mode 100644 pkg/expr/service.go create mode 100644 pkg/expr/service_test.go create mode 100644 pkg/expr/transform.go create mode 100644 pkg/extensions/main.go create mode 100644 pkg/ifaces/gcsifaces/gcsifaces.go create mode 100644 pkg/infra/fs/copy.go create mode 100644 pkg/infra/fs/copy_test.go create mode 100644 pkg/infra/fs/exists.go create mode 100644 pkg/infra/fs/exists_test.go create mode 100644 pkg/infra/localcache/cache.go create mode 100644 pkg/infra/log/file.go create mode 100644 pkg/infra/log/file_test.go create mode 100644 pkg/infra/log/handlers.go create mode 100644 pkg/infra/log/interface.go create mode 100644 pkg/infra/log/log.go create mode 100644 pkg/infra/log/syslog.go create mode 100644 pkg/infra/log/syslog_windows.go create mode 100644 pkg/infra/metrics/frontendmetrics.go create mode 100644 pkg/infra/metrics/graphitebridge/graphite.go create mode 100644 pkg/infra/metrics/graphitebridge/graphite_test.go create mode 100644 pkg/infra/metrics/metrics.go create mode 100644 pkg/infra/metrics/metricutil/utils.go create mode 100644 pkg/infra/metrics/metricutil/utils_test.go create mode 100644 pkg/infra/metrics/service.go create mode 100644 pkg/infra/metrics/settings.go create mode 100644 pkg/infra/network/address.go create mode 100644 pkg/infra/network/address_test.go create mode 100644 pkg/infra/remotecache/database_storage.go create mode 100644 pkg/infra/remotecache/database_storage_test.go create mode 100644 pkg/infra/remotecache/memcached_storage.go create mode 100644 pkg/infra/remotecache/memcached_storage_integration_test.go create mode 100644 pkg/infra/remotecache/redis_storage.go create mode 100644 pkg/infra/remotecache/redis_storage_integration_test.go create mode 100644 pkg/infra/remotecache/redis_storage_test.go create mode 100644 pkg/infra/remotecache/remotecache.go create mode 100644 pkg/infra/remotecache/remotecache_test.go create mode 100644 pkg/infra/remotecache/testing.go create mode 100644 pkg/infra/serverlock/model.go create mode 100644 pkg/infra/serverlock/serverlock.go create mode 100644 pkg/infra/serverlock/serverlock_integration_test.go create mode 100644 pkg/infra/serverlock/serverlock_test.go create mode 100644 pkg/infra/tracing/tracing.go create mode 100644 pkg/infra/tracing/tracing_test.go create mode 100644 pkg/infra/usagestats/service.go create mode 100644 pkg/infra/usagestats/types.go create mode 100644 pkg/infra/usagestats/usage_stats.go create mode 100644 pkg/infra/usagestats/usage_stats_service_test.go create mode 100644 pkg/infra/usagestats/usage_stats_test.go create mode 100644 pkg/login/auth.go create mode 100644 pkg/login/auth_test.go create mode 100644 pkg/login/brute_force_login_protection.go create mode 100644 pkg/login/brute_force_login_protection_test.go create mode 100644 pkg/login/grafana_login.go create mode 100644 pkg/login/grafana_login_test.go create mode 100644 pkg/login/ldap_login.go create mode 100644 pkg/login/ldap_login_test.go create mode 100644 pkg/login/social/azuread_oauth.go create mode 100644 pkg/login/social/azuread_oauth_test.go create mode 100644 pkg/login/social/common.go create mode 100644 pkg/login/social/generic_oauth.go create mode 100644 pkg/login/social/generic_oauth_test.go create mode 100644 pkg/login/social/github_oauth.go create mode 100644 pkg/login/social/gitlab_oauth.go create mode 100644 pkg/login/social/google_oauth.go create mode 100644 pkg/login/social/grafana_com_oauth.go create mode 100644 pkg/login/social/okta_oauth.go create mode 100644 pkg/login/social/social.go create mode 100644 pkg/middleware/auth.go create mode 100644 pkg/middleware/auth_test.go create mode 100644 pkg/middleware/cookies/cookies.go create mode 100644 pkg/middleware/csp.go create mode 100644 pkg/middleware/dashboard_redirect.go create mode 100644 pkg/middleware/dashboard_redirect_test.go create mode 100644 pkg/middleware/gziper.go create mode 100644 pkg/middleware/logger.go create mode 100644 pkg/middleware/middleware.go create mode 100644 pkg/middleware/middleware_basic_auth_test.go create mode 100644 pkg/middleware/middleware_test.go create mode 100644 pkg/middleware/org_redirect.go create mode 100644 pkg/middleware/org_redirect_test.go create mode 100644 pkg/middleware/quota.go create mode 100644 pkg/middleware/quota_test.go create mode 100644 pkg/middleware/rate_limit.go create mode 100644 pkg/middleware/rate_limit_test.go create mode 100644 pkg/middleware/recovery.go create mode 100644 pkg/middleware/recovery_test.go create mode 100644 pkg/middleware/request_metrics.go create mode 100644 pkg/middleware/request_tracing.go create mode 100644 pkg/middleware/testing.go create mode 100644 pkg/middleware/validate_host.go create mode 100644 pkg/mocks/mock_gcsifaces/mocks.go create mode 100644 pkg/models/address.go create mode 100644 pkg/models/alert.go create mode 100644 pkg/models/alert_notifications.go create mode 100644 pkg/models/alert_test.go create mode 100644 pkg/models/apikey.go create mode 100644 pkg/models/context.go create mode 100644 pkg/models/dashboard_acl.go create mode 100644 pkg/models/dashboard_acl_test.go create mode 100644 pkg/models/dashboard_snapshot.go create mode 100644 pkg/models/dashboard_version.go create mode 100644 pkg/models/dashboards.go create mode 100644 pkg/models/dashboards_test.go create mode 100644 pkg/models/datasource.go create mode 100644 pkg/models/datasource_cache.go create mode 100644 pkg/models/datasource_cache_test.go create mode 100644 pkg/models/folders.go create mode 100644 pkg/models/health.go create mode 100644 pkg/models/helpflags.go create mode 100644 pkg/models/licensing.go create mode 100644 pkg/models/live.go create mode 100644 pkg/models/login_attempt.go create mode 100644 pkg/models/measurement.go create mode 100644 pkg/models/models.go create mode 100644 pkg/models/notifications.go create mode 100644 pkg/models/org.go create mode 100644 pkg/models/org_user.go create mode 100644 pkg/models/playlist.go create mode 100644 pkg/models/plugin_setting_cache.go create mode 100644 pkg/models/plugin_setting_cache_test.go create mode 100644 pkg/models/plugin_settings.go create mode 100644 pkg/models/preferences.go create mode 100644 pkg/models/quotas.go create mode 100644 pkg/models/shorturl.go create mode 100644 pkg/models/star.go create mode 100644 pkg/models/stats.go create mode 100644 pkg/models/tags.go create mode 100644 pkg/models/tags_test.go create mode 100644 pkg/models/team.go create mode 100644 pkg/models/team_member.go create mode 100644 pkg/models/temp_user.go create mode 100644 pkg/models/test_data.go create mode 100644 pkg/models/user.go create mode 100644 pkg/models/user_auth.go create mode 100644 pkg/models/user_token.go create mode 100644 pkg/models/validations.go create mode 100644 pkg/plugins/app_plugin.go create mode 100644 pkg/plugins/backend_utils.go create mode 100644 pkg/plugins/backendplugin/coreplugin/core_plugin.go create mode 100644 pkg/plugins/backendplugin/coreplugin/core_plugin_test.go create mode 100644 pkg/plugins/backendplugin/coreplugin/query_endpoint_adapter.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/client.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/client_v1.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/client_v2.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/log_wrapper.go create mode 100644 pkg/plugins/backendplugin/grpcplugin/log_wrapper_test.go create mode 100644 pkg/plugins/backendplugin/instrumentation.go create mode 100644 pkg/plugins/backendplugin/manager.go create mode 100644 pkg/plugins/backendplugin/manager_test.go create mode 100644 pkg/plugins/backendplugin/plugin.go create mode 100644 pkg/plugins/backendplugin/plugin_settings.go create mode 100644 pkg/plugins/backendplugin/plugin_settings_test.go create mode 100755 pkg/plugins/backendplugin/pluginextensionv2/generate.sh create mode 100644 pkg/plugins/backendplugin/pluginextensionv2/renderer_grpc_plugin.go create mode 100644 pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go create mode 100644 pkg/plugins/backendplugin/pluginextensionv2/rendererv2.proto create mode 100644 pkg/plugins/backendplugin/resource_response_stream.go create mode 100644 pkg/plugins/dashboard_importer.go create mode 100644 pkg/plugins/dashboard_importer_test.go create mode 100644 pkg/plugins/dashboards.go create mode 100644 pkg/plugins/dashboards_test.go create mode 100644 pkg/plugins/dashboards_updater.go create mode 100644 pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go create mode 100644 pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go create mode 100644 pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_v2.go create mode 100644 pkg/plugins/datasource_plugin.go create mode 100644 pkg/plugins/error.go create mode 100644 pkg/plugins/frontend_plugin.go create mode 100644 pkg/plugins/frontend_plugin_test.go create mode 100644 pkg/plugins/manifest.go create mode 100644 pkg/plugins/manifest_test.go create mode 100644 pkg/plugins/models.go create mode 100644 pkg/plugins/panel_plugin.go create mode 100644 pkg/plugins/plugins.go create mode 100644 pkg/plugins/plugins_test.go create mode 100644 pkg/plugins/queries.go create mode 100644 pkg/plugins/renderer_plugin.go create mode 100644 pkg/plugins/testdata/duplicate-plugins/nested/nested/plugin.json create mode 100644 pkg/plugins/testdata/duplicate-plugins/nested/plugin.json create mode 100644 pkg/plugins/testdata/invalid-v1-signature/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/invalid-v1-signature/plugin/plugin.json create mode 100644 pkg/plugins/testdata/invalid-v2-signature-2/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/invalid-v2-signature-2/plugin/extraFile create mode 100644 pkg/plugins/testdata/invalid-v2-signature-2/plugin/plugin.json create mode 100644 pkg/plugins/testdata/invalid-v2-signature/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/invalid-v2-signature/plugin/plugin.json create mode 100644 pkg/plugins/testdata/lacking-files/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/lacking-files/plugin/plugin.json create mode 100644 pkg/plugins/testdata/test-app/dashboards/connections.json create mode 100644 pkg/plugins/testdata/test-app/dashboards/connections_result.json create mode 100644 pkg/plugins/testdata/test-app/dashboards/memory.json create mode 100644 pkg/plugins/testdata/test-app/plugin.json create mode 100644 pkg/plugins/testdata/unsigned/plugin/plugin.json create mode 100644 pkg/plugins/testdata/valid-v2-pvt-signature/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/valid-v2-pvt-signature/plugin/plugin.json create mode 100644 pkg/plugins/testdata/valid-v2-signature/plugin/MANIFEST.txt create mode 100644 pkg/plugins/testdata/valid-v2-signature/plugin/plugin.json create mode 100644 pkg/plugins/update_checker.go create mode 100644 pkg/registry/di.go create mode 100644 pkg/registry/registry.go create mode 100644 pkg/ruleguard.rules.go create mode 100644 pkg/server/server.go create mode 100644 pkg/services/alerting/alerting_usage.go create mode 100644 pkg/services/alerting/alerting_usage_test.go create mode 100644 pkg/services/alerting/commands.go create mode 100644 pkg/services/alerting/conditions/evaluator.go create mode 100644 pkg/services/alerting/conditions/evaluator_test.go create mode 100644 pkg/services/alerting/conditions/query.go create mode 100644 pkg/services/alerting/conditions/query_test.go create mode 100644 pkg/services/alerting/conditions/reducer.go create mode 100644 pkg/services/alerting/conditions/reducer_test.go create mode 100644 pkg/services/alerting/engine.go create mode 100644 pkg/services/alerting/engine_integration_test.go create mode 100644 pkg/services/alerting/engine_test.go create mode 100644 pkg/services/alerting/eval_context.go create mode 100644 pkg/services/alerting/eval_context_test.go create mode 100644 pkg/services/alerting/eval_handler.go create mode 100644 pkg/services/alerting/eval_handler_test.go create mode 100644 pkg/services/alerting/extractor.go create mode 100644 pkg/services/alerting/extractor_test.go create mode 100644 pkg/services/alerting/interfaces.go create mode 100644 pkg/services/alerting/models.go create mode 100644 pkg/services/alerting/notifier.go create mode 100644 pkg/services/alerting/notifier_test.go create mode 100644 pkg/services/alerting/notifiers/alertmanager.go create mode 100644 pkg/services/alerting/notifiers/alertmanager_test.go create mode 100644 pkg/services/alerting/notifiers/base.go create mode 100644 pkg/services/alerting/notifiers/base_test.go create mode 100644 pkg/services/alerting/notifiers/dingding.go create mode 100644 pkg/services/alerting/notifiers/dingding_test.go create mode 100644 pkg/services/alerting/notifiers/discord.go create mode 100644 pkg/services/alerting/notifiers/discord_test.go create mode 100644 pkg/services/alerting/notifiers/email.go create mode 100644 pkg/services/alerting/notifiers/email_test.go create mode 100644 pkg/services/alerting/notifiers/googlechat.go create mode 100644 pkg/services/alerting/notifiers/googlechat_test.go create mode 100644 pkg/services/alerting/notifiers/hipchat.go create mode 100644 pkg/services/alerting/notifiers/hipchat_test.go create mode 100644 pkg/services/alerting/notifiers/kafka.go create mode 100644 pkg/services/alerting/notifiers/kafka_test.go create mode 100644 pkg/services/alerting/notifiers/line.go create mode 100644 pkg/services/alerting/notifiers/line_test.go create mode 100644 pkg/services/alerting/notifiers/opsgenie.go create mode 100644 pkg/services/alerting/notifiers/opsgenie_test.go create mode 100644 pkg/services/alerting/notifiers/pagerduty.go create mode 100644 pkg/services/alerting/notifiers/pagerduty_test.go create mode 100644 pkg/services/alerting/notifiers/pushover.go create mode 100644 pkg/services/alerting/notifiers/pushover_test.go create mode 100644 pkg/services/alerting/notifiers/sensu.go create mode 100644 pkg/services/alerting/notifiers/sensu_test.go create mode 100644 pkg/services/alerting/notifiers/sensugo.go create mode 100644 pkg/services/alerting/notifiers/sensugo_test.go create mode 100644 pkg/services/alerting/notifiers/slack.go create mode 100644 pkg/services/alerting/notifiers/slack_test.go create mode 100644 pkg/services/alerting/notifiers/teams.go create mode 100644 pkg/services/alerting/notifiers/teams_test.go create mode 100644 pkg/services/alerting/notifiers/telegram.go create mode 100644 pkg/services/alerting/notifiers/telegram_test.go create mode 100644 pkg/services/alerting/notifiers/threema.go create mode 100644 pkg/services/alerting/notifiers/threema_test.go create mode 100644 pkg/services/alerting/notifiers/victorops.go create mode 100644 pkg/services/alerting/notifiers/victorops_test.go create mode 100644 pkg/services/alerting/notifiers/webhook.go create mode 100644 pkg/services/alerting/notifiers/webhook_test.go create mode 100644 pkg/services/alerting/reader.go create mode 100644 pkg/services/alerting/result_handler.go create mode 100644 pkg/services/alerting/rule.go create mode 100644 pkg/services/alerting/rule_test.go create mode 100644 pkg/services/alerting/scheduler.go create mode 100644 pkg/services/alerting/test_notification.go create mode 100644 pkg/services/alerting/test_rule.go create mode 100644 pkg/services/alerting/testdata/collapsed-panels.json create mode 100644 pkg/services/alerting/testdata/dash-without-id.json create mode 100644 pkg/services/alerting/testdata/graphite-alert.json create mode 100644 pkg/services/alerting/testdata/influxdb-alert.json create mode 100644 pkg/services/alerting/testdata/panel-with-id-0.json create mode 100644 pkg/services/alerting/testdata/panel-without-specified-datasource.json create mode 100644 pkg/services/alerting/testdata/panels-missing-id.json create mode 100644 pkg/services/alerting/testdata/settings/empty.json create mode 100644 pkg/services/alerting/testdata/settings/one_condition.json create mode 100644 pkg/services/alerting/testdata/settings/three_conditions.json create mode 100644 pkg/services/alerting/testdata/settings/two_conditions.json create mode 100644 pkg/services/alerting/testdata/v5-dashboard.json create mode 100644 pkg/services/alerting/ticker.go create mode 100644 pkg/services/alerting/ticker_test.go create mode 100644 pkg/services/annotations/annotations.go create mode 100644 pkg/services/auth/auth_token.go create mode 100644 pkg/services/auth/auth_token_test.go create mode 100644 pkg/services/auth/model.go create mode 100644 pkg/services/auth/testing.go create mode 100644 pkg/services/auth/token_cleanup.go create mode 100644 pkg/services/auth/token_cleanup_test.go create mode 100644 pkg/services/cleanup/cleanup.go create mode 100644 pkg/services/cleanup/cleanup_test.go create mode 100644 pkg/services/contexthandler/auth_proxy_test.go create mode 100644 pkg/services/contexthandler/authproxy/authproxy.go create mode 100644 pkg/services/contexthandler/authproxy/authproxy_test.go create mode 100644 pkg/services/contexthandler/contexthandler.go create mode 100644 pkg/services/contexthandler/contexthandler_test.go create mode 100644 pkg/services/dashboards/acl_service.go create mode 100644 pkg/services/dashboards/dashboard_service.go create mode 100644 pkg/services/dashboards/dashboard_service_test.go create mode 100644 pkg/services/dashboards/folder_service.go create mode 100644 pkg/services/dashboards/folder_service_test.go create mode 100644 pkg/services/datasources/cache.go create mode 100644 pkg/services/datasources/log.go create mode 100644 pkg/services/guardian/guardian.go create mode 100644 pkg/services/guardian/guardian_test.go create mode 100644 pkg/services/guardian/guardian_util_test.go create mode 100644 pkg/services/hooks/hooks.go create mode 100644 pkg/services/ldap/helpers.go create mode 100644 pkg/services/ldap/ldap.go create mode 100644 pkg/services/ldap/ldap_helpers_test.go create mode 100644 pkg/services/ldap/ldap_login_test.go create mode 100644 pkg/services/ldap/ldap_private_test.go create mode 100644 pkg/services/ldap/ldap_test.go create mode 100644 pkg/services/ldap/settings.go create mode 100644 pkg/services/ldap/settings_test.go create mode 100644 pkg/services/ldap/testdata/ldap.toml create mode 100644 pkg/services/ldap/testing.go create mode 100644 pkg/services/librarypanels/api.go create mode 100644 pkg/services/librarypanels/database.go create mode 100644 pkg/services/librarypanels/guard.go create mode 100644 pkg/services/librarypanels/librarypanels.go create mode 100644 pkg/services/librarypanels/librarypanels_connections_test.go create mode 100644 pkg/services/librarypanels/librarypanels_create_test.go create mode 100644 pkg/services/librarypanels/librarypanels_delete_test.go create mode 100644 pkg/services/librarypanels/librarypanels_get_all_test.go create mode 100644 pkg/services/librarypanels/librarypanels_get_test.go create mode 100644 pkg/services/librarypanels/librarypanels_patch_test.go create mode 100644 pkg/services/librarypanels/librarypanels_permissions_test.go create mode 100644 pkg/services/librarypanels/librarypanels_test.go create mode 100644 pkg/services/librarypanels/models.go create mode 100644 pkg/services/licensing/oss.go create mode 100644 pkg/services/live/channel.go create mode 100644 pkg/services/live/channel_test.go create mode 100644 pkg/services/live/features/broadcast.go create mode 100644 pkg/services/live/features/dashboard.go create mode 100644 pkg/services/live/features/measurements.go create mode 100644 pkg/services/live/features/testdata.go create mode 100644 pkg/services/live/live.go create mode 100644 pkg/services/live/pluginHandler.go create mode 100644 pkg/services/login/errors.go create mode 100644 pkg/services/login/login.go create mode 100644 pkg/services/login/login_test.go create mode 100644 pkg/services/multildap/multildap.go create mode 100644 pkg/services/multildap/multildap_test.go create mode 100644 pkg/services/ngalert/alert_definition.go create mode 100644 pkg/services/ngalert/api.go create mode 100644 pkg/services/ngalert/common_test.go create mode 100644 pkg/services/ngalert/database.go create mode 100644 pkg/services/ngalert/database_mig.go create mode 100644 pkg/services/ngalert/database_test.go create mode 100644 pkg/services/ngalert/eval/alert_query.go create mode 100644 pkg/services/ngalert/eval/alert_query_test.go create mode 100644 pkg/services/ngalert/eval/eval.go create mode 100644 pkg/services/ngalert/fetcher.go create mode 100644 pkg/services/ngalert/instance.go create mode 100644 pkg/services/ngalert/instance_api.go create mode 100644 pkg/services/ngalert/instance_database.go create mode 100644 pkg/services/ngalert/instance_database_test.go create mode 100644 pkg/services/ngalert/instance_labels.go create mode 100644 pkg/services/ngalert/middleware.go create mode 100644 pkg/services/ngalert/models.go create mode 100644 pkg/services/ngalert/ngalert.go create mode 100644 pkg/services/ngalert/schedule.go create mode 100644 pkg/services/ngalert/schedule_test.go create mode 100644 pkg/services/ngalert/validator.go create mode 100644 pkg/services/notifications/codes.go create mode 100644 pkg/services/notifications/codes_test.go create mode 100644 pkg/services/notifications/email.go create mode 100644 pkg/services/notifications/mailer.go create mode 100644 pkg/services/notifications/notifications.go create mode 100644 pkg/services/notifications/notifications_test.go create mode 100644 pkg/services/notifications/send_email_integration_test.go create mode 100644 pkg/services/notifications/webhook.go create mode 100644 pkg/services/oauthtoken/oauth_token_util.go create mode 100644 pkg/services/provisioning/dashboards/config_reader.go create mode 100644 pkg/services/provisioning/dashboards/config_reader_test.go create mode 100644 pkg/services/provisioning/dashboards/dashboard.go create mode 100644 pkg/services/provisioning/dashboards/dashboard_mock.go create mode 100644 pkg/services/provisioning/dashboards/file_reader.go create mode 100644 pkg/services/provisioning/dashboards/file_reader_linux_test.go create mode 100644 pkg/services/provisioning/dashboards/file_reader_test.go create mode 100644 pkg/services/provisioning/dashboards/testdata/test-configs/applied-defaults/dev-dashboards.yaml create mode 100644 pkg/services/provisioning/dashboards/testdata/test-configs/broken-configs/commented.yaml create mode 100644 pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml create mode 100644 pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/sample.yaml create mode 100644 pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/empty-json.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/broken-dashboards/invalid.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json create mode 120000 pkg/services/provisioning/dashboards/testdata/test-dashboards/symlink create mode 100644 pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json create mode 100644 pkg/services/provisioning/dashboards/types.go create mode 100644 pkg/services/provisioning/datasources/config_reader.go create mode 100644 pkg/services/provisioning/datasources/config_reader_test.go create mode 100644 pkg/services/provisioning/datasources/datasources.go create mode 100644 pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/all-properties/not.yaml.txt create mode 100644 pkg/services/provisioning/datasources/testdata/all-properties/sample.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/all-properties/second.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/appliedDefaults/without-defaults.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/broken-yaml/broken.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/double-default/default-1.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/double-default/default-2.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/insert-two-delete-two/one-datasources.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/insert-two-delete-two/two-datasources.yml create mode 100644 pkg/services/provisioning/datasources/testdata/invalid-access/invalid-access.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/multiple-org-default/config.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/two-datasources/two-datasources.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/version-0/version-0.yaml create mode 100644 pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git create mode 100644 pkg/services/provisioning/datasources/types.go create mode 100644 pkg/services/provisioning/notifiers/alert_notifications.go create mode 100644 pkg/services/provisioning/notifiers/config_reader.go create mode 100644 pkg/services/provisioning/notifiers/config_reader_test.go create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml create mode 100644 pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml create mode 100644 pkg/services/provisioning/notifiers/types.go create mode 100644 pkg/services/provisioning/plugins/config_reader.go create mode 100644 pkg/services/provisioning/plugins/config_reader_test.go create mode 100644 pkg/services/provisioning/plugins/plugin_provisioner.go create mode 100644 pkg/services/provisioning/plugins/plugin_provisioner_test.go create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/broken.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/broken-yaml/not.yaml.text create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/correct-properties/correct-properties.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/empty_folder/.gitignore create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/incorrect-settings/incorrect-settings.yaml create mode 100644 pkg/services/provisioning/plugins/testdata/test-configs/unknown-app/unknown-app.yaml create mode 100644 pkg/services/provisioning/plugins/types.go create mode 100644 pkg/services/provisioning/provisioning.go create mode 100644 pkg/services/provisioning/provisioning_mock.go create mode 100644 pkg/services/provisioning/provisioning_test.go create mode 100644 pkg/services/provisioning/utils/utils.go create mode 100644 pkg/services/provisioning/utils/utils_test.go create mode 100644 pkg/services/provisioning/values/values.go create mode 100644 pkg/services/provisioning/values/values_test.go create mode 100644 pkg/services/quota/quota.go create mode 100644 pkg/services/rendering/http_mode.go create mode 100644 pkg/services/rendering/interface.go create mode 100644 pkg/services/rendering/plugin_mode.go create mode 100644 pkg/services/rendering/rendering.go create mode 100644 pkg/services/rendering/rendering_test.go create mode 100644 pkg/services/search/hits.go create mode 100644 pkg/services/search/service.go create mode 100644 pkg/services/search/service_test.go create mode 100644 pkg/services/search/sorting.go create mode 100644 pkg/services/shorturls/short_url_service.go create mode 100644 pkg/services/shorturls/short_url_service_test.go create mode 100644 pkg/services/sqlstore/alert.go create mode 100644 pkg/services/sqlstore/alert_notification.go create mode 100644 pkg/services/sqlstore/alert_notification_test.go create mode 100644 pkg/services/sqlstore/alert_test.go create mode 100644 pkg/services/sqlstore/annotation.go create mode 100644 pkg/services/sqlstore/annotation_cleanup.go create mode 100644 pkg/services/sqlstore/annotation_cleanup_test.go create mode 100644 pkg/services/sqlstore/annotation_test.go create mode 100644 pkg/services/sqlstore/apikey.go create mode 100644 pkg/services/sqlstore/apikey_test.go create mode 100644 pkg/services/sqlstore/dashboard.go create mode 100644 pkg/services/sqlstore/dashboard_acl.go create mode 100644 pkg/services/sqlstore/dashboard_acl_test.go create mode 100644 pkg/services/sqlstore/dashboard_folder_test.go create mode 100644 pkg/services/sqlstore/dashboard_provisioning.go create mode 100644 pkg/services/sqlstore/dashboard_provisioning_test.go create mode 100644 pkg/services/sqlstore/dashboard_service_integration_test.go create mode 100644 pkg/services/sqlstore/dashboard_snapshot.go create mode 100644 pkg/services/sqlstore/dashboard_snapshot_test.go create mode 100644 pkg/services/sqlstore/dashboard_test.go create mode 100644 pkg/services/sqlstore/dashboard_version.go create mode 100644 pkg/services/sqlstore/dashboard_version_test.go create mode 100644 pkg/services/sqlstore/database_wrapper.go create mode 100644 pkg/services/sqlstore/datasource.go create mode 100644 pkg/services/sqlstore/datasource_test.go create mode 100644 pkg/services/sqlstore/health.go create mode 100644 pkg/services/sqlstore/health_test.go create mode 100644 pkg/services/sqlstore/logger.go create mode 100644 pkg/services/sqlstore/login_attempt.go create mode 100644 pkg/services/sqlstore/login_attempt_test.go create mode 100644 pkg/services/sqlstore/migrations/alert_mig.go create mode 100644 pkg/services/sqlstore/migrations/annotation_mig.go create mode 100644 pkg/services/sqlstore/migrations/apikey_mig.go create mode 100644 pkg/services/sqlstore/migrations/cache_data_mig.go create mode 100644 pkg/services/sqlstore/migrations/common.go create mode 100644 pkg/services/sqlstore/migrations/dashboard_acl.go create mode 100644 pkg/services/sqlstore/migrations/dashboard_mig.go create mode 100644 pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go create mode 100644 pkg/services/sqlstore/migrations/dashboard_version_mig.go create mode 100644 pkg/services/sqlstore/migrations/datasource_mig.go create mode 100644 pkg/services/sqlstore/migrations/heartbeat_mig.go create mode 100644 pkg/services/sqlstore/migrations/login_attempt_mig.go create mode 100644 pkg/services/sqlstore/migrations/migrations.go create mode 100644 pkg/services/sqlstore/migrations/migrations_test.go create mode 100644 pkg/services/sqlstore/migrations/org_mig.go create mode 100644 pkg/services/sqlstore/migrations/playlist_mig.go create mode 100644 pkg/services/sqlstore/migrations/plugin_setting.go create mode 100644 pkg/services/sqlstore/migrations/preferences_mig.go create mode 100644 pkg/services/sqlstore/migrations/quota_mig.go create mode 100644 pkg/services/sqlstore/migrations/serverlock_migrations.go create mode 100644 pkg/services/sqlstore/migrations/session_mig.go create mode 100755 pkg/services/sqlstore/migrations/short_url_mig.go create mode 100644 pkg/services/sqlstore/migrations/stats_mig.go create mode 100644 pkg/services/sqlstore/migrations/tag_mig.go create mode 100644 pkg/services/sqlstore/migrations/team_mig.go create mode 100644 pkg/services/sqlstore/migrations/temp_user.go create mode 100644 pkg/services/sqlstore/migrations/user_auth_mig.go create mode 100644 pkg/services/sqlstore/migrations/user_auth_token_mig.go create mode 100644 pkg/services/sqlstore/migrations/user_mig.go create mode 100644 pkg/services/sqlstore/migrator/column.go create mode 100644 pkg/services/sqlstore/migrator/conditions.go create mode 100644 pkg/services/sqlstore/migrator/dialect.go create mode 100644 pkg/services/sqlstore/migrator/migrations.go create mode 100644 pkg/services/sqlstore/migrator/migrator.go create mode 100644 pkg/services/sqlstore/migrator/mysql_dialect.go create mode 100644 pkg/services/sqlstore/migrator/postgres_dialect.go create mode 100644 pkg/services/sqlstore/migrator/sqlite_dialect.go create mode 100644 pkg/services/sqlstore/migrator/types.go create mode 100644 pkg/services/sqlstore/org.go create mode 100644 pkg/services/sqlstore/org_test.go create mode 100644 pkg/services/sqlstore/org_users.go create mode 100644 pkg/services/sqlstore/permissions/dashboard.go create mode 100644 pkg/services/sqlstore/playlist.go create mode 100644 pkg/services/sqlstore/playlist_test.go create mode 100644 pkg/services/sqlstore/plugin_setting.go create mode 100644 pkg/services/sqlstore/preferences.go create mode 100644 pkg/services/sqlstore/preferences_test.go create mode 100644 pkg/services/sqlstore/quota.go create mode 100644 pkg/services/sqlstore/quota_test.go create mode 100644 pkg/services/sqlstore/searchstore/builder.go create mode 100644 pkg/services/sqlstore/searchstore/doc.go create mode 100644 pkg/services/sqlstore/searchstore/filters.go create mode 100644 pkg/services/sqlstore/searchstore/search_test.go create mode 100644 pkg/services/sqlstore/session.go create mode 100644 pkg/services/sqlstore/sql_test_data.go create mode 100644 pkg/services/sqlstore/sqlbuilder.go create mode 100644 pkg/services/sqlstore/sqlbuilder_test.go create mode 100644 pkg/services/sqlstore/sqlstore.go create mode 100644 pkg/services/sqlstore/sqlstore.goconvey create mode 100644 pkg/services/sqlstore/sqlstore_test.go create mode 100644 pkg/services/sqlstore/sqlutil/sqlutil.go create mode 100644 pkg/services/sqlstore/star.go create mode 100644 pkg/services/sqlstore/stars_test.go create mode 100644 pkg/services/sqlstore/stats.go create mode 100644 pkg/services/sqlstore/stats_integration_test.go create mode 100644 pkg/services/sqlstore/stats_test.go create mode 100644 pkg/services/sqlstore/tables.go create mode 100644 pkg/services/sqlstore/tags.go create mode 100644 pkg/services/sqlstore/tags_test.go create mode 100644 pkg/services/sqlstore/team.go create mode 100644 pkg/services/sqlstore/team_test.go create mode 100644 pkg/services/sqlstore/temp_user.go create mode 100644 pkg/services/sqlstore/temp_user_test.go create mode 100644 pkg/services/sqlstore/tls_mysql.go create mode 100644 pkg/services/sqlstore/transactions.go create mode 100644 pkg/services/sqlstore/transactions_test.go create mode 100644 pkg/services/sqlstore/user.go create mode 100644 pkg/services/sqlstore/user_auth.go create mode 100644 pkg/services/sqlstore/user_auth_test.go create mode 100644 pkg/services/sqlstore/user_test.go create mode 100644 pkg/services/teamguardian/team.go create mode 100644 pkg/services/teamguardian/teams_test.go create mode 100644 pkg/services/validations/oss.go create mode 100644 pkg/setting/date_formats.go create mode 100644 pkg/setting/date_formats_test.go create mode 100644 pkg/setting/dynamic_settings_test.go create mode 100644 pkg/setting/expanders.go create mode 100644 pkg/setting/expanders_test.go create mode 100644 pkg/setting/setting.go create mode 100644 pkg/setting/setting_oauth.go create mode 100644 pkg/setting/setting_plugins.go create mode 100644 pkg/setting/setting_plugins_test.go create mode 100644 pkg/setting/setting_quota.go create mode 100644 pkg/setting/setting_sentry.go create mode 100644 pkg/setting/setting_session_test.go create mode 100644 pkg/setting/setting_smtp.go create mode 100644 pkg/setting/setting_test.go create mode 100644 pkg/setting/testdata/invalid.ini create mode 100644 pkg/setting/testdata/override.ini create mode 100644 pkg/setting/testdata/override_windows.ini create mode 100644 pkg/setting/testdata/session.ini create mode 100644 pkg/tests/README.md create mode 100644 pkg/tests/api/metrics/README.md create mode 100644 pkg/tests/api/metrics/api_metrics_test.go create mode 100644 pkg/tests/testinfra/testinfra.go create mode 100644 pkg/tests/web/index_view_test.go create mode 100644 pkg/tsdb/azuremonitor/applicationinsights-datasource.go create mode 100644 pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go create mode 100644 pkg/tsdb/azuremonitor/applicationinsights-metrics.go create mode 100644 pkg/tsdb/azuremonitor/applicationinsights-metrics_test.go create mode 100644 pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go create mode 100644 pkg/tsdb/azuremonitor/azure-log-analytics-datasource_test.go create mode 100644 pkg/tsdb/azuremonitor/azure-log-analytics-table-frame.go create mode 100644 pkg/tsdb/azuremonitor/azure-log-analytics-table-frame_test.go create mode 100644 pkg/tsdb/azuremonitor/azuremonitor-datasource.go create mode 100644 pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go create mode 100644 pkg/tsdb/azuremonitor/azuremonitor-time.go create mode 100644 pkg/tsdb/azuremonitor/azuremonitor.go create mode 100644 pkg/tsdb/azuremonitor/insights-analytics-datasource.go create mode 100644 pkg/tsdb/azuremonitor/macros.go create mode 100644 pkg/tsdb/azuremonitor/macros_test.go create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/1-application-insights-response-raw-query.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/2-application-insights-response-raw-query-segmented.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/4-application-insights-response-metrics-multi-segmented.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/4-application-insights-response-metrics-no-segment.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/4-application-insights-response-metrics-segmented.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/5-application-insights-empty-response.json create mode 100644 pkg/tsdb/azuremonitor/testdata/applicationinsights/bigger.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/1-azure-monitor-response-avg.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/2-azure-monitor-response-total.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/3-azure-monitor-response-maximum.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/4-azure-monitor-response-minimum.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/5-azure-monitor-response-count.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/6-azure-monitor-response-single-dimension.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/7-azure-monitor-response-multi-dimension.json create mode 100644 pkg/tsdb/azuremonitor/testdata/azuremonitor/8-azure-monitor-response-unspecified-unit.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/1-log-analytics-response-metrics-single-series.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/2-log-analytics-response-metrics-multiple-series.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/3-log-analytics-response-metrics-no-metric-column.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/4-log-analytics-response-metrics-no-time-column.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/5-log-analytics-response-metrics-no-value-column.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/6-log-analytics-response-table.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/7-log-analytics-all-types-table.json create mode 100644 pkg/tsdb/azuremonitor/testdata/loganalytics/8-log-analytics-response-nan-inf.json create mode 100644 pkg/tsdb/azuremonitor/time-grain.go create mode 100644 pkg/tsdb/azuremonitor/time-grain_test.go create mode 100644 pkg/tsdb/azuremonitor/types.go create mode 100644 pkg/tsdb/azuremonitor/url-builder.go create mode 100644 pkg/tsdb/azuremonitor/url-builder_test.go create mode 100644 pkg/tsdb/cloudmonitoring/annotation_query.go create mode 100644 pkg/tsdb/cloudmonitoring/annotation_query_test.go create mode 100644 pkg/tsdb/cloudmonitoring/cloudmonitoring.go create mode 100644 pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go create mode 100644 pkg/tsdb/cloudmonitoring/test-data/1-series-response-agg-one-metric.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/2-series-response-no-agg.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/3-series-response-distribution-exponential.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/4-series-response-distribution-explicit.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/5-series-response-meta-data.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/6-series-response-slo.json create mode 100644 pkg/tsdb/cloudmonitoring/test-data/7-series-response-mql.json create mode 100644 pkg/tsdb/cloudmonitoring/time_series_filter.go create mode 100644 pkg/tsdb/cloudmonitoring/time_series_query.go create mode 100644 pkg/tsdb/cloudmonitoring/types.go create mode 100644 pkg/tsdb/cloudwatch/annotation_query.go create mode 100644 pkg/tsdb/cloudwatch/cloudwatch.go create mode 100644 pkg/tsdb/cloudwatch/cloudwatch_query.go create mode 100644 pkg/tsdb/cloudwatch/cloudwatch_query_test.go create mode 100644 pkg/tsdb/cloudwatch/get_metric_data_executor.go create mode 100644 pkg/tsdb/cloudwatch/get_metric_data_executor_test.go create mode 100644 pkg/tsdb/cloudwatch/live.go create mode 100644 pkg/tsdb/cloudwatch/log_actions.go create mode 100644 pkg/tsdb/cloudwatch/log_actions_test.go create mode 100644 pkg/tsdb/cloudwatch/log_query.go create mode 100644 pkg/tsdb/cloudwatch/log_query_test.go create mode 100644 pkg/tsdb/cloudwatch/logs.go create mode 100644 pkg/tsdb/cloudwatch/metric_data_input_builder.go create mode 100644 pkg/tsdb/cloudwatch/metric_data_query_builder.go create mode 100644 pkg/tsdb/cloudwatch/metric_data_query_builder_test.go create mode 100644 pkg/tsdb/cloudwatch/metric_find_query.go create mode 100644 pkg/tsdb/cloudwatch/metric_find_query_test.go create mode 100644 pkg/tsdb/cloudwatch/query_transformer.go create mode 100644 pkg/tsdb/cloudwatch/query_transformer_test.go create mode 100644 pkg/tsdb/cloudwatch/request_parser.go create mode 100644 pkg/tsdb/cloudwatch/request_parser_test.go create mode 100644 pkg/tsdb/cloudwatch/response_parser.go create mode 100644 pkg/tsdb/cloudwatch/response_parser_test.go create mode 100644 pkg/tsdb/cloudwatch/sort_frame.go create mode 100644 pkg/tsdb/cloudwatch/sort_frame_test.go create mode 100644 pkg/tsdb/cloudwatch/test_utils.go create mode 100644 pkg/tsdb/cloudwatch/time_series_query.go create mode 100644 pkg/tsdb/cloudwatch/time_series_query_test.go create mode 100644 pkg/tsdb/cloudwatch/types.go create mode 100644 pkg/tsdb/elasticsearch/client/client.go create mode 100644 pkg/tsdb/elasticsearch/client/client_test.go create mode 100644 pkg/tsdb/elasticsearch/client/index_pattern.go create mode 100644 pkg/tsdb/elasticsearch/client/index_pattern_test.go create mode 100644 pkg/tsdb/elasticsearch/client/models.go create mode 100644 pkg/tsdb/elasticsearch/client/search_request.go create mode 100644 pkg/tsdb/elasticsearch/client/search_request_test.go create mode 100644 pkg/tsdb/elasticsearch/elasticsearch.go create mode 100644 pkg/tsdb/elasticsearch/models.go create mode 100644 pkg/tsdb/elasticsearch/response_parser.go create mode 100644 pkg/tsdb/elasticsearch/response_parser_test.go create mode 100644 pkg/tsdb/elasticsearch/time_series_query.go create mode 100644 pkg/tsdb/elasticsearch/time_series_query_test.go create mode 100644 pkg/tsdb/fake_test.go create mode 100644 pkg/tsdb/frame_util.go create mode 100644 pkg/tsdb/graphite/graphite.go create mode 100644 pkg/tsdb/graphite/graphite_test.go create mode 100644 pkg/tsdb/graphite/types.go create mode 100644 pkg/tsdb/influxdb/flux/builder.go create mode 100644 pkg/tsdb/influxdb/flux/builder_test.go create mode 100644 pkg/tsdb/influxdb/flux/executor.go create mode 100644 pkg/tsdb/influxdb/flux/executor_test.go create mode 100644 pkg/tsdb/influxdb/flux/flux.go create mode 100644 pkg/tsdb/influxdb/flux/macros.go create mode 100644 pkg/tsdb/influxdb/flux/macros_test.go create mode 100644 pkg/tsdb/influxdb/flux/query_models.go create mode 100644 pkg/tsdb/influxdb/flux/testdata/aggregate.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/aggregate.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/boolean.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/boolean.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/buckets.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/buckets.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/grouping.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/grouping.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/max_data_points_exceeded.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/multiple.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/multiple.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/non_standard_time_column.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/non_standard_time_column.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/renamed.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/renamed.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/simple.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/simple.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/single.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/single.golden.txt create mode 100644 pkg/tsdb/influxdb/flux/testdata/table.csv create mode 100644 pkg/tsdb/influxdb/flux/testdata/table.golden.txt create mode 100644 pkg/tsdb/influxdb/influxdb.go create mode 100644 pkg/tsdb/influxdb/influxdb_test.go create mode 100644 pkg/tsdb/influxdb/model_parser.go create mode 100644 pkg/tsdb/influxdb/model_parser_test.go create mode 100644 pkg/tsdb/influxdb/models.go create mode 100644 pkg/tsdb/influxdb/query.go create mode 100644 pkg/tsdb/influxdb/query_part.go create mode 100644 pkg/tsdb/influxdb/query_part_test.go create mode 100644 pkg/tsdb/influxdb/query_test.go create mode 100644 pkg/tsdb/influxdb/response_parser.go create mode 100644 pkg/tsdb/influxdb/response_parser_test.go create mode 100644 pkg/tsdb/interval.go create mode 100644 pkg/tsdb/interval_test.go create mode 100644 pkg/tsdb/loki/loki.go create mode 100644 pkg/tsdb/loki/loki_test.go create mode 100644 pkg/tsdb/loki/types.go create mode 100644 pkg/tsdb/models.go create mode 100644 pkg/tsdb/mssql/macros.go create mode 100644 pkg/tsdb/mssql/macros_test.go create mode 100644 pkg/tsdb/mssql/mssql.go create mode 100644 pkg/tsdb/mssql/mssql_test.go create mode 100644 pkg/tsdb/mysql/macros.go create mode 100644 pkg/tsdb/mysql/macros_test.go create mode 100644 pkg/tsdb/mysql/mysql.go create mode 100644 pkg/tsdb/mysql/mysql_test.go create mode 100644 pkg/tsdb/opentsdb/opentsdb.go create mode 100644 pkg/tsdb/opentsdb/opentsdb_test.go create mode 100644 pkg/tsdb/opentsdb/types.go create mode 100644 pkg/tsdb/postgres/locker.go create mode 100644 pkg/tsdb/postgres/locker_test.go create mode 100644 pkg/tsdb/postgres/macros.go create mode 100644 pkg/tsdb/postgres/macros_test.go create mode 100644 pkg/tsdb/postgres/postgres.go create mode 100644 pkg/tsdb/postgres/postgres_test.go create mode 100644 pkg/tsdb/postgres/tlsmanager.go create mode 100644 pkg/tsdb/postgres/tlsmanager_test.go create mode 100644 pkg/tsdb/prometheus/prometheus.go create mode 100644 pkg/tsdb/prometheus/prometheus_test.go create mode 100644 pkg/tsdb/prometheus/types.go create mode 100644 pkg/tsdb/query_endpoint.go create mode 100644 pkg/tsdb/request.go create mode 100644 pkg/tsdb/request_test.go create mode 100644 pkg/tsdb/sqleng/sql_engine.go create mode 100644 pkg/tsdb/sqleng/sql_engine_test.go create mode 100644 pkg/tsdb/tempo/tempo.go create mode 100644 pkg/tsdb/tempo/tempo_test.go create mode 100644 pkg/tsdb/testdatasource/resource_handler.go create mode 100644 pkg/tsdb/testdatasource/scenarios.go create mode 100644 pkg/tsdb/testdatasource/scenarios_test.go create mode 100644 pkg/tsdb/testdatasource/testdata.go create mode 100644 pkg/tsdb/testdatasource/utils.go create mode 100644 pkg/tsdb/time_range.go create mode 100644 pkg/tsdb/time_range_test.go create mode 100644 pkg/util/encoding.go create mode 100644 pkg/util/encoding_test.go create mode 100644 pkg/util/encryption.go create mode 100644 pkg/util/encryption_test.go create mode 100644 pkg/util/errutil/errors.go create mode 100644 pkg/util/filepath.go create mode 100644 pkg/util/ip_address.go create mode 100644 pkg/util/ip_address_test.go create mode 100644 pkg/util/json.go create mode 100644 pkg/util/math.go create mode 100644 pkg/util/md5.go create mode 100644 pkg/util/md5_test.go create mode 100644 pkg/util/proxyutil/proxyutil.go create mode 100644 pkg/util/proxyutil/proxyutil_test.go create mode 100644 pkg/util/retryer/retryer.go create mode 100644 pkg/util/retryer/retryer_test.go create mode 100644 pkg/util/shortid_generator.go create mode 100644 pkg/util/shortid_generator_test.go create mode 100644 pkg/util/split_email.go create mode 100644 pkg/util/split_email_test.go create mode 100644 pkg/util/strings.go create mode 100644 pkg/util/strings_test.go create mode 100644 pkg/util/url.go create mode 100644 pkg/util/url_test.go create mode 100644 pkg/util/validation.go create mode 100644 pkg/util/validation_test.go create mode 100644 plugins-bundled/.gitignore create mode 100644 plugins-bundled/README.md create mode 100644 plugins-bundled/external.json create mode 100644 plugins-bundled/internal/input-datasource/LICENSE create mode 100644 plugins-bundled/internal/input-datasource/README.md create mode 100644 plugins-bundled/internal/input-datasource/jest.config.js create mode 100644 plugins-bundled/internal/input-datasource/package.json create mode 100644 plugins-bundled/internal/input-datasource/src/InputConfigEditor.tsx create mode 100644 plugins-bundled/internal/input-datasource/src/InputDatasource.test.ts create mode 100644 plugins-bundled/internal/input-datasource/src/InputDatasource.ts create mode 100644 plugins-bundled/internal/input-datasource/src/InputQueryEditor.tsx create mode 100644 plugins-bundled/internal/input-datasource/src/img/input.svg create mode 100644 plugins-bundled/internal/input-datasource/src/module.ts create mode 100644 plugins-bundled/internal/input-datasource/src/plugin.json create mode 100644 plugins-bundled/internal/input-datasource/src/testHelpers.ts create mode 100644 plugins-bundled/internal/input-datasource/src/types.ts create mode 100644 plugins-bundled/internal/input-datasource/src/utils.ts create mode 100644 plugins-bundled/internal/input-datasource/tsconfig.json create mode 100644 public/app/app.ts create mode 100644 public/app/core/actions/cleanUp.ts create mode 100644 public/app/core/actions/index.ts create mode 100644 public/app/core/angular_wrappers.ts create mode 100644 public/app/core/app_events.ts create mode 100644 public/app/core/components/Animations/FadeIn.tsx create mode 100644 public/app/core/components/Animations/SlideDown.tsx create mode 100644 public/app/core/components/AppNotifications/AppNotificationItem.tsx create mode 100644 public/app/core/components/AppNotifications/AppNotificationList.tsx create mode 100644 public/app/core/components/Branding/Branding.tsx create mode 100644 public/app/core/components/Card/Card.tsx create mode 100644 public/app/core/components/CopyToClipboard/CopyToClipboard.tsx create mode 100644 public/app/core/components/DynamicImports/ErrorLoadingChunk.tsx create mode 100644 public/app/core/components/DynamicImports/LoadingChunkPlaceHolder.tsx create mode 100644 public/app/core/components/DynamicImports/SafeDynamicImport.test.tsx create mode 100644 public/app/core/components/DynamicImports/SafeDynamicImport.tsx create mode 100644 public/app/core/components/EmptyListCTA/EmptyListCTA.tsx create mode 100644 public/app/core/components/ErrorPage/ErrorPage.tsx create mode 100644 public/app/core/components/FilterInput/FilterInput.tsx create mode 100644 public/app/core/components/Footer/Footer.tsx create mode 100644 public/app/core/components/ForgottenPassword/ChangePassword.tsx create mode 100644 public/app/core/components/ForgottenPassword/ChangePasswordPage.tsx create mode 100644 public/app/core/components/ForgottenPassword/ForgottenPassword.tsx create mode 100644 public/app/core/components/ForgottenPassword/SendResetMailPage.tsx create mode 100644 public/app/core/components/LayoutSelector/LayoutSelector.tsx create mode 100644 public/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx create mode 100644 public/app/core/components/LocalStorageValueProvider/index.tsx create mode 100644 public/app/core/components/Login/LoginCtrl.tsx create mode 100644 public/app/core/components/Login/LoginForm.tsx create mode 100644 public/app/core/components/Login/LoginLayout.tsx create mode 100644 public/app/core/components/Login/LoginPage.tsx create mode 100644 public/app/core/components/Login/LoginServiceButtons.tsx create mode 100644 public/app/core/components/Login/UserSignup.tsx create mode 100644 public/app/core/components/OrgActionBar/OrgActionBar.test.tsx create mode 100644 public/app/core/components/OrgActionBar/OrgActionBar.tsx create mode 100644 public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap create mode 100644 public/app/core/components/OrgSwitcher.tsx create mode 100644 public/app/core/components/Page/Page.tsx create mode 100644 public/app/core/components/Page/PageContents.tsx create mode 100644 public/app/core/components/PageHeader/PageHeader.test.tsx create mode 100644 public/app/core/components/PageHeader/PageHeader.tsx create mode 100644 public/app/core/components/PageLoader/PageLoader.tsx create mode 100644 public/app/core/components/PermissionList/AddPermission.tsx create mode 100644 public/app/core/components/PermissionList/DisabledPermissionListItem.tsx create mode 100644 public/app/core/components/PermissionList/PermissionList.tsx create mode 100644 public/app/core/components/PermissionList/PermissionListItem.tsx create mode 100644 public/app/core/components/PermissionList/PermissionsInfo.tsx create mode 100644 public/app/core/components/PluginHelp/PluginHelp.tsx create mode 100644 public/app/core/components/QueryOperationRow/QueryOperationAction.test.tsx create mode 100644 public/app/core/components/QueryOperationRow/QueryOperationAction.tsx create mode 100644 public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx create mode 100644 public/app/core/components/QueryOperationRow/QueryOperationRow.tsx create mode 100644 public/app/core/components/Select/DashboardPicker.tsx create mode 100644 public/app/core/components/Select/DataSourcePicker.tsx create mode 100644 public/app/core/components/Select/FolderPicker.test.tsx create mode 100644 public/app/core/components/Select/FolderPicker.tsx create mode 100644 public/app/core/components/Select/MetricSelect.test.tsx create mode 100644 public/app/core/components/Select/MetricSelect.tsx create mode 100644 public/app/core/components/Select/OrgPicker.tsx create mode 100644 public/app/core/components/Select/SortPicker.tsx create mode 100644 public/app/core/components/Select/TeamPicker.test.tsx create mode 100644 public/app/core/components/Select/TeamPicker.tsx create mode 100644 public/app/core/components/Select/UserPicker.test.tsx create mode 100644 public/app/core/components/Select/UserPicker.tsx create mode 100644 public/app/core/components/Select/__snapshots__/FolderPicker.test.tsx.snap create mode 100644 public/app/core/components/SharedPreferences/SharedPreferences.tsx create mode 100644 public/app/core/components/Signup/Signup.tsx create mode 100644 public/app/core/components/Signup/SignupPage.tsx create mode 100644 public/app/core/components/Signup/VerifyEmail.tsx create mode 100644 public/app/core/components/Signup/VerifyEmailPage.tsx create mode 100644 public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx create mode 100644 public/app/core/components/TagFilter/TagBadge.tsx create mode 100644 public/app/core/components/TagFilter/TagFilter.tsx create mode 100644 public/app/core/components/TagFilter/TagOption.tsx create mode 100644 public/app/core/components/TagFilter/TagValue.tsx create mode 100644 public/app/core/components/TimePicker/TimePickerWithHistory.tsx create mode 100644 public/app/core/components/TraceToLogsSettings.tsx create mode 100644 public/app/core/components/TransformersUI/CalculateFieldTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/ConcatenateTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByNameTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByRefIdTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts create mode 100644 public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts create mode 100644 public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/LabelsToFieldsTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/MergeTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/ReduceTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/RenameByRegexTransformer.tsx create mode 100644 public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/SeriesToRowsTransformerEditor.tsx create mode 100644 public/app/core/components/TransformersUI/SortByTransformerEditor.tsx create mode 100644 public/app/core/components/WithFeatureToggle.tsx create mode 100644 public/app/core/components/code_editor/brace.d.ts create mode 100644 public/app/core/components/code_editor/code_editor.ts create mode 100644 public/app/core/components/code_editor/theme-grafana-dark.js create mode 100644 public/app/core/components/colorpicker/spectrum_picker.ts create mode 100644 public/app/core/components/connectWithCleanUp.tsx create mode 100644 public/app/core/components/dashboard_selector.ts create mode 100644 public/app/core/components/form_dropdown/form_dropdown.ts create mode 100644 public/app/core/components/help/HelpModal.tsx create mode 100644 public/app/core/components/info_popover.ts create mode 100644 public/app/core/components/jsontree/jsontree.ts create mode 100644 public/app/core/components/modals/AngularModalProxy.tsx create mode 100644 public/app/core/components/query_part/query_part.ts create mode 100644 public/app/core/components/query_part/query_part_editor.ts create mode 100644 public/app/core/components/scroll/scroll.ts create mode 100644 public/app/core/components/search/search_results.ts create mode 100644 public/app/core/components/sidemenu/BottomNavLinks.test.tsx create mode 100644 public/app/core/components/sidemenu/BottomNavLinks.tsx create mode 100644 public/app/core/components/sidemenu/BottomSection.test.tsx create mode 100644 public/app/core/components/sidemenu/BottomSection.tsx create mode 100644 public/app/core/components/sidemenu/DropDownChild.test.tsx create mode 100644 public/app/core/components/sidemenu/DropDownChild.tsx create mode 100644 public/app/core/components/sidemenu/SideMenu.test.tsx create mode 100644 public/app/core/components/sidemenu/SideMenu.tsx create mode 100644 public/app/core/components/sidemenu/SideMenuDropDown.test.tsx create mode 100644 public/app/core/components/sidemenu/SideMenuDropDown.tsx create mode 100644 public/app/core/components/sidemenu/SignIn.test.tsx create mode 100644 public/app/core/components/sidemenu/SignIn.tsx create mode 100644 public/app/core/components/sidemenu/TopSection.test.tsx create mode 100644 public/app/core/components/sidemenu/TopSection.tsx create mode 100644 public/app/core/components/sidemenu/TopSectionItem.test.tsx create mode 100644 public/app/core/components/sidemenu/TopSectionItem.tsx create mode 100644 public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap create mode 100644 public/app/core/components/sidemenu/utils.test.ts create mode 100644 public/app/core/components/sidemenu/utils.ts create mode 100644 public/app/core/components/sql_part/sql_part.ts create mode 100644 public/app/core/components/sql_part/sql_part_editor.ts create mode 100644 public/app/core/components/switch.ts create mode 100644 public/app/core/config.ts create mode 100644 public/app/core/constants.ts create mode 100644 public/app/core/controllers/all.ts create mode 100644 public/app/core/controllers/invited_ctrl.ts create mode 100644 public/app/core/controllers/json_editor_ctrl.ts create mode 100644 public/app/core/controllers/reset_password_ctrl.ts create mode 100644 public/app/core/controllers/signup_ctrl.ts create mode 100644 public/app/core/copy/appNotification.ts create mode 100644 public/app/core/core.ts create mode 100644 public/app/core/core_module.ts create mode 100644 public/app/core/directives/array_join.ts create mode 100644 public/app/core/directives/autofill_event_fix.ts create mode 100644 public/app/core/directives/diff-view.ts create mode 100644 public/app/core/directives/dropdown_typeahead.ts create mode 100644 public/app/core/directives/give_focus.ts create mode 100644 public/app/core/directives/metric_segment.ts create mode 100644 public/app/core/directives/misc.ts create mode 100644 public/app/core/directives/ng_model_on_blur.ts create mode 100644 public/app/core/directives/rebuild_on_change.ts create mode 100644 public/app/core/directives/tags.ts create mode 100644 public/app/core/filters/filters.ts create mode 100644 public/app/core/hooks/useNavModel.ts create mode 100644 public/app/core/hooks/useRefMounted.ts create mode 100644 public/app/core/injectorMonkeyPatch.ts create mode 100644 public/app/core/jquery_extended.ts create mode 100644 public/app/core/lodash_extended.ts create mode 100644 public/app/core/logs_model.test.ts create mode 100644 public/app/core/logs_model.ts create mode 100644 public/app/core/middlewares/application.ts create mode 100644 public/app/core/mod_defs.d.ts create mode 100644 public/app/core/nav_model_srv.ts create mode 100644 public/app/core/partials.ts create mode 100644 public/app/core/profiler.ts create mode 100644 public/app/core/reducers/appNotification.test.ts create mode 100644 public/app/core/reducers/appNotification.ts create mode 100644 public/app/core/reducers/application.test.ts create mode 100644 public/app/core/reducers/application.ts create mode 100644 public/app/core/reducers/index.ts create mode 100644 public/app/core/reducers/location.test.ts create mode 100644 public/app/core/reducers/location.ts create mode 100644 public/app/core/reducers/navModel.test.ts create mode 100644 public/app/core/reducers/navModel.ts create mode 100644 public/app/core/reducers/processsAclItems.ts create mode 100644 public/app/core/reducers/root.test.ts create mode 100644 public/app/core/reducers/root.ts create mode 100644 public/app/core/selectors/location.ts create mode 100644 public/app/core/selectors/navModel.ts create mode 100644 public/app/core/services/AngularLoader.ts create mode 100644 public/app/core/services/FetchQueue.test.ts create mode 100644 public/app/core/services/FetchQueue.ts create mode 100644 public/app/core/services/FetchQueueWorker.test.ts create mode 100644 public/app/core/services/FetchQueueWorker.ts create mode 100644 public/app/core/services/ResponseQueue.test.ts create mode 100644 public/app/core/services/ResponseQueue.ts create mode 100644 public/app/core/services/__mocks__/backend_srv.ts create mode 100644 public/app/core/services/__mocks__/search_srv.ts create mode 100644 public/app/core/services/alert_srv.ts create mode 100644 public/app/core/services/all.ts create mode 100644 public/app/core/services/analytics.ts create mode 100644 public/app/core/services/backend_srv.ts create mode 100644 public/app/core/services/bridge_srv.test.ts create mode 100644 public/app/core/services/bridge_srv.ts create mode 100644 public/app/core/services/context_srv.ts create mode 100644 public/app/core/services/dynamic_directive_srv.ts create mode 100644 public/app/core/services/echo/Echo.ts create mode 100644 public/app/core/services/echo/EchoSrv.ts create mode 100644 public/app/core/services/echo/backends/PerformanceBackend.ts create mode 100644 public/app/core/services/echo/backends/sentry/SentryBackend.test.ts create mode 100644 public/app/core/services/echo/backends/sentry/SentryBackend.ts create mode 100644 public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.test.ts create mode 100644 public/app/core/services/echo/backends/sentry/transports/CustomEndpointTransport.ts create mode 100644 public/app/core/services/echo/backends/sentry/transports/EchoSrvTransport.ts create mode 100644 public/app/core/services/echo/backends/sentry/types.ts create mode 100644 public/app/core/services/impression_srv.ts create mode 100644 public/app/core/services/keybindingSrv.ts create mode 100644 public/app/core/services/ng_react.ts create mode 100644 public/app/core/services/popover_srv.ts create mode 100644 public/app/core/services/search_srv.ts create mode 100644 public/app/core/services/segment_srv.ts create mode 100644 public/app/core/services/timer.ts create mode 100644 public/app/core/services/util_srv.ts create mode 100644 public/app/core/specs/OrgSwitcher.test.tsx create mode 100644 public/app/core/specs/backend_srv.test.ts create mode 100644 public/app/core/specs/factors.test.ts create mode 100644 public/app/core/specs/flatten.test.ts create mode 100644 public/app/core/specs/rangeutil.test.ts create mode 100644 public/app/core/specs/search_srv.test.ts create mode 100644 public/app/core/specs/store.test.ts create mode 100644 public/app/core/specs/table_model.test.ts create mode 100644 public/app/core/specs/ticks.test.ts create mode 100644 public/app/core/specs/time_series.test.ts create mode 100644 public/app/core/store.ts create mode 100644 public/app/core/table_model.ts create mode 100644 public/app/core/time_series2.ts create mode 100644 public/app/core/utils/CancelablePromise.ts create mode 100644 public/app/core/utils/ConfigProvider.tsx create mode 100644 public/app/core/utils/UserProvider.tsx create mode 100644 public/app/core/utils/acl.ts create mode 100644 public/app/core/utils/applyStateChanges.ts create mode 100644 public/app/core/utils/arrayMove.ts create mode 100644 public/app/core/utils/browser.test.ts create mode 100644 public/app/core/utils/browser.ts create mode 100644 public/app/core/utils/colors.ts create mode 100644 public/app/core/utils/connectWithReduxStore.tsx create mode 100644 public/app/core/utils/css_loader.ts create mode 100644 public/app/core/utils/dag.test.ts create mode 100644 public/app/core/utils/dag.ts create mode 100644 public/app/core/utils/deferred.ts create mode 100644 public/app/core/utils/docsLinks.ts create mode 100644 public/app/core/utils/errors.test.ts create mode 100644 public/app/core/utils/errors.ts create mode 100644 public/app/core/utils/explore.test.ts create mode 100644 public/app/core/utils/explore.ts create mode 100644 public/app/core/utils/factors.ts create mode 100644 public/app/core/utils/fetch.test.ts create mode 100644 public/app/core/utils/fetch.ts create mode 100644 public/app/core/utils/flatten.ts create mode 100644 public/app/core/utils/kbn.test.ts create mode 100644 public/app/core/utils/kbn.ts create mode 100644 public/app/core/utils/model_utils.ts create mode 100644 public/app/core/utils/outline.ts create mode 100644 public/app/core/utils/promiseToDigest.test.ts create mode 100644 public/app/core/utils/promiseToDigest.ts create mode 100644 public/app/core/utils/query.test.ts create mode 100644 public/app/core/utils/query.ts create mode 100644 public/app/core/utils/react2angular.ts create mode 100644 public/app/core/utils/richHistory.test.ts create mode 100644 public/app/core/utils/richHistory.ts create mode 100644 public/app/core/utils/shortLinks.test.ts create mode 100644 public/app/core/utils/shortLinks.ts create mode 100644 public/app/core/utils/sort_by_keys.ts create mode 100644 public/app/core/utils/standardTransformers.ts create mode 100644 public/app/core/utils/ticks.ts create mode 100644 public/app/core/utils/timePicker.test.ts create mode 100644 public/app/core/utils/timePicker.ts create mode 100644 public/app/core/utils/version.ts create mode 100644 public/app/dev.ts create mode 100644 public/app/features/admin/AdminEditOrgCtrl.ts create mode 100644 public/app/features/admin/AdminEditOrgPage.tsx create mode 100644 public/app/features/admin/AdminListOrgsPage.tsx create mode 100644 public/app/features/admin/AdminOrgsTable.tsx create mode 100644 public/app/features/admin/AdminSettings.tsx create mode 100644 public/app/features/admin/LicenseChrome.tsx create mode 100644 public/app/features/admin/OrgRolePicker.tsx create mode 100644 public/app/features/admin/ServerStats.test.tsx create mode 100644 public/app/features/admin/ServerStats.tsx create mode 100644 public/app/features/admin/UpgradePage.tsx create mode 100644 public/app/features/admin/UserAdminPage.tsx create mode 100644 public/app/features/admin/UserCreatePage.tsx create mode 100644 public/app/features/admin/UserLdapSyncInfo.tsx create mode 100644 public/app/features/admin/UserListAdminPage.tsx create mode 100644 public/app/features/admin/UserOrgs.tsx create mode 100644 public/app/features/admin/UserPermissions.tsx create mode 100644 public/app/features/admin/UserProfile.tsx create mode 100644 public/app/features/admin/UserSessions.tsx create mode 100644 public/app/features/admin/index.ts create mode 100644 public/app/features/admin/ldap/LdapConnectionStatus.tsx create mode 100644 public/app/features/admin/ldap/LdapPage.tsx create mode 100644 public/app/features/admin/ldap/LdapSyncInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserGroups.tsx create mode 100644 public/app/features/admin/ldap/LdapUserInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserMappingInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserPermissions.tsx create mode 100644 public/app/features/admin/ldap/LdapUserTeams.tsx create mode 100644 public/app/features/admin/partials/admin_home.html create mode 100644 public/app/features/admin/partials/edit_org.html create mode 100644 public/app/features/admin/partials/stats.html create mode 100644 public/app/features/admin/partials/styleguide.html create mode 100644 public/app/features/admin/state/actions.ts create mode 100644 public/app/features/admin/state/apis.ts create mode 100644 public/app/features/admin/state/reducers.test.ts create mode 100644 public/app/features/admin/state/reducers.ts create mode 100644 public/app/features/alerting/AlertRuleItem.test.tsx create mode 100644 public/app/features/alerting/AlertRuleItem.tsx create mode 100644 public/app/features/alerting/AlertRuleList.test.tsx create mode 100644 public/app/features/alerting/AlertRuleList.tsx create mode 100644 public/app/features/alerting/AlertTab.tsx create mode 100644 public/app/features/alerting/AlertTabCtrl.test.ts create mode 100644 public/app/features/alerting/AlertTabCtrl.ts create mode 100644 public/app/features/alerting/EditNotificationChannelPage.tsx create mode 100644 public/app/features/alerting/NewNotificationChannelPage.tsx create mode 100644 public/app/features/alerting/NextGenAlertingPage.tsx create mode 100644 public/app/features/alerting/NotificationsEditCtrl.ts create mode 100644 public/app/features/alerting/NotificationsListPage.tsx create mode 100644 public/app/features/alerting/StateHistory.tsx create mode 100644 public/app/features/alerting/TestRuleResult.test.tsx create mode 100644 public/app/features/alerting/TestRuleResult.tsx create mode 100644 public/app/features/alerting/__snapshots__/TestRuleResult.test.tsx.snap create mode 100644 public/app/features/alerting/components/AlertDefinitionItem.tsx create mode 100644 public/app/features/alerting/components/AlertDefinitionOptions.tsx create mode 100644 public/app/features/alerting/components/AlertingQueryEditor.tsx create mode 100644 public/app/features/alerting/components/AlertingQueryPreview.tsx create mode 100644 public/app/features/alerting/components/BasicSettings.tsx create mode 100644 public/app/features/alerting/components/ChannelSettings.tsx create mode 100644 public/app/features/alerting/components/NotificationChannelForm.tsx create mode 100644 public/app/features/alerting/components/NotificationChannelOptions.tsx create mode 100644 public/app/features/alerting/components/NotificationSettings.tsx create mode 100644 public/app/features/alerting/components/OptionElement.tsx create mode 100644 public/app/features/alerting/components/PreviewInstancesTab.tsx create mode 100644 public/app/features/alerting/components/PreviewQueryTab.tsx create mode 100644 public/app/features/alerting/getAlertingValidationMessage.test.ts create mode 100644 public/app/features/alerting/getAlertingValidationMessage.ts create mode 100644 public/app/features/alerting/partials/alert_howto.html create mode 100644 public/app/features/alerting/partials/alert_tab.html create mode 100644 public/app/features/alerting/state/ThresholdMapper.test.ts create mode 100644 public/app/features/alerting/state/ThresholdMapper.ts create mode 100644 public/app/features/alerting/state/actions.ts create mode 100644 public/app/features/alerting/state/alertDef.ts create mode 100644 public/app/features/alerting/state/reducers.test.ts create mode 100644 public/app/features/alerting/state/reducers.ts create mode 100644 public/app/features/alerting/state/selectors.test.ts create mode 100644 public/app/features/alerting/state/selectors.ts create mode 100644 public/app/features/alerting/utils/notificationChannel.test.ts create mode 100644 public/app/features/alerting/utils/notificationChannels.ts create mode 100644 public/app/features/all.ts create mode 100644 public/app/features/annotations/all.ts create mode 100644 public/app/features/annotations/annotation_tooltip.ts create mode 100644 public/app/features/annotations/annotations_srv.ts create mode 100644 public/app/features/annotations/components/AnnotationResultMapper.tsx create mode 100644 public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx create mode 100644 public/app/features/annotations/editor_ctrl.ts create mode 100644 public/app/features/annotations/event_editor.ts create mode 100644 public/app/features/annotations/event_manager.ts create mode 100644 public/app/features/annotations/events_processing.ts create mode 100644 public/app/features/annotations/partials/editor.html create mode 100644 public/app/features/annotations/partials/event_editor.html create mode 100644 public/app/features/annotations/specs/annotations_srv.test.ts create mode 100644 public/app/features/annotations/specs/annotations_srv_specs.test.ts create mode 100644 public/app/features/annotations/standardAnnotationSupport.test.ts create mode 100644 public/app/features/annotations/standardAnnotationSupport.ts create mode 100644 public/app/features/annotations/types.ts create mode 100644 public/app/features/api-keys/ApiKeysActionBar.tsx create mode 100644 public/app/features/api-keys/ApiKeysAddedModal.test.tsx create mode 100644 public/app/features/api-keys/ApiKeysAddedModal.tsx create mode 100644 public/app/features/api-keys/ApiKeysController.tsx create mode 100644 public/app/features/api-keys/ApiKeysForm.tsx create mode 100644 public/app/features/api-keys/ApiKeysPage.test.tsx create mode 100644 public/app/features/api-keys/ApiKeysPage.tsx create mode 100644 public/app/features/api-keys/ApiKeysTable.tsx create mode 100644 public/app/features/api-keys/__mocks__/apiKeysMock.ts create mode 100644 public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap create mode 100644 public/app/features/api-keys/state/actions.ts create mode 100644 public/app/features/api-keys/state/reducers.test.ts create mode 100644 public/app/features/api-keys/state/reducers.ts create mode 100644 public/app/features/api-keys/state/selectors.test.ts create mode 100644 public/app/features/api-keys/state/selectors.ts create mode 100644 public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx create mode 100644 public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx create mode 100644 public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss create mode 100644 public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap create mode 100644 public/app/features/dashboard/components/AddPanelWidget/index.ts create mode 100644 public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts create mode 100644 public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts create mode 100644 public/app/features/dashboard/components/DashExportModal/index.ts create mode 100644 public/app/features/dashboard/components/DashNav/DashNav.tsx create mode 100644 public/app/features/dashboard/components/DashNav/DashNavButton.tsx create mode 100644 public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx create mode 100644 public/app/features/dashboard/components/DashNav/index.ts create mode 100644 public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx create mode 100644 public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx create mode 100644 public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx create mode 100644 public/app/features/dashboard/components/DashboardRow/index.ts create mode 100644 public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts create mode 100644 public/app/features/dashboard/components/DashboardSettings/index.ts create mode 100644 public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx create mode 100644 public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx create mode 100644 public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx create mode 100644 public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts create mode 100644 public/app/features/dashboard/components/Inspector/InspectContent.tsx create mode 100644 public/app/features/dashboard/components/Inspector/PanelInspector.tsx create mode 100644 public/app/features/dashboard/components/Inspector/hooks.ts create mode 100644 public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx create mode 100644 public/app/features/dashboard/components/LinksSettings/LinkSettingsHeader.tsx create mode 100644 public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx create mode 100644 public/app/features/dashboard/components/LinksSettings/index.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.test.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/OptionsGroup.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/OptionsPaneContent.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/OverrideEditor.test.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/OverrideEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/OverrideFieldConfigEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelNotSupported.test.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/PanelOptionsTab.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/VisualizationTab.tsx create mode 100644 public/app/features/dashboard/components/PanelEditor/index.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/actions.test.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/actions.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/reducers.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/selectors.test.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/selectors.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/state/utils.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/types.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/usePanelLatestData.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/utils.test.ts create mode 100644 public/app/features/dashboard/components/PanelEditor/utils.ts create mode 100644 public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx create mode 100644 public/app/features/dashboard/components/RowOptions/RowOptionsButton.tsx create mode 100644 public/app/features/dashboard/components/RowOptions/RowOptionsForm.tsx create mode 100644 public/app/features/dashboard/components/RowOptions/RowOptionsModal.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardModal.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveProvisionedDashboard.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/UnsavedChangesModal.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/types.ts create mode 100644 public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareExport.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareLink.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareModal.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts create mode 100644 public/app/features/dashboard/components/ShareModal/index.ts create mode 100644 public/app/features/dashboard/components/ShareModal/types.ts create mode 100644 public/app/features/dashboard/components/ShareModal/utils.ts create mode 100644 public/app/features/dashboard/components/SubMenu/Annotations.tsx create mode 100644 public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx create mode 100644 public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx create mode 100644 public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx create mode 100644 public/app/features/dashboard/components/SubMenu/SubMenu.tsx create mode 100644 public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/types.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/HistorySrv.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx create mode 100644 public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx create mode 100644 public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx create mode 100644 public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx create mode 100644 public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx create mode 100644 public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/index.ts create mode 100644 public/app/features/dashboard/components/VersionHistory/template.html create mode 100644 public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx create mode 100644 public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx create mode 100644 public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx create mode 100644 public/app/features/dashboard/containers/DashboardPage.test.tsx create mode 100644 public/app/features/dashboard/containers/DashboardPage.tsx create mode 100644 public/app/features/dashboard/containers/SoloPanelPage.test.tsx create mode 100644 public/app/features/dashboard/containers/SoloPanelPage.tsx create mode 100644 public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap create mode 100644 public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardGrid.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardPanel.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelChrome.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelChrome.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelPluginError.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelResizer.tsx create mode 100644 public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap create mode 100644 public/app/features/dashboard/index.ts create mode 100644 public/app/features/dashboard/services/ChangeTracker.test.ts create mode 100644 public/app/features/dashboard/services/ChangeTracker.ts create mode 100644 public/app/features/dashboard/services/DashboardLoaderSrv.ts create mode 100644 public/app/features/dashboard/services/DashboardSrv.ts create mode 100644 public/app/features/dashboard/services/TimeSrv.test.ts create mode 100644 public/app/features/dashboard/services/TimeSrv.ts create mode 100644 public/app/features/dashboard/services/UnsavedChangesSrv.ts create mode 100644 public/app/features/dashboard/state/DashboardMigrator.test.ts create mode 100644 public/app/features/dashboard/state/DashboardMigrator.ts create mode 100644 public/app/features/dashboard/state/DashboardModel.repeat.test.ts create mode 100644 public/app/features/dashboard/state/DashboardModel.test.ts create mode 100644 public/app/features/dashboard/state/DashboardModel.ts create mode 100644 public/app/features/dashboard/state/PanelModel.test.ts create mode 100644 public/app/features/dashboard/state/PanelModel.ts create mode 100644 public/app/features/dashboard/state/actions.ts create mode 100644 public/app/features/dashboard/state/analyticsProcessor.ts create mode 100644 public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts create mode 100644 public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts create mode 100644 public/app/features/dashboard/state/index.ts create mode 100644 public/app/features/dashboard/state/initDashboard.test.ts create mode 100644 public/app/features/dashboard/state/initDashboard.ts create mode 100644 public/app/features/dashboard/state/reducers.test.ts create mode 100644 public/app/features/dashboard/state/reducers.ts create mode 100644 public/app/features/dashboard/state/selectors.ts create mode 100644 public/app/features/dashboard/utils/getPanelMenu.test.ts create mode 100644 public/app/features/dashboard/utils/getPanelMenu.ts create mode 100644 public/app/features/dashboard/utils/getRefreshFromUrl.test.ts create mode 100644 public/app/features/dashboard/utils/getRefreshFromUrl.ts create mode 100644 public/app/features/dashboard/utils/loadSnapshotData.ts create mode 100644 public/app/features/dashboard/utils/panel.test.ts create mode 100644 public/app/features/dashboard/utils/panel.ts create mode 100644 public/app/features/datasources/DashboardsTable.test.tsx create mode 100644 public/app/features/datasources/DashboardsTable.tsx create mode 100644 public/app/features/datasources/DataSourceDashboards.test.tsx create mode 100644 public/app/features/datasources/DataSourceDashboards.tsx create mode 100644 public/app/features/datasources/DataSourceList.test.tsx create mode 100644 public/app/features/datasources/DataSourcesList.tsx create mode 100644 public/app/features/datasources/DataSourcesListPage.test.tsx create mode 100644 public/app/features/datasources/DataSourcesListPage.tsx create mode 100644 public/app/features/datasources/NewDataSourcePage.tsx create mode 100644 public/app/features/datasources/__mocks__/dataSourcesMocks.ts create mode 100644 public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap create mode 100644 public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap create mode 100644 public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap create mode 100644 public/app/features/datasources/mocks.ts create mode 100644 public/app/features/datasources/partials/http_settings.html create mode 100644 public/app/features/datasources/partials/http_settings_next.html create mode 100644 public/app/features/datasources/partials/tls_auth_settings.html create mode 100644 public/app/features/datasources/settings/BasicSettings.test.tsx create mode 100644 public/app/features/datasources/settings/BasicSettings.tsx create mode 100644 public/app/features/datasources/settings/ButtonRow.test.tsx create mode 100644 public/app/features/datasources/settings/ButtonRow.tsx create mode 100644 public/app/features/datasources/settings/CloudInfoBox.tsx create mode 100644 public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx create mode 100644 public/app/features/datasources/settings/DataSourceSettingsPage.tsx create mode 100644 public/app/features/datasources/settings/HttpSettingsCtrl.ts create mode 100644 public/app/features/datasources/settings/PluginSettings.tsx create mode 100644 public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts create mode 100644 public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap create mode 100644 public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap create mode 100644 public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap create mode 100644 public/app/features/datasources/state/actions.test.ts create mode 100644 public/app/features/datasources/state/actions.ts create mode 100644 public/app/features/datasources/state/buildCategories.test.ts create mode 100644 public/app/features/datasources/state/buildCategories.ts create mode 100644 public/app/features/datasources/state/navModel.ts create mode 100644 public/app/features/datasources/state/reducers.test.ts create mode 100644 public/app/features/datasources/state/reducers.ts create mode 100644 public/app/features/datasources/state/selectors.ts create mode 100644 public/app/features/datasources/utils/passwordHandlers.test.ts create mode 100644 public/app/features/datasources/utils/passwordHandlers.ts create mode 100644 public/app/features/explore/AdHocFilter.tsx create mode 100644 public/app/features/explore/AdHocFilterField.test.tsx create mode 100644 public/app/features/explore/AdHocFilterField.tsx create mode 100644 public/app/features/explore/ElapsedTime.tsx create mode 100644 public/app/features/explore/ErrorContainer.test.tsx create mode 100644 public/app/features/explore/ErrorContainer.tsx create mode 100644 public/app/features/explore/Explore.test.tsx create mode 100644 public/app/features/explore/Explore.tsx create mode 100644 public/app/features/explore/ExploreDrawer.test.tsx create mode 100644 public/app/features/explore/ExploreDrawer.tsx create mode 100644 public/app/features/explore/ExploreGraphNGPanel.tsx create mode 100644 public/app/features/explore/ExploreGraphPanel.tsx create mode 100644 public/app/features/explore/ExplorePaneContainer.tsx create mode 100644 public/app/features/explore/ExploreQueryInspector.test.tsx create mode 100644 public/app/features/explore/ExploreQueryInspector.tsx create mode 100644 public/app/features/explore/ExploreTimeControls.tsx create mode 100644 public/app/features/explore/ExploreToolbar.tsx create mode 100644 public/app/features/explore/JSONViewer.tsx create mode 100644 public/app/features/explore/LiveLogs.test.tsx create mode 100644 public/app/features/explore/LiveLogs.tsx create mode 100644 public/app/features/explore/LiveTailButton.tsx create mode 100644 public/app/features/explore/Logs.tsx create mode 100644 public/app/features/explore/LogsContainer.tsx create mode 100644 public/app/features/explore/MetaInfoText.test.tsx create mode 100644 public/app/features/explore/MetaInfoText.tsx create mode 100644 public/app/features/explore/NoDataSourceCallToAction.tsx create mode 100644 public/app/features/explore/NodeGraphContainer.tsx create mode 100644 public/app/features/explore/QueryEditor.tsx create mode 100644 public/app/features/explore/QueryRow.test.tsx create mode 100644 public/app/features/explore/QueryRow.tsx create mode 100644 public/app/features/explore/QueryRowActions.test.tsx create mode 100644 public/app/features/explore/QueryRowActions.tsx create mode 100644 public/app/features/explore/QueryRows.tsx create mode 100644 public/app/features/explore/QueryStatus.test.tsx create mode 100644 public/app/features/explore/QueryStatus.tsx create mode 100644 public/app/features/explore/ReturnToDashboardButton.test.tsx create mode 100644 public/app/features/explore/ReturnToDashboardButton.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistory.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistory.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryCard.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryCard.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryContainer.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryContainer.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistorySettings.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistorySettings.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx create mode 100644 public/app/features/explore/RunButton.test.tsx create mode 100644 public/app/features/explore/RunButton.tsx create mode 100644 public/app/features/explore/SecondaryActions.test.tsx create mode 100644 public/app/features/explore/SecondaryActions.tsx create mode 100644 public/app/features/explore/TableContainer.test.tsx create mode 100644 public/app/features/explore/TableContainer.tsx create mode 100644 public/app/features/explore/Time.tsx create mode 100644 public/app/features/explore/TimeSyncButton.test.tsx create mode 100644 public/app/features/explore/TimeSyncButton.tsx create mode 100644 public/app/features/explore/TraceView/TraceView.test.tsx create mode 100644 public/app/features/explore/TraceView/TraceView.tsx create mode 100644 public/app/features/explore/TraceView/createSpanLink.test.ts create mode 100644 public/app/features/explore/TraceView/createSpanLink.tsx create mode 100644 public/app/features/explore/TraceView/uiElements.tsx create mode 100644 public/app/features/explore/TraceView/useChildrenState.test.ts create mode 100644 public/app/features/explore/TraceView/useChildrenState.ts create mode 100644 public/app/features/explore/TraceView/useDetailState.test.ts create mode 100644 public/app/features/explore/TraceView/useDetailState.ts create mode 100644 public/app/features/explore/TraceView/useHoverIndentGuide.test.ts create mode 100644 public/app/features/explore/TraceView/useHoverIndentGuide.ts create mode 100644 public/app/features/explore/TraceView/useSearch.test.ts create mode 100644 public/app/features/explore/TraceView/useSearch.ts create mode 100644 public/app/features/explore/TraceView/useViewRange.test.ts create mode 100644 public/app/features/explore/TraceView/useViewRange.ts create mode 100644 public/app/features/explore/Wrapper.test.tsx create mode 100644 public/app/features/explore/Wrapper.tsx create mode 100644 public/app/features/explore/__mocks__/mockData.ts create mode 100644 public/app/features/explore/__snapshots__/ErrorContainer.test.tsx.snap create mode 100644 public/app/features/explore/__snapshots__/Explore.test.tsx.snap create mode 100644 public/app/features/explore/__snapshots__/MetaInfoText.test.tsx.snap create mode 100644 public/app/features/explore/__snapshots__/QueryRowActions.test.tsx.snap create mode 100644 public/app/features/explore/__snapshots__/TableContainer.test.tsx.snap create mode 100644 public/app/features/explore/flotgraph/getGraphSeriesModel.ts create mode 100644 public/app/features/explore/flotgraph/types.ts create mode 100644 public/app/features/explore/slate-plugins/prism/index.tsx create mode 100644 public/app/features/explore/state/datasource.test.ts create mode 100644 public/app/features/explore/state/datasource.ts create mode 100644 public/app/features/explore/state/explorePane.test.ts create mode 100644 public/app/features/explore/state/explorePane.ts create mode 100644 public/app/features/explore/state/history.ts create mode 100644 public/app/features/explore/state/main.test.ts create mode 100644 public/app/features/explore/state/main.ts create mode 100644 public/app/features/explore/state/query.test.ts create mode 100644 public/app/features/explore/state/query.ts create mode 100644 public/app/features/explore/state/selectors.test.ts create mode 100644 public/app/features/explore/state/selectors.ts create mode 100644 public/app/features/explore/state/time.test.ts create mode 100644 public/app/features/explore/state/time.ts create mode 100644 public/app/features/explore/state/utils.test.ts create mode 100644 public/app/features/explore/state/utils.ts create mode 100644 public/app/features/explore/useLiveTailControls.ts create mode 100644 public/app/features/explore/utils/LogsCrossFadeTransition.tsx create mode 100644 public/app/features/explore/utils/decorators.test.ts create mode 100644 public/app/features/explore/utils/decorators.ts create mode 100644 public/app/features/explore/utils/links.test.ts create mode 100644 public/app/features/explore/utils/links.ts create mode 100644 public/app/features/explore/utils/set.test.ts create mode 100644 public/app/features/explore/utils/set.ts create mode 100644 public/app/features/explore/utils/time.ts create mode 100644 public/app/features/expressions/ExpressionDatasource.ts create mode 100644 public/app/features/expressions/ExpressionQueryEditor.tsx create mode 100644 public/app/features/expressions/types.ts create mode 100644 public/app/features/folders/FolderPermissions.tsx create mode 100644 public/app/features/folders/FolderSettingsPage.test.tsx create mode 100644 public/app/features/folders/FolderSettingsPage.tsx create mode 100644 public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap create mode 100644 public/app/features/folders/components/NewDashboardsFolder.tsx create mode 100644 public/app/features/folders/state/actions.ts create mode 100644 public/app/features/folders/state/navModel.ts create mode 100644 public/app/features/folders/state/reducers.test.ts create mode 100644 public/app/features/folders/state/reducers.ts create mode 100644 public/app/features/inspector/DetailText.tsx create mode 100644 public/app/features/inspector/InspectDataTab.test.tsx create mode 100644 public/app/features/inspector/InspectDataTab.tsx create mode 100644 public/app/features/inspector/InspectErrorTab.tsx create mode 100644 public/app/features/inspector/InspectJSONTab.tsx create mode 100644 public/app/features/inspector/InspectMetadataTab.tsx create mode 100644 public/app/features/inspector/InspectStatsTab.tsx create mode 100644 public/app/features/inspector/InspectStatsTable.tsx create mode 100644 public/app/features/inspector/InspectSubtitle.tsx create mode 100644 public/app/features/inspector/QueryInspector.tsx create mode 100644 public/app/features/inspector/styles.ts create mode 100644 public/app/features/inspector/types.ts create mode 100644 public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx create mode 100644 public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx create mode 100644 public/app/features/library-panels/components/DeleteLibraryPanelModal/actions.ts create mode 100644 public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.test.ts create mode 100644 public/app/features/library-panels/components/DeleteLibraryPanelModal/reducer.ts create mode 100644 public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelsView/actions.ts create mode 100644 public/app/features/library-panels/components/LibraryPanelsView/reducer.test.ts create mode 100644 public/app/features/library-panels/components/LibraryPanelsView/reducer.ts create mode 100644 public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx create mode 100644 public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx create mode 100644 public/app/features/library-panels/components/UnlinkModal/UnlinkModal.tsx create mode 100644 public/app/features/library-panels/guard.ts create mode 100644 public/app/features/library-panels/state/api.ts create mode 100644 public/app/features/library-panels/styles.ts create mode 100644 public/app/features/library-panels/types.ts create mode 100644 public/app/features/library-panels/utils.ts create mode 100644 public/app/features/library-panels/utils/usePanelSave.ts create mode 100644 public/app/features/live/channel.ts create mode 100644 public/app/features/live/dashboard/DashboardChangedModal.tsx create mode 100644 public/app/features/live/dashboard/dashboardWatcher.ts create mode 100644 public/app/features/live/dashboard/types.ts create mode 100644 public/app/features/live/features.ts create mode 100644 public/app/features/live/live.ts create mode 100644 public/app/features/live/measurements/measurementsSupport.ts create mode 100644 public/app/features/live/scopes.ts create mode 100644 public/app/features/manage-dashboards/DashboardImportPage.tsx create mode 100644 public/app/features/manage-dashboards/SnapshotListPage.tsx create mode 100644 public/app/features/manage-dashboards/components/ImportDashboardForm.tsx create mode 100644 public/app/features/manage-dashboards/components/ImportDashboardOverview.tsx create mode 100644 public/app/features/manage-dashboards/components/SnapshotListTable.tsx create mode 100644 public/app/features/manage-dashboards/components/UploadDashboard/index.ts create mode 100644 public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts create mode 100644 public/app/features/manage-dashboards/index.ts create mode 100644 public/app/features/manage-dashboards/services/ValidationSrv.ts create mode 100644 public/app/features/manage-dashboards/state/actions.ts create mode 100644 public/app/features/manage-dashboards/state/reducers.ts create mode 100644 public/app/features/manage-dashboards/state/selectors.ts create mode 100644 public/app/features/manage-dashboards/types.ts create mode 100644 public/app/features/manage-dashboards/utils/validation.ts create mode 100644 public/app/features/org/NewOrgPage.tsx create mode 100644 public/app/features/org/OrgDetailsPage.test.tsx create mode 100644 public/app/features/org/OrgDetailsPage.tsx create mode 100644 public/app/features/org/OrgProfile.test.tsx create mode 100644 public/app/features/org/OrgProfile.tsx create mode 100644 public/app/features/org/SelectOrgPage.tsx create mode 100644 public/app/features/org/UserInviteForm.tsx create mode 100644 public/app/features/org/UserInvitePage.tsx create mode 100644 public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap create mode 100644 public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap create mode 100644 public/app/features/org/state/actions.test.ts create mode 100644 public/app/features/org/state/actions.ts create mode 100644 public/app/features/org/state/reducers.test.ts create mode 100644 public/app/features/org/state/reducers.ts create mode 100644 public/app/features/panel/all.ts create mode 100644 public/app/features/panel/metrics_panel_ctrl.ts create mode 100644 public/app/features/panel/panel_ctrl.ts create mode 100644 public/app/features/panel/panel_directive.ts create mode 100644 public/app/features/panel/panel_editor_tab.ts create mode 100644 public/app/features/panel/panellinks/linkSuppliers.test.ts create mode 100644 public/app/features/panel/panellinks/linkSuppliers.ts create mode 100644 public/app/features/panel/panellinks/link_srv.ts create mode 100644 public/app/features/panel/panellinks/module.html create mode 100644 public/app/features/panel/panellinks/module.ts create mode 100644 public/app/features/panel/panellinks/specs/link_srv.test.ts create mode 100644 public/app/features/panel/partials/query_editor_row.html create mode 100644 public/app/features/panel/query_ctrl.ts create mode 100644 public/app/features/panel/query_editor_row.ts create mode 100644 public/app/features/panel/specs/metrics_panel_ctrl.test.ts create mode 100644 public/app/features/playlist/all.ts create mode 100644 public/app/features/playlist/partials/playlist.html create mode 100644 public/app/features/playlist/partials/playlist_search.html create mode 100644 public/app/features/playlist/partials/playlists.html create mode 100644 public/app/features/playlist/playlist_edit_ctrl.ts create mode 100644 public/app/features/playlist/playlist_routes.ts create mode 100644 public/app/features/playlist/playlist_search.ts create mode 100644 public/app/features/playlist/playlist_srv.ts create mode 100644 public/app/features/playlist/playlists_ctrl.ts create mode 100644 public/app/features/playlist/specs/playlist_edit_ctrl.test.ts create mode 100644 public/app/features/playlist/specs/playlist_srv.test.ts create mode 100644 public/app/features/plugins/AppRootPage.test.tsx create mode 100644 public/app/features/plugins/AppRootPage.tsx create mode 100644 public/app/features/plugins/PluginDashboards.tsx create mode 100644 public/app/features/plugins/PluginList.test.tsx create mode 100644 public/app/features/plugins/PluginList.tsx create mode 100644 public/app/features/plugins/PluginListItem.test.tsx create mode 100644 public/app/features/plugins/PluginListItem.tsx create mode 100644 public/app/features/plugins/PluginListPage.test.tsx create mode 100644 public/app/features/plugins/PluginListPage.tsx create mode 100644 public/app/features/plugins/PluginPage.tsx create mode 100644 public/app/features/plugins/PluginSettingsCache.ts create mode 100644 public/app/features/plugins/PluginSignatureBadge.tsx create mode 100644 public/app/features/plugins/PluginStateInfo.tsx create mode 100644 public/app/features/plugins/PluginsErrorsInfo.tsx create mode 100644 public/app/features/plugins/__mocks__/pluginMocks.ts create mode 100644 public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap create mode 100644 public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap create mode 100644 public/app/features/plugins/all.ts create mode 100644 public/app/features/plugins/built_in_plugins.ts create mode 100644 public/app/features/plugins/datasource_srv.ts create mode 100644 public/app/features/plugins/partials/plugin_page.html create mode 100644 public/app/features/plugins/partials/update_instructions.html create mode 100644 public/app/features/plugins/plugin_component.ts create mode 100644 public/app/features/plugins/plugin_loader.test.ts create mode 100644 public/app/features/plugins/plugin_loader.ts create mode 100644 public/app/features/plugins/plugin_page_ctrl.ts create mode 100644 public/app/features/plugins/specs/datasource_srv.test.ts create mode 100644 public/app/features/plugins/state/actions.ts create mode 100644 public/app/features/plugins/state/reducers.test.ts create mode 100644 public/app/features/plugins/state/reducers.ts create mode 100644 public/app/features/plugins/state/selectors.test.ts create mode 100644 public/app/features/plugins/state/selectors.ts create mode 100644 public/app/features/plugins/variableQueryEditorLoader.tsx create mode 100644 public/app/features/plugins/wrappers/AppConfigWrapper.tsx create mode 100644 public/app/features/profile/ChangePasswordForm.tsx create mode 100644 public/app/features/profile/ChangePasswordPage.tsx create mode 100644 public/app/features/profile/ProfileCtrl.ts create mode 100644 public/app/features/profile/UserOrganizations.tsx create mode 100644 public/app/features/profile/UserProfileEdit.tsx create mode 100644 public/app/features/profile/UserProfileEditForm.tsx create mode 100644 public/app/features/profile/UserSessions.tsx create mode 100644 public/app/features/profile/UserTeams.tsx create mode 100644 public/app/features/profile/all.ts create mode 100644 public/app/features/profile/state/reducers.ts create mode 100644 public/app/features/profile/state/selectors.ts create mode 100644 public/app/features/query/components/HelpToggle.tsx create mode 100644 public/app/features/query/components/QueryEditorRow.test.ts create mode 100644 public/app/features/query/components/QueryEditorRow.tsx create mode 100644 public/app/features/query/components/QueryEditorRowTitle.test.tsx create mode 100644 public/app/features/query/components/QueryEditorRowTitle.tsx create mode 100644 public/app/features/query/components/QueryEditorRows.tsx create mode 100644 public/app/features/query/components/QueryGroup.tsx create mode 100644 public/app/features/query/components/QueryGroupOptions.tsx create mode 100644 public/app/features/query/state/PanelQueryRunner.test.ts create mode 100644 public/app/features/query/state/PanelQueryRunner.ts create mode 100644 public/app/features/query/state/queryAnalytics.test.ts create mode 100644 public/app/features/query/state/queryAnalytics.ts create mode 100644 public/app/features/query/state/runRequest.test.ts create mode 100644 public/app/features/query/state/runRequest.ts create mode 100644 public/app/features/query/state/types.ts create mode 100644 public/app/features/sandbox/TestStuffPage.tsx create mode 100644 public/app/features/search/components/ActionRow.tsx create mode 100644 public/app/features/search/components/ConfirmDeleteModal.tsx create mode 100644 public/app/features/search/components/DashboardActions.tsx create mode 100644 public/app/features/search/components/DashboardListPage.tsx create mode 100644 public/app/features/search/components/DashboardSearch.test.tsx create mode 100644 public/app/features/search/components/DashboardSearch.tsx create mode 100644 public/app/features/search/components/ManageDashboards.tsx create mode 100644 public/app/features/search/components/MoveToFolderModal.tsx create mode 100644 public/app/features/search/components/SearchCheckbox.tsx create mode 100644 public/app/features/search/components/SearchField.tsx create mode 100644 public/app/features/search/components/SearchItem.test.tsx create mode 100644 public/app/features/search/components/SearchItem.tsx create mode 100644 public/app/features/search/components/SearchResults.test.tsx create mode 100644 public/app/features/search/components/SearchResults.tsx create mode 100644 public/app/features/search/components/SearchResultsFilter.test.tsx create mode 100644 public/app/features/search/components/SearchResultsFilter.tsx create mode 100644 public/app/features/search/components/SearchWrapper.tsx create mode 100644 public/app/features/search/components/SectionHeader.tsx create mode 100644 public/app/features/search/connect.ts create mode 100644 public/app/features/search/constants.ts create mode 100644 public/app/features/search/hooks/useDashboardSearch.ts create mode 100644 public/app/features/search/hooks/useManageDashboards.test.ts create mode 100644 public/app/features/search/hooks/useManageDashboards.ts create mode 100644 public/app/features/search/hooks/useSearch.ts create mode 100644 public/app/features/search/hooks/useSearchQuery.ts create mode 100644 public/app/features/search/index.ts create mode 100644 public/app/features/search/loaders.ts create mode 100644 public/app/features/search/reducers/actionTypes.ts create mode 100644 public/app/features/search/reducers/dashboardSearch.test.ts create mode 100644 public/app/features/search/reducers/dashboardSearch.ts create mode 100644 public/app/features/search/reducers/manageDashboards.test.ts create mode 100644 public/app/features/search/reducers/manageDashboards.ts create mode 100644 public/app/features/search/reducers/searchQueryReducer.ts create mode 100644 public/app/features/search/testData.ts create mode 100644 public/app/features/search/types.ts create mode 100644 public/app/features/search/utils.test.ts create mode 100644 public/app/features/search/utils.ts create mode 100644 public/app/features/teams/CreateTeam.test.tsx create mode 100644 public/app/features/teams/CreateTeam.tsx create mode 100644 public/app/features/teams/TeamGroupSync.test.tsx create mode 100644 public/app/features/teams/TeamGroupSync.tsx create mode 100644 public/app/features/teams/TeamList.test.tsx create mode 100644 public/app/features/teams/TeamList.tsx create mode 100644 public/app/features/teams/TeamMemberRow.test.tsx create mode 100644 public/app/features/teams/TeamMemberRow.tsx create mode 100644 public/app/features/teams/TeamMembers.test.tsx create mode 100644 public/app/features/teams/TeamMembers.tsx create mode 100644 public/app/features/teams/TeamPages.test.tsx create mode 100644 public/app/features/teams/TeamPages.tsx create mode 100644 public/app/features/teams/TeamSettings.test.tsx create mode 100644 public/app/features/teams/TeamSettings.tsx create mode 100644 public/app/features/teams/__mocks__/navModelMock.ts create mode 100644 public/app/features/teams/__mocks__/teamMocks.ts create mode 100644 public/app/features/teams/__snapshots__/CreateTeam.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamList.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap create mode 100644 public/app/features/teams/state/actions.ts create mode 100644 public/app/features/teams/state/navModel.ts create mode 100644 public/app/features/teams/state/reducers.test.ts create mode 100644 public/app/features/teams/state/reducers.ts create mode 100644 public/app/features/teams/state/selectors.test.ts create mode 100644 public/app/features/teams/state/selectors.ts create mode 100644 public/app/features/templating/all.ts create mode 100644 public/app/features/templating/formatRegistry.ts create mode 100644 public/app/features/templating/template_srv.test.ts create mode 100644 public/app/features/templating/template_srv.ts create mode 100644 public/app/features/users/InviteeRow.tsx create mode 100644 public/app/features/users/InviteesTable.test.tsx create mode 100644 public/app/features/users/InviteesTable.tsx create mode 100644 public/app/features/users/SignupInvited.test.tsx create mode 100644 public/app/features/users/SignupInvited.tsx create mode 100644 public/app/features/users/TokenRevokedModal.tsx create mode 100644 public/app/features/users/UsersActionBar.test.tsx create mode 100644 public/app/features/users/UsersActionBar.tsx create mode 100644 public/app/features/users/UsersListPage.test.tsx create mode 100644 public/app/features/users/UsersListPage.tsx create mode 100644 public/app/features/users/UsersTable.test.tsx create mode 100644 public/app/features/users/UsersTable.tsx create mode 100644 public/app/features/users/__mocks__/userMocks.ts create mode 100644 public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap create mode 100644 public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap create mode 100644 public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap create mode 100644 public/app/features/users/__snapshots__/UsersTable.test.tsx.snap create mode 100644 public/app/features/users/state/actions.ts create mode 100644 public/app/features/users/state/reducers.test.ts create mode 100644 public/app/features/users/state/reducers.ts create mode 100644 public/app/features/users/state/selectors.ts create mode 100644 public/app/features/variables/adapters.ts create mode 100644 public/app/features/variables/adhoc/AdHocVariableEditor.tsx create mode 100644 public/app/features/variables/adhoc/actions.test.ts create mode 100644 public/app/features/variables/adhoc/actions.ts create mode 100644 public/app/features/variables/adhoc/adapter.ts create mode 100644 public/app/features/variables/adhoc/picker/AdHocFilterBuilder.tsx create mode 100644 public/app/features/variables/adhoc/picker/AdHocFilterKey.tsx create mode 100644 public/app/features/variables/adhoc/picker/AdHocFilterRenderer.tsx create mode 100644 public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx create mode 100644 public/app/features/variables/adhoc/picker/AdHocPicker.tsx create mode 100644 public/app/features/variables/adhoc/picker/ConditionSegment.tsx create mode 100644 public/app/features/variables/adhoc/picker/OperatorSegment.tsx create mode 100644 public/app/features/variables/adhoc/reducer.test.ts create mode 100644 public/app/features/variables/adhoc/reducer.ts create mode 100644 public/app/features/variables/adhoc/urlParser.test.ts create mode 100644 public/app/features/variables/adhoc/urlParser.ts create mode 100644 public/app/features/variables/constant/ConstantVariableEditor.tsx create mode 100644 public/app/features/variables/constant/actions.test.ts create mode 100644 public/app/features/variables/constant/actions.ts create mode 100644 public/app/features/variables/constant/adapter.ts create mode 100644 public/app/features/variables/constant/reducer.test.ts create mode 100644 public/app/features/variables/constant/reducer.ts create mode 100644 public/app/features/variables/custom/CustomVariableEditor.tsx create mode 100644 public/app/features/variables/custom/actions.test.ts create mode 100644 public/app/features/variables/custom/actions.ts create mode 100644 public/app/features/variables/custom/adapter.ts create mode 100644 public/app/features/variables/custom/reducer.test.ts create mode 100644 public/app/features/variables/custom/reducer.ts create mode 100644 public/app/features/variables/datasource/DataSourceVariableEditor.tsx create mode 100644 public/app/features/variables/datasource/actions.test.ts create mode 100644 public/app/features/variables/datasource/actions.ts create mode 100644 public/app/features/variables/datasource/adapter.ts create mode 100644 public/app/features/variables/datasource/reducer.test.ts create mode 100644 public/app/features/variables/datasource/reducer.ts create mode 100644 public/app/features/variables/date/DateVariableEditor.tsx create mode 100644 public/app/features/variables/date/DateVariablePicker.tsx create mode 100644 public/app/features/variables/date/actions.test.ts create mode 100644 public/app/features/variables/date/actions.ts create mode 100644 public/app/features/variables/date/adapter.test.ts create mode 100644 public/app/features/variables/date/adapter.ts create mode 100644 public/app/features/variables/date/reducer.test.ts create mode 100644 public/app/features/variables/date/reducer.ts create mode 100644 public/app/features/variables/editor/LegacyVariableQueryEditor.tsx create mode 100644 public/app/features/variables/editor/SelectionOptionsEditor.tsx create mode 100644 public/app/features/variables/editor/VariableEditorContainer.tsx create mode 100644 public/app/features/variables/editor/VariableEditorEditor.tsx create mode 100644 public/app/features/variables/editor/VariableEditorList.tsx create mode 100644 public/app/features/variables/editor/VariableHideSelect.tsx create mode 100644 public/app/features/variables/editor/VariableSectionHeader.tsx create mode 100644 public/app/features/variables/editor/VariableSelectField.tsx create mode 100644 public/app/features/variables/editor/VariableSwitchField.tsx create mode 100644 public/app/features/variables/editor/VariableTextAreaField.tsx create mode 100644 public/app/features/variables/editor/VariableTextField.tsx create mode 100644 public/app/features/variables/editor/VariableTypeSelect.tsx create mode 100644 public/app/features/variables/editor/VariableValuesPreview.tsx create mode 100644 public/app/features/variables/editor/actions.test.ts create mode 100644 public/app/features/variables/editor/actions.ts create mode 100644 public/app/features/variables/editor/getVariableQueryEditor.test.tsx create mode 100644 public/app/features/variables/editor/getVariableQueryEditor.tsx create mode 100644 public/app/features/variables/editor/reducer.test.ts create mode 100644 public/app/features/variables/editor/reducer.ts create mode 100644 public/app/features/variables/editor/types.ts create mode 100644 public/app/features/variables/getAllVariableValuesForUrl.test.ts create mode 100644 public/app/features/variables/getAllVariableValuesForUrl.ts create mode 100644 public/app/features/variables/guard.test.ts create mode 100644 public/app/features/variables/guard.ts create mode 100644 public/app/features/variables/inspect/NetworkGraph.tsx create mode 100644 public/app/features/variables/inspect/NetworkGraphModal.tsx create mode 100644 public/app/features/variables/inspect/VariableUsagesButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesDependenciesButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesUnknownButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesUnknownTable.tsx create mode 100644 public/app/features/variables/inspect/reducer.test.ts create mode 100644 public/app/features/variables/inspect/reducer.ts create mode 100644 public/app/features/variables/inspect/utils.test.ts create mode 100644 public/app/features/variables/inspect/utils.ts create mode 100644 public/app/features/variables/interval/IntervalVariableEditor.tsx create mode 100644 public/app/features/variables/interval/actions.test.ts create mode 100644 public/app/features/variables/interval/actions.ts create mode 100644 public/app/features/variables/interval/adapter.ts create mode 100644 public/app/features/variables/interval/reducer.test.ts create mode 100644 public/app/features/variables/interval/reducer.ts create mode 100644 public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx create mode 100644 public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx create mode 100644 public/app/features/variables/pickers/OptionsPicker/actions.test.ts create mode 100644 public/app/features/variables/pickers/OptionsPicker/actions.ts create mode 100644 public/app/features/variables/pickers/OptionsPicker/reducer.test.ts create mode 100644 public/app/features/variables/pickers/OptionsPicker/reducer.ts create mode 100644 public/app/features/variables/pickers/PickerRenderer.tsx create mode 100644 public/app/features/variables/pickers/index.ts create mode 100644 public/app/features/variables/pickers/shared/VariableInput.tsx create mode 100644 public/app/features/variables/pickers/shared/VariableLink.tsx create mode 100644 public/app/features/variables/pickers/shared/VariableOptions.tsx create mode 100644 public/app/features/variables/pickers/types.ts create mode 100644 public/app/features/variables/query/QueryVariableEditor.test.tsx create mode 100644 public/app/features/variables/query/QueryVariableEditor.tsx create mode 100644 public/app/features/variables/query/QueryVariableRefreshSelect.tsx create mode 100644 public/app/features/variables/query/QueryVariableSortSelect.tsx create mode 100644 public/app/features/variables/query/VariableQueryRunner.test.ts create mode 100644 public/app/features/variables/query/VariableQueryRunner.ts create mode 100644 public/app/features/variables/query/actions.test.ts create mode 100644 public/app/features/variables/query/actions.ts create mode 100644 public/app/features/variables/query/adapter.ts create mode 100644 public/app/features/variables/query/operators.test.ts create mode 100644 public/app/features/variables/query/operators.ts create mode 100644 public/app/features/variables/query/queryRunners.test.ts create mode 100644 public/app/features/variables/query/queryRunners.ts create mode 100644 public/app/features/variables/query/reducer.test.ts create mode 100644 public/app/features/variables/query/reducer.ts create mode 100644 public/app/features/variables/query/variableQueryObserver.test.ts create mode 100644 public/app/features/variables/query/variableQueryObserver.ts create mode 100644 public/app/features/variables/shared/formatVariable.ts create mode 100644 public/app/features/variables/shared/multiOptions.test.ts create mode 100644 public/app/features/variables/shared/multiOptions.ts create mode 100644 public/app/features/variables/shared/testing/adHocVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/builders.ts create mode 100644 public/app/features/variables/shared/testing/datasourceVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/helpers.ts create mode 100644 public/app/features/variables/shared/testing/intervalVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/multiVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/optionsVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/queryVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/textboxVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/variableBuilder.ts create mode 100644 public/app/features/variables/state/actions.test.ts create mode 100644 public/app/features/variables/state/actions.ts create mode 100644 public/app/features/variables/state/helpers.ts create mode 100644 public/app/features/variables/state/onTimeRangeUpdated.test.ts create mode 100644 public/app/features/variables/state/processVariable.test.ts create mode 100644 public/app/features/variables/state/reducers.test.ts create mode 100644 public/app/features/variables/state/reducers.ts create mode 100644 public/app/features/variables/state/selectors.ts create mode 100644 public/app/features/variables/state/setOptionFromUrl.test.ts create mode 100644 public/app/features/variables/state/sharedReducer.test.ts create mode 100644 public/app/features/variables/state/sharedReducer.ts create mode 100644 public/app/features/variables/state/transactionReducer.test.ts create mode 100644 public/app/features/variables/state/transactionReducer.ts create mode 100644 public/app/features/variables/state/types.ts create mode 100644 public/app/features/variables/state/upgradeLegacyQueries.test.ts create mode 100644 public/app/features/variables/state/variablesReducer.ts create mode 100644 public/app/features/variables/system/adapter.ts create mode 100644 public/app/features/variables/textbox/TextBoxVariableEditor.tsx create mode 100644 public/app/features/variables/textbox/TextBoxVariablePicker.tsx create mode 100644 public/app/features/variables/textbox/actions.test.ts create mode 100644 public/app/features/variables/textbox/actions.ts create mode 100644 public/app/features/variables/textbox/adapter.test.ts create mode 100644 public/app/features/variables/textbox/adapter.ts create mode 100644 public/app/features/variables/textbox/reducer.test.ts create mode 100644 public/app/features/variables/textbox/reducer.ts create mode 100644 public/app/features/variables/types.ts create mode 100644 public/app/features/variables/utils.test.ts create mode 100644 public/app/features/variables/utils.ts create mode 100644 public/app/index.ts create mode 100644 public/app/partials/confirm_modal.html create mode 100644 public/app/partials/edit_json.html create mode 100644 public/app/partials/modal.html create mode 100644 public/app/partials/reset_password.html create mode 100644 public/app/partials/signup_invited.html create mode 100644 public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/README.md create mode 100644 public/app/plugins/datasource/cloud-monitoring/annotations_query_ctrl.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/api.test.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/api.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Aggregations.test.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Aggregations.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/AliasBy.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/AlignmentPeriods.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Alignments.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/GroupBys.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Help.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/LabelFilter.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/MQLQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/MetricQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Metrics.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/Project.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/QueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/QueryType.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/SLOQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/VisualMetricQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/__snapshots__/VariableQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/cloud-monitoring/components/index.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/config_ctrl.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/constants.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloud-storage-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloudfunctions-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloudsql-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloudsql-mysql-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloudsql-postgre-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/cloudtasks-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/dataprocessing-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/firewall-insight-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/gce-network-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/gce-vm-instance-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/gke-prometheus-pod-node-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/https-lb-backend-services-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/https-loadbalancer-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/micro-service-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/dashboards/network-tcp-loadbalancer-monitoring.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/datasource.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/functions.test.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/functions.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg create mode 100644 public/app/plugins/datasource/cloud-monitoring/module.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/cloud-monitoring/partials/config.html create mode 100644 public/app/plugins/datasource/cloud-monitoring/plugin.json create mode 100644 public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/specs/testData.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/types.ts create mode 100644 public/app/plugins/datasource/cloud-monitoring/variables.ts create mode 100644 public/app/plugins/datasource/cloudwatch/README.md create mode 100644 public/app/plugins/datasource/cloudwatch/annotations_query_ctrl.ts create mode 100644 public/app/plugins/datasource/cloudwatch/aws_url.ts create mode 100644 public/app/plugins/datasource/cloudwatch/components/Alias.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Alias.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/CloudWatchLink.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Dimensions.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Dimensions.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Forms.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/MetricsQueryFieldsEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Stats.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Stats.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/ThrottlingErrorMessage.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/__snapshots__/Alias.test.tsx.snap create mode 100644 public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/cloudwatch/components/__snapshots__/MetricsQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/cloudwatch/components/index.ts create mode 100644 public/app/plugins/datasource/cloudwatch/dashboards/EBS.json create mode 100644 public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json create mode 100644 public/app/plugins/datasource/cloudwatch/dashboards/Logs.json create mode 100644 public/app/plugins/datasource/cloudwatch/dashboards/RDS.json create mode 100644 public/app/plugins/datasource/cloudwatch/dashboards/ec2.json create mode 100644 public/app/plugins/datasource/cloudwatch/datasource.d.ts create mode 100644 public/app/plugins/datasource/cloudwatch/datasource.test.ts create mode 100644 public/app/plugins/datasource/cloudwatch/datasource.ts create mode 100644 public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png create mode 100644 public/app/plugins/datasource/cloudwatch/language_provider.test.ts create mode 100644 public/app/plugins/datasource/cloudwatch/language_provider.ts create mode 100644 public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts create mode 100644 public/app/plugins/datasource/cloudwatch/module.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/cloudwatch/partials/query.parameter.html create mode 100644 public/app/plugins/datasource/cloudwatch/plugin.json create mode 100644 public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts create mode 100644 public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/cloudwatch/syntax.ts create mode 100644 public/app/plugins/datasource/cloudwatch/types.ts create mode 100644 public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLLexer.js create mode 100644 public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParser.js create mode 100644 public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParserListener.js create mode 100644 public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.test.ts create mode 100644 public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.ts create mode 100644 public/app/plugins/datasource/cloudwatch/utils/rxjs/increasingInterval.ts create mode 100644 public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx create mode 100644 public/app/plugins/datasource/dashboard/DashboardQueryRow.tsx create mode 100644 public/app/plugins/datasource/dashboard/README.md create mode 100644 public/app/plugins/datasource/dashboard/datasource.ts create mode 100644 public/app/plugins/datasource/dashboard/index.ts create mode 100644 public/app/plugins/datasource/dashboard/module.ts create mode 100644 public/app/plugins/datasource/dashboard/plugin.json create mode 100644 public/app/plugins/datasource/dashboard/runSharedRequest.test.ts create mode 100644 public/app/plugins/datasource/dashboard/runSharedRequest.ts create mode 100644 public/app/plugins/datasource/dashboard/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/README.md create mode 100644 public/app/plugins/datasource/elasticsearch/components/AddRemove.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/IconButton.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts create mode 100644 public/app/plugins/datasource/elasticsearch/components/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/configuration/mocks.ts create mode 100644 public/app/plugins/datasource/elasticsearch/datasource.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/datasource.ts create mode 100644 public/app/plugins/datasource/elasticsearch/elastic_response.ts create mode 100644 public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts create mode 100644 public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts create mode 100644 public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg create mode 100644 public/app/plugins/datasource/elasticsearch/index_pattern.ts create mode 100644 public/app/plugins/datasource/elasticsearch/language_provider.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/language_provider.ts create mode 100644 public/app/plugins/datasource/elasticsearch/module.ts create mode 100644 public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/elasticsearch/plugin.json create mode 100644 public/app/plugins/datasource/elasticsearch/query_builder.ts create mode 100644 public/app/plugins/datasource/elasticsearch/query_def.ts create mode 100644 public/app/plugins/datasource/elasticsearch/query_help.md create mode 100644 public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/specs/index_pattern.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/types.ts create mode 100644 public/app/plugins/datasource/elasticsearch/utils.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/utils.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/sdk.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/annotations_query_ctrl.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/response_parser.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/__mocks__/schema.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.test.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/InsightsConfig.test.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/InsightsConfig.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MonitorConfig.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AzureCredentialsForm.test.tsx.snap create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/ConfigEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/InsightsConfig.test.tsx.snap create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/css/query_editor.css create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/editor_component.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/kusto/kusto.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/typeahead.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_network.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_1_select_type.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_2_azure_monitor_api_details.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_3_app_insights_api_details.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_4_save_and_test.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/grafana_cloud_install.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/grafana_cloud_login.png create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/img/logo.jpg create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/insights_analytics/insights_analytics_datasource.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/module.tsx create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/plugin.json create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/query_help.md create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts create mode 100644 public/app/plugins/datasource/grafana/README.md create mode 100644 public/app/plugins/datasource/grafana/annotation_ctrl.ts create mode 100644 public/app/plugins/datasource/grafana/components/QueryEditor.tsx create mode 100644 public/app/plugins/datasource/grafana/datasource.test.ts create mode 100644 public/app/plugins/datasource/grafana/datasource.ts create mode 100644 public/app/plugins/datasource/grafana/module.ts create mode 100644 public/app/plugins/datasource/grafana/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/grafana/plugin.json create mode 100644 public/app/plugins/datasource/grafana/types.ts create mode 100644 public/app/plugins/datasource/graphite/FunctionEditor.tsx create mode 100644 public/app/plugins/datasource/graphite/FunctionEditorControls.tsx create mode 100644 public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx create mode 100644 public/app/plugins/datasource/graphite/README.md create mode 100644 public/app/plugins/datasource/graphite/add_graphite_func.ts create mode 100644 public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json create mode 100644 public/app/plugins/datasource/graphite/dashboards/metrictank.json create mode 100644 public/app/plugins/datasource/graphite/datasource.ts create mode 100644 public/app/plugins/datasource/graphite/func_editor.ts create mode 100644 public/app/plugins/datasource/graphite/gfunc.ts create mode 100644 public/app/plugins/datasource/graphite/graphite_query.ts create mode 100644 public/app/plugins/datasource/graphite/img/graphite_logo.png create mode 100644 public/app/plugins/datasource/graphite/lexer.ts create mode 100644 public/app/plugins/datasource/graphite/meta.test.ts create mode 100644 public/app/plugins/datasource/graphite/meta.ts create mode 100644 public/app/plugins/datasource/graphite/module.ts create mode 100644 public/app/plugins/datasource/graphite/parser.ts create mode 100644 public/app/plugins/datasource/graphite/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/graphite/partials/query.editor.html create mode 100644 public/app/plugins/datasource/graphite/plugin.json create mode 100644 public/app/plugins/datasource/graphite/query_ctrl.ts create mode 100644 public/app/plugins/datasource/graphite/query_help.md create mode 100644 public/app/plugins/datasource/graphite/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/graphite/specs/gfunc.test.ts create mode 100644 public/app/plugins/datasource/graphite/specs/graphite_query.test.ts create mode 100644 public/app/plugins/datasource/graphite/specs/lexer.test.ts create mode 100644 public/app/plugins/datasource/graphite/specs/parser.test.ts create mode 100644 public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts create mode 100644 public/app/plugins/datasource/graphite/types.ts create mode 100644 public/app/plugins/datasource/influxdb/README.md create mode 100644 public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/InfluxCheatSheet.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.test.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/InfluxStartPage.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/influxdb/datasource.mock.ts create mode 100644 public/app/plugins/datasource/influxdb/datasource.ts create mode 100644 public/app/plugins/datasource/influxdb/img/influxdb_logo.svg create mode 100644 public/app/plugins/datasource/influxdb/influx_query_model.ts create mode 100644 public/app/plugins/datasource/influxdb/influx_series.ts create mode 100644 public/app/plugins/datasource/influxdb/module.ts create mode 100644 public/app/plugins/datasource/influxdb/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/influxdb/partials/query.editor.html create mode 100644 public/app/plugins/datasource/influxdb/plugin.json create mode 100644 public/app/plugins/datasource/influxdb/query_builder.ts create mode 100644 public/app/plugins/datasource/influxdb/query_ctrl.ts create mode 100644 public/app/plugins/datasource/influxdb/query_help.md create mode 100644 public/app/plugins/datasource/influxdb/query_part.ts create mode 100644 public/app/plugins/datasource/influxdb/response_parser.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/influx_query_model.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/influx_series.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/query_builder.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/query_part.test.ts create mode 100644 public/app/plugins/datasource/influxdb/specs/response_parser.test.ts create mode 100644 public/app/plugins/datasource/influxdb/types.ts create mode 100644 public/app/plugins/datasource/jaeger/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/jaeger/QueryField.test.tsx create mode 100644 public/app/plugins/datasource/jaeger/QueryField.tsx create mode 100644 public/app/plugins/datasource/jaeger/datasource.test.ts create mode 100644 public/app/plugins/datasource/jaeger/datasource.ts create mode 100644 public/app/plugins/datasource/jaeger/img/jaeger_logo.svg create mode 100644 public/app/plugins/datasource/jaeger/module.ts create mode 100644 public/app/plugins/datasource/jaeger/plugin.json create mode 100644 public/app/plugins/datasource/jaeger/responseTransform.test.ts create mode 100644 public/app/plugins/datasource/jaeger/responseTransform.ts create mode 100644 public/app/plugins/datasource/jaeger/testResponse.ts create mode 100644 public/app/plugins/datasource/jaeger/types.ts create mode 100644 public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx create mode 100644 public/app/plugins/datasource/loki/README.md create mode 100644 public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiLabel.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiOptionFields.test.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiOptionFields.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiQueryField.tsx create mode 100644 public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx create mode 100644 public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/loki/components/__snapshots__/LokiQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DebugSection.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DerivedField.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/DerivedFields.tsx create mode 100644 public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx create mode 100644 public/app/plugins/datasource/loki/datasource.test.ts create mode 100644 public/app/plugins/datasource/loki/datasource.ts create mode 100644 public/app/plugins/datasource/loki/img/grafana_icon.svg create mode 100644 public/app/plugins/datasource/loki/img/loki_icon.svg create mode 100644 public/app/plugins/datasource/loki/language_provider.test.ts create mode 100644 public/app/plugins/datasource/loki/language_provider.ts create mode 100644 public/app/plugins/datasource/loki/live_streams.test.ts create mode 100644 public/app/plugins/datasource/loki/live_streams.ts create mode 100644 public/app/plugins/datasource/loki/mocks.ts create mode 100644 public/app/plugins/datasource/loki/module.ts create mode 100644 public/app/plugins/datasource/loki/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/loki/plugin.json create mode 100644 public/app/plugins/datasource/loki/query_utils.test.ts create mode 100644 public/app/plugins/datasource/loki/query_utils.ts create mode 100644 public/app/plugins/datasource/loki/result_transformer.test.ts create mode 100644 public/app/plugins/datasource/loki/result_transformer.ts create mode 100644 public/app/plugins/datasource/loki/syntax.test.ts create mode 100644 public/app/plugins/datasource/loki/syntax.ts create mode 100644 public/app/plugins/datasource/loki/types.ts create mode 100644 public/app/plugins/datasource/mixed/MixedDataSource.test.ts create mode 100644 public/app/plugins/datasource/mixed/MixedDataSource.ts create mode 100644 public/app/plugins/datasource/mixed/README.md create mode 100644 public/app/plugins/datasource/mixed/module.ts create mode 100644 public/app/plugins/datasource/mixed/plugin.json create mode 100644 public/app/plugins/datasource/mssql/README.md create mode 100644 public/app/plugins/datasource/mssql/config_ctrl.ts create mode 100644 public/app/plugins/datasource/mssql/datasource.ts create mode 100644 public/app/plugins/datasource/mssql/img/sql_server_logo.svg create mode 100644 public/app/plugins/datasource/mssql/module.ts create mode 100644 public/app/plugins/datasource/mssql/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/mssql/partials/config.html create mode 100644 public/app/plugins/datasource/mssql/partials/query.editor.html create mode 100644 public/app/plugins/datasource/mssql/plugin.json create mode 100644 public/app/plugins/datasource/mssql/query_ctrl.ts create mode 100644 public/app/plugins/datasource/mssql/response_parser.ts create mode 100644 public/app/plugins/datasource/mssql/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/mssql/types.ts create mode 100644 public/app/plugins/datasource/mysql/README.md create mode 100644 public/app/plugins/datasource/mysql/datasource.ts create mode 100644 public/app/plugins/datasource/mysql/img/mysql_logo.svg create mode 100644 public/app/plugins/datasource/mysql/meta_query.ts create mode 100644 public/app/plugins/datasource/mysql/module.ts create mode 100644 public/app/plugins/datasource/mysql/mysql_query.ts create mode 100644 public/app/plugins/datasource/mysql/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/mysql/partials/config.html create mode 100644 public/app/plugins/datasource/mysql/partials/query.editor.html create mode 100644 public/app/plugins/datasource/mysql/plugin.json create mode 100644 public/app/plugins/datasource/mysql/query_ctrl.ts create mode 100644 public/app/plugins/datasource/mysql/response_parser.ts create mode 100644 public/app/plugins/datasource/mysql/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/mysql/sql_part.ts create mode 100644 public/app/plugins/datasource/mysql/types.ts create mode 100644 public/app/plugins/datasource/opentsdb/README.md create mode 100644 public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx create mode 100644 public/app/plugins/datasource/opentsdb/datasource.d.ts create mode 100644 public/app/plugins/datasource/opentsdb/datasource.ts create mode 100644 public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png create mode 100644 public/app/plugins/datasource/opentsdb/module.ts create mode 100644 public/app/plugins/datasource/opentsdb/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/opentsdb/partials/query.editor.html create mode 100644 public/app/plugins/datasource/opentsdb/plugin.json create mode 100644 public/app/plugins/datasource/opentsdb/query_ctrl.ts create mode 100644 public/app/plugins/datasource/opentsdb/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts create mode 100644 public/app/plugins/datasource/opentsdb/types.ts create mode 100644 public/app/plugins/datasource/postgres/README.md create mode 100644 public/app/plugins/datasource/postgres/config_ctrl.ts create mode 100644 public/app/plugins/datasource/postgres/datasource.ts create mode 100644 public/app/plugins/datasource/postgres/img/postgresql_logo.svg create mode 100644 public/app/plugins/datasource/postgres/meta_query.ts create mode 100644 public/app/plugins/datasource/postgres/mode-sql.js create mode 100644 public/app/plugins/datasource/postgres/module.ts create mode 100644 public/app/plugins/datasource/postgres/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/postgres/partials/config.html create mode 100644 public/app/plugins/datasource/postgres/partials/query.editor.html create mode 100644 public/app/plugins/datasource/postgres/plugin.json create mode 100644 public/app/plugins/datasource/postgres/postgres_query.ts create mode 100644 public/app/plugins/datasource/postgres/query_ctrl.ts create mode 100644 public/app/plugins/datasource/postgres/response_parser.ts create mode 100644 public/app/plugins/datasource/postgres/specs/datasource.test.ts create mode 100644 public/app/plugins/datasource/postgres/specs/postgres_query.test.ts create mode 100644 public/app/plugins/datasource/postgres/sql_part.ts create mode 100644 public/app/plugins/datasource/postgres/types.ts create mode 100644 public/app/plugins/datasource/prometheus/README.md create mode 100644 public/app/plugins/datasource/prometheus/add_label_to_query.test.ts create mode 100644 public/app/plugins/datasource/prometheus/add_label_to_query.ts create mode 100644 public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromLink.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromLink.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PromQueryField.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap create mode 100644 public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx create mode 100644 public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx create mode 100644 public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx create mode 100644 public/app/plugins/datasource/prometheus/configuration/mocks.ts create mode 100644 public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json create mode 100644 public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json create mode 100644 public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json create mode 100644 public/app/plugins/datasource/prometheus/datasource.test.ts create mode 100644 public/app/plugins/datasource/prometheus/datasource.ts create mode 100644 public/app/plugins/datasource/prometheus/img/prometheus_logo.svg create mode 100644 public/app/plugins/datasource/prometheus/language_provider.test.ts create mode 100644 public/app/plugins/datasource/prometheus/language_provider.ts create mode 100644 public/app/plugins/datasource/prometheus/language_utils.test.ts create mode 100644 public/app/plugins/datasource/prometheus/language_utils.ts create mode 100644 public/app/plugins/datasource/prometheus/metric_find_query.test.ts create mode 100644 public/app/plugins/datasource/prometheus/metric_find_query.ts create mode 100644 public/app/plugins/datasource/prometheus/module.test.ts create mode 100644 public/app/plugins/datasource/prometheus/module.ts create mode 100644 public/app/plugins/datasource/prometheus/partials/annotations.editor.html create mode 100644 public/app/plugins/datasource/prometheus/plugin.json create mode 100644 public/app/plugins/datasource/prometheus/promql.test.ts create mode 100644 public/app/plugins/datasource/prometheus/promql.ts create mode 100644 public/app/plugins/datasource/prometheus/query_hints.test.ts create mode 100644 public/app/plugins/datasource/prometheus/query_hints.ts create mode 100644 public/app/plugins/datasource/prometheus/result_transformer.test.ts create mode 100644 public/app/plugins/datasource/prometheus/result_transformer.ts create mode 100644 public/app/plugins/datasource/prometheus/types.ts create mode 100644 public/app/plugins/datasource/prometheus/variables.ts create mode 100644 public/app/plugins/datasource/tempo/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/tempo/QueryField.tsx create mode 100644 public/app/plugins/datasource/tempo/datasource.test.ts create mode 100644 public/app/plugins/datasource/tempo/datasource.ts create mode 100644 public/app/plugins/datasource/tempo/img/tempo_logo.svg create mode 100644 public/app/plugins/datasource/tempo/module.ts create mode 100644 public/app/plugins/datasource/tempo/plugin.json create mode 100644 public/app/plugins/datasource/testdata/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/LogIpsum.ts create mode 100644 public/app/plugins/datasource/testdata/QueryEditor.test.tsx create mode 100644 public/app/plugins/datasource/testdata/QueryEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/TestInfoTab.tsx create mode 100644 public/app/plugins/datasource/testdata/__mocks__/scenarios.ts create mode 100644 public/app/plugins/datasource/testdata/components/CSVWaveEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/GrafanaLiveEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/ManualEntryEditor.test.tsx create mode 100644 public/app/plugins/datasource/testdata/components/ManualEntryEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/PredictablePulseEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/RandomWalkEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/StreamingClientEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/components/index.ts create mode 100644 public/app/plugins/datasource/testdata/constants.ts create mode 100644 public/app/plugins/datasource/testdata/dashboards/streaming.json create mode 100644 public/app/plugins/datasource/testdata/datasource.ts create mode 100644 public/app/plugins/datasource/testdata/img/testdata.svg create mode 100644 public/app/plugins/datasource/testdata/metricTree.test.ts create mode 100644 public/app/plugins/datasource/testdata/metricTree.ts create mode 100644 public/app/plugins/datasource/testdata/module.tsx create mode 100644 public/app/plugins/datasource/testdata/nodeGraphUtils.ts create mode 100644 public/app/plugins/datasource/testdata/plugin.json create mode 100644 public/app/plugins/datasource/testdata/runStreams.ts create mode 100644 public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts create mode 100644 public/app/plugins/datasource/testdata/types.ts create mode 100644 public/app/plugins/datasource/testdata/variables.ts create mode 100644 public/app/plugins/datasource/zipkin/ConfigEditor.tsx create mode 100644 public/app/plugins/datasource/zipkin/QueryField.test.tsx create mode 100644 public/app/plugins/datasource/zipkin/QueryField.tsx create mode 100644 public/app/plugins/datasource/zipkin/constants.ts create mode 100644 public/app/plugins/datasource/zipkin/datasource.test.ts create mode 100644 public/app/plugins/datasource/zipkin/datasource.ts create mode 100644 public/app/plugins/datasource/zipkin/img/zipkin-logo.svg create mode 100644 public/app/plugins/datasource/zipkin/module.ts create mode 100644 public/app/plugins/datasource/zipkin/plugin.json create mode 100644 public/app/plugins/datasource/zipkin/types.ts create mode 100644 public/app/plugins/datasource/zipkin/utils/testData.ts create mode 100644 public/app/plugins/datasource/zipkin/utils/transforms.test.ts create mode 100644 public/app/plugins/datasource/zipkin/utils/transforms.ts create mode 100644 public/app/plugins/panel/alertlist/README.md create mode 100644 public/app/plugins/panel/alertlist/editor.html create mode 100644 public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg create mode 100644 public/app/plugins/panel/alertlist/module.html create mode 100644 public/app/plugins/panel/alertlist/module.ts create mode 100644 public/app/plugins/panel/alertlist/plugin.json create mode 100644 public/app/plugins/panel/annolist/AnnoListPanel.test.tsx create mode 100644 public/app/plugins/panel/annolist/AnnoListPanel.tsx create mode 100644 public/app/plugins/panel/annolist/AnnotationListItem.tsx create mode 100644 public/app/plugins/panel/annolist/AnnotationListItemTags.tsx create mode 100644 public/app/plugins/panel/annolist/README.md create mode 100644 public/app/plugins/panel/annolist/img/icn-annolist-panel.svg create mode 100644 public/app/plugins/panel/annolist/module.tsx create mode 100644 public/app/plugins/panel/annolist/plugin.json create mode 100644 public/app/plugins/panel/annolist/types.ts create mode 100755 public/app/plugins/panel/barchart/BarChartPanel.tsx create mode 100644 public/app/plugins/panel/barchart/img/barchart.svg create mode 100755 public/app/plugins/panel/barchart/module.tsx create mode 100755 public/app/plugins/panel/barchart/plugin.json create mode 100644 public/app/plugins/panel/bargauge/BarGaugeMigrations.test.ts create mode 100644 public/app/plugins/panel/bargauge/BarGaugeMigrations.ts create mode 100644 public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx create mode 100644 public/app/plugins/panel/bargauge/BarGaugePanel.tsx create mode 100644 public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg create mode 100644 public/app/plugins/panel/bargauge/module.tsx create mode 100644 public/app/plugins/panel/bargauge/plugin.json create mode 100644 public/app/plugins/panel/bargauge/types.ts create mode 100644 public/app/plugins/panel/dashlist/DashList.tsx create mode 100644 public/app/plugins/panel/dashlist/README.md create mode 100644 public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg create mode 100644 public/app/plugins/panel/dashlist/module.tsx create mode 100644 public/app/plugins/panel/dashlist/plugin.json create mode 100644 public/app/plugins/panel/dashlist/styles.ts create mode 100644 public/app/plugins/panel/dashlist/types.ts create mode 100644 public/app/plugins/panel/gauge/GaugeMigrations.test.ts create mode 100644 public/app/plugins/panel/gauge/GaugeMigrations.ts create mode 100644 public/app/plugins/panel/gauge/GaugePanel.tsx create mode 100644 public/app/plugins/panel/gauge/__snapshots__/GaugeMigrations.test.ts.snap create mode 100644 public/app/plugins/panel/gauge/img/icon_gauge.svg create mode 100644 public/app/plugins/panel/gauge/module.tsx create mode 100644 public/app/plugins/panel/gauge/plugin.json create mode 100644 public/app/plugins/panel/gauge/types.ts create mode 100644 public/app/plugins/panel/gettingstarted/GettingStarted.tsx create mode 100644 public/app/plugins/panel/gettingstarted/README.md create mode 100644 public/app/plugins/panel/gettingstarted/components/DocsCard.tsx create mode 100644 public/app/plugins/panel/gettingstarted/components/Step.tsx create mode 100644 public/app/plugins/panel/gettingstarted/components/TutorialCard.tsx create mode 100644 public/app/plugins/panel/gettingstarted/components/sharedStyles.ts create mode 100644 public/app/plugins/panel/gettingstarted/img/Onboarding_Panel_dark.svg create mode 100644 public/app/plugins/panel/gettingstarted/img/Onboarding_Panel_light.svg create mode 100644 public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg create mode 100644 public/app/plugins/panel/gettingstarted/module.ts create mode 100644 public/app/plugins/panel/gettingstarted/plugin.json create mode 100644 public/app/plugins/panel/gettingstarted/steps.ts create mode 100644 public/app/plugins/panel/gettingstarted/types.ts create mode 100644 public/app/plugins/panel/graph/GraphContextMenuCtrl.ts create mode 100644 public/app/plugins/panel/graph/GraphMigrations.test.ts create mode 100644 public/app/plugins/panel/graph/GraphMigrations.ts create mode 100644 public/app/plugins/panel/graph/Legend/Legend.tsx create mode 100644 public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx create mode 100644 public/app/plugins/panel/graph/README.md create mode 100644 public/app/plugins/panel/graph/align_yaxes.ts create mode 100644 public/app/plugins/panel/graph/axes_editor.html create mode 100644 public/app/plugins/panel/graph/axes_editor.ts create mode 100644 public/app/plugins/panel/graph/data_processor.ts create mode 100644 public/app/plugins/panel/graph/graph.ts create mode 100644 public/app/plugins/panel/graph/graph_tooltip.d.ts create mode 100644 public/app/plugins/panel/graph/graph_tooltip.ts create mode 100644 public/app/plugins/panel/graph/histogram.ts create mode 100644 public/app/plugins/panel/graph/img/icn-graph-panel.svg create mode 100644 public/app/plugins/panel/graph/jquery.flot.events.ts create mode 100644 public/app/plugins/panel/graph/module.ts create mode 100644 public/app/plugins/panel/graph/plugin.json create mode 100644 public/app/plugins/panel/graph/series_overrides_ctrl.ts create mode 100644 public/app/plugins/panel/graph/specs/__snapshots__/data_processor.test.ts.snap create mode 100644 public/app/plugins/panel/graph/specs/align_yaxes.test.ts create mode 100644 public/app/plugins/panel/graph/specs/data_processor.test.ts create mode 100644 public/app/plugins/panel/graph/specs/graph.test.ts create mode 100644 public/app/plugins/panel/graph/specs/graph_ctrl.test.ts create mode 100644 public/app/plugins/panel/graph/specs/graph_tooltip.test.ts create mode 100644 public/app/plugins/panel/graph/specs/histogram.test.ts create mode 100644 public/app/plugins/panel/graph/specs/series_override_ctrl.test.ts create mode 100644 public/app/plugins/panel/graph/specs/threshold_manager.test.ts create mode 100644 public/app/plugins/panel/graph/specs/time_region_manager.test.ts create mode 100644 public/app/plugins/panel/graph/tab_display.html create mode 100644 public/app/plugins/panel/graph/tab_legend.html create mode 100644 public/app/plugins/panel/graph/tab_series_overrides.html create mode 100644 public/app/plugins/panel/graph/tab_thresholds.html create mode 100644 public/app/plugins/panel/graph/tab_time_regions.html create mode 100644 public/app/plugins/panel/graph/template.ts create mode 100644 public/app/plugins/panel/graph/threshold_manager.ts create mode 100644 public/app/plugins/panel/graph/thresholds_form.html create mode 100644 public/app/plugins/panel/graph/thresholds_form.ts create mode 100644 public/app/plugins/panel/graph/time_region_manager.ts create mode 100644 public/app/plugins/panel/graph/time_regions_form.html create mode 100644 public/app/plugins/panel/graph/time_regions_form.ts create mode 100644 public/app/plugins/panel/graph/types.ts create mode 100644 public/app/plugins/panel/graph/utils.test.ts create mode 100644 public/app/plugins/panel/graph/utils.ts create mode 100644 public/app/plugins/panel/heatmap/README.md create mode 100644 public/app/plugins/panel/heatmap/axes_editor.ts create mode 100644 public/app/plugins/panel/heatmap/color_legend.ts create mode 100644 public/app/plugins/panel/heatmap/color_scale.ts create mode 100644 public/app/plugins/panel/heatmap/display_editor.ts create mode 100644 public/app/plugins/panel/heatmap/heatmap_ctrl.ts create mode 100644 public/app/plugins/panel/heatmap/heatmap_data_converter.ts create mode 100644 public/app/plugins/panel/heatmap/heatmap_tooltip.ts create mode 100644 public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg create mode 100644 public/app/plugins/panel/heatmap/module.html create mode 100644 public/app/plugins/panel/heatmap/module.ts create mode 100644 public/app/plugins/panel/heatmap/partials/axes_editor.html create mode 100644 public/app/plugins/panel/heatmap/partials/display_editor.html create mode 100644 public/app/plugins/panel/heatmap/plugin.json create mode 100644 public/app/plugins/panel/heatmap/rendering.ts create mode 100644 public/app/plugins/panel/heatmap/specs/heatmap_ctrl.test.ts create mode 100644 public/app/plugins/panel/heatmap/specs/heatmap_data_converter.test.ts create mode 100644 public/app/plugins/panel/heatmap/types.ts create mode 100644 public/app/plugins/panel/live/LiveChannelEditor.tsx create mode 100755 public/app/plugins/panel/live/LivePanel.tsx create mode 100644 public/app/plugins/panel/live/img/live.svg create mode 100755 public/app/plugins/panel/live/module.tsx create mode 100755 public/app/plugins/panel/live/plugin.json create mode 100755 public/app/plugins/panel/live/types.ts create mode 100644 public/app/plugins/panel/logs/LogsPanel.tsx create mode 100644 public/app/plugins/panel/logs/img/icn-logs-panel.svg create mode 100644 public/app/plugins/panel/logs/module.tsx create mode 100644 public/app/plugins/panel/logs/plugin.json create mode 100644 public/app/plugins/panel/logs/types.ts create mode 100755 public/app/plugins/panel/news/NewsPanel.tsx create mode 100644 public/app/plugins/panel/news/constants.ts create mode 100644 public/app/plugins/panel/news/img/news.svg create mode 100755 public/app/plugins/panel/news/module.tsx create mode 100755 public/app/plugins/panel/news/plugin.json create mode 100644 public/app/plugins/panel/news/rss.ts create mode 100755 public/app/plugins/panel/news/types.ts create mode 100644 public/app/plugins/panel/news/utils.test.ts create mode 100644 public/app/plugins/panel/news/utils.ts create mode 100644 public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx create mode 100644 public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg create mode 100644 public/app/plugins/panel/nodeGraph/module.tsx create mode 100644 public/app/plugins/panel/nodeGraph/plugin.json create mode 100644 public/app/plugins/panel/nodeGraph/types.ts create mode 100644 public/app/plugins/panel/piechart/PieChartOptionsBox.tsx create mode 100644 public/app/plugins/panel/piechart/PieChartPanel.tsx create mode 100644 public/app/plugins/panel/piechart/img/icon_piechart.svg create mode 100644 public/app/plugins/panel/piechart/migrations.test.ts create mode 100644 public/app/plugins/panel/piechart/migrations.ts create mode 100644 public/app/plugins/panel/piechart/module.tsx create mode 100644 public/app/plugins/panel/piechart/plugin.json create mode 100644 public/app/plugins/panel/piechart/types.ts create mode 100644 public/app/plugins/panel/pluginlist/README.md create mode 100644 public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg create mode 100644 public/app/plugins/panel/pluginlist/module.html create mode 100644 public/app/plugins/panel/pluginlist/module.ts create mode 100644 public/app/plugins/panel/pluginlist/plugin.json create mode 100644 public/app/plugins/panel/singlestat/README.md create mode 100644 public/app/plugins/panel/singlestat/editor.html create mode 100644 public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg create mode 100644 public/app/plugins/panel/singlestat/mappings.html create mode 100644 public/app/plugins/panel/singlestat/module.html create mode 100644 public/app/plugins/panel/singlestat/module.ts create mode 100644 public/app/plugins/panel/singlestat/plugin.json create mode 100644 public/app/plugins/panel/singlestat/specs/singlestat.test.ts create mode 100644 public/app/plugins/panel/singlestat/specs/singlestat_panel.test.ts create mode 100644 public/app/plugins/panel/stat/README.md create mode 100644 public/app/plugins/panel/stat/StatMigrations.test.ts create mode 100644 public/app/plugins/panel/stat/StatMigrations.ts create mode 100644 public/app/plugins/panel/stat/StatPanel.tsx create mode 100644 public/app/plugins/panel/stat/img/icn-singlestat-panel.svg create mode 100644 public/app/plugins/panel/stat/module.tsx create mode 100644 public/app/plugins/panel/stat/plugin.json create mode 100644 public/app/plugins/panel/stat/types.ts create mode 100644 public/app/plugins/panel/table-old/README.md create mode 100644 public/app/plugins/panel/table-old/column_options.html create mode 100644 public/app/plugins/panel/table-old/column_options.ts create mode 100644 public/app/plugins/panel/table-old/editor.html create mode 100644 public/app/plugins/panel/table-old/editor.ts create mode 100644 public/app/plugins/panel/table-old/img/icn-table-panel.svg create mode 100644 public/app/plugins/panel/table-old/module.html create mode 100644 public/app/plugins/panel/table-old/module.ts create mode 100644 public/app/plugins/panel/table-old/plugin.json create mode 100644 public/app/plugins/panel/table-old/renderer.ts create mode 100644 public/app/plugins/panel/table-old/specs/renderer.test.ts create mode 100644 public/app/plugins/panel/table-old/specs/transformers.test.ts create mode 100644 public/app/plugins/panel/table-old/transformers.ts create mode 100644 public/app/plugins/panel/table-old/types.ts create mode 100644 public/app/plugins/panel/table/README.md create mode 100644 public/app/plugins/panel/table/TablePanel.tsx create mode 100644 public/app/plugins/panel/table/__snapshots__/migrations.test.ts.snap create mode 100644 public/app/plugins/panel/table/img/icn-table-panel.svg create mode 100644 public/app/plugins/panel/table/migrations.test.ts create mode 100644 public/app/plugins/panel/table/migrations.ts create mode 100644 public/app/plugins/panel/table/module.tsx create mode 100644 public/app/plugins/panel/table/plugin.json create mode 100644 public/app/plugins/panel/table/types.ts create mode 100644 public/app/plugins/panel/text/README.md create mode 100644 public/app/plugins/panel/text/TextPanel.tsx create mode 100644 public/app/plugins/panel/text/TextPanelEditor.tsx create mode 100644 public/app/plugins/panel/text/img/icn-text-panel.svg create mode 100644 public/app/plugins/panel/text/module.tsx create mode 100644 public/app/plugins/panel/text/plugin.json create mode 100644 public/app/plugins/panel/text/textPanelMigrationHandler.test.ts create mode 100644 public/app/plugins/panel/text/textPanelMigrationHandler.ts create mode 100644 public/app/plugins/panel/text/types.ts create mode 100644 public/app/plugins/panel/timeseries/FillBelowToEditor.tsx create mode 100644 public/app/plugins/panel/timeseries/HideSeriesConfigEditor.tsx create mode 100644 public/app/plugins/panel/timeseries/LayoutBuilder.ts create mode 100644 public/app/plugins/panel/timeseries/LineStyleEditor.tsx create mode 100644 public/app/plugins/panel/timeseries/README.md create mode 100644 public/app/plugins/panel/timeseries/ScaleDistributionEditor.tsx create mode 100644 public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx create mode 100644 public/app/plugins/panel/timeseries/__snapshots__/migrations.test.ts.snap create mode 100644 public/app/plugins/panel/timeseries/config.ts create mode 100644 public/app/plugins/panel/timeseries/img/icn-graph-panel.svg create mode 100644 public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg create mode 100644 public/app/plugins/panel/timeseries/migrations.test.ts create mode 100644 public/app/plugins/panel/timeseries/migrations.ts create mode 100644 public/app/plugins/panel/timeseries/module.tsx create mode 100644 public/app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory.test.ts create mode 100644 public/app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory.ts create mode 100644 public/app/plugins/panel/timeseries/overrides/hideSeriesConfigFactory.test.ts create mode 100644 public/app/plugins/panel/timeseries/overrides/hideSeriesConfigFactory.ts create mode 100644 public/app/plugins/panel/timeseries/plugin.json create mode 100644 public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx create mode 100644 public/app/plugins/panel/timeseries/types.ts create mode 100644 public/app/plugins/panel/welcome/README.md create mode 100644 public/app/plugins/panel/welcome/Welcome.tsx create mode 100644 public/app/plugins/panel/welcome/img/background_light.svg create mode 100644 public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg create mode 100644 public/app/plugins/panel/welcome/module.ts create mode 100644 public/app/plugins/panel/welcome/plugin.json create mode 100644 public/app/plugins/panel/xychart/README.md create mode 100644 public/app/plugins/panel/xychart/XYChartPanel.tsx create mode 100644 public/app/plugins/panel/xychart/XYDimsEditor.tsx create mode 100644 public/app/plugins/panel/xychart/dims.ts create mode 100644 public/app/plugins/panel/xychart/img/icn-xychart.svg create mode 100644 public/app/plugins/panel/xychart/module.tsx create mode 100644 public/app/plugins/panel/xychart/plugin.json create mode 100644 public/app/plugins/panel/xychart/types.ts create mode 100644 public/app/plugins/sdk.ts create mode 100644 public/app/routes/GrafanaCtrl.ts create mode 100644 public/app/routes/ReactContainer.tsx create mode 100644 public/app/routes/dashboard_loaders.ts create mode 100644 public/app/routes/registry.ts create mode 100644 public/app/routes/routes.ts create mode 100644 public/app/store/configureStore.ts create mode 100644 public/app/store/store.ts create mode 100644 public/app/types/acl.ts create mode 100644 public/app/types/alerting.ts create mode 100644 public/app/types/angular.ts create mode 100644 public/app/types/apiKeys.ts create mode 100644 public/app/types/appEvent.ts create mode 100644 public/app/types/appNotifications.ts create mode 100644 public/app/types/application.ts create mode 100644 public/app/types/dangerously-set-html-content.d.ts create mode 100644 public/app/types/dashboard.ts create mode 100644 public/app/types/datasources.ts create mode 100644 public/app/types/events.ts create mode 100644 public/app/types/explore.ts create mode 100644 public/app/types/folders.ts create mode 100644 public/app/types/index.ts create mode 100644 public/app/types/jquery/jquery.d.ts create mode 100644 public/app/types/ldap.ts create mode 100644 public/app/types/location.ts create mode 100644 public/app/types/organization.ts create mode 100644 public/app/types/plugins.ts create mode 100644 public/app/types/query.ts create mode 100644 public/app/types/sanitize-url.d.ts create mode 100644 public/app/types/store.ts create mode 100644 public/app/types/svg.d.ts create mode 100644 public/app/types/teams.ts create mode 100644 public/app/types/templates.ts create mode 100644 public/app/types/user.ts create mode 100644 public/dashboards/default.json create mode 100644 public/dashboards/home.json create mode 100644 public/dashboards/scripted.js create mode 100644 public/dashboards/scripted_async.js create mode 100644 public/dashboards/scripted_templated.js create mode 100644 public/dashboards/template_vars.json create mode 100644 public/emails/README.md create mode 100644 public/emails/alert_notification.html create mode 100644 public/emails/alert_notification_example.html create mode 100644 public/emails/invited_to_org.html create mode 100644 public/emails/new_user_invite.html create mode 100644 public/emails/reset_password.html create mode 100644 public/emails/signup_started.html create mode 100644 public/emails/welcome_on_signup.html create mode 100644 public/fonts/FontAwesome.otf create mode 100644 public/fonts/fontawesome-webfont.eot create mode 100644 public/fonts/fontawesome-webfont.svg create mode 100644 public/fonts/fontawesome-webfont.ttf create mode 100644 public/fonts/fontawesome-webfont.woff create mode 100644 public/fonts/fontawesome-webfont.woff2 create mode 100755 public/fonts/grafana-icons.eot create mode 100644 public/fonts/grafana-icons.svg create mode 100755 public/fonts/grafana-icons.ttf create mode 100755 public/fonts/grafana-icons.woff create mode 100644 public/fonts/opensans/59ZRklaO5bWGqF5A9baEERJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTRWV49_lSm1NYrwo-zkhivY.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTT0LW-43aMEzIO6XUTLjad8.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTZX5f-9o1vgP2EXwfjgl7AY.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTa-j2U0lmluP9RWlSytm3ho.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTaaRobkAwv3vxw3jMhVENGA.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTegdm0LZdjqr5-oayXSOefg.woff2 create mode 100644 public/fonts/opensans/DXI1ORHCpsQm3Vp6mXoaTf8zf_FOSsgRmwsS7Aa9k2w.woff2 create mode 100644 public/fonts/opensans/K88pR3goAWT7BTt32Z01mxJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/opensans/LWCjsQkB6EMdfHrEVqA1KRJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNShWV49_lSm1NYrwo-zkhivY.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSj0LW-43aMEzIO6XUTLjad8.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSpX5f-9o1vgP2EXwfjgl7AY.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSq-j2U0lmluP9RWlSytm3ho.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSqaRobkAwv3vxw3jMhVENGA.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSugdm0LZdjqr5-oayXSOefg.woff2 create mode 100644 public/fonts/opensans/MTP_ySUJH_bn48VBG8sNSv8zf_FOSsgRmwsS7Aa9k2w.woff2 create mode 100644 public/fonts/opensans/RjgO7rYTmqiVp7vzi-Q5URJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/opensans/cJZKeOuBrn4kERxqtaUH3VtXRa8TVwTICgirnJhmVJw.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzBWV49_lSm1NYrwo-zkhivY.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzD0LW-43aMEzIO6XUTLjad8.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzJX5f-9o1vgP2EXwfjgl7AY.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzK-j2U0lmluP9RWlSytm3ho.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzKaRobkAwv3vxw3jMhVENGA.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzOgdm0LZdjqr5-oayXSOefg.woff2 create mode 100644 public/fonts/opensans/k3k702ZOKiLJc3WVjuplzP8zf_FOSsgRmwsS7Aa9k2w.woff2 create mode 100644 public/fonts/opensans/u-WUoqrET9fUeobQW7jkRRJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBiYE0-AqJ3nfInTTiDXDjU4.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBjTOQ_MqJVwkKsUn0wKzc2I.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBjUj_cnvWIuuBMVgbX098Mw.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBkbcKLIaa1LC45dFaAfauRA.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBmo_sUJ8uO4YLWRInS22T3Y.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBo4P5ICox8Kq3LLUNMylGO4.woff2 create mode 100644 public/fonts/opensans/xjAJXh38I15wypJXxuGMBr6up8jxqWt8HVA3mDhkV_0.woff2 create mode 100644 public/fonts/opensans/xozscpT2726on7jbcb_pAhJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2 create mode 100644 public/fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2 create mode 100644 public/fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2 create mode 100644 public/fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2 create mode 100644 public/fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2 create mode 100644 public/fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2 create mode 100644 public/fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2 create mode 100644 public/img/alert_howto_new.png create mode 100644 public/img/angle_gradient_light_rev.png create mode 100644 public/img/angle_gradient_rev.png create mode 100644 public/img/apple-touch-icon.png create mode 100644 public/img/background_tease.jpg create mode 100644 public/img/browserconfig.xml create mode 100644 public/img/check_radio_sheet.png create mode 100644 public/img/checkbox.png create mode 100644 public/img/checkbox_white.png create mode 100644 public/img/critical.svg create mode 100644 public/img/cubes.png create mode 100644 public/img/envelope.png create mode 100644 public/img/fav32.png create mode 100644 public/img/glyphicons-halflings-white.png create mode 100644 public/img/glyphicons-halflings.png create mode 100644 public/img/grab_dark.svg create mode 100644 public/img/grab_light.svg create mode 100644 public/img/grafana_com_auth_icon.svg create mode 100644 public/img/grafana_enterprise_typelogo.svg create mode 100644 public/img/grafana_icon.svg create mode 100644 public/img/grafana_mask_icon.svg create mode 100644 public/img/grafana_mask_icon_white.svg create mode 100644 public/img/grafana_net_logo.svg create mode 100644 public/img/grafana_typelogo.svg create mode 100644 public/img/grafanaconline.png create mode 100644 public/img/graph404.svg create mode 100644 public/img/heatmap_bg_test.svg create mode 100644 public/img/icn-app.svg create mode 100644 public/img/icn-dashboard-tiny.svg create mode 100644 public/img/icn-dashboard.svg create mode 100644 public/img/icn-datasource.svg create mode 100644 public/img/icn-panel.svg create mode 100644 public/img/icn-plugins-tiny.svg create mode 100644 public/img/icn-row.svg create mode 100644 public/img/icons_dark_theme/icon_add_annotation.svg create mode 100644 public/img/icons_dark_theme/icon_add_annotation_alt.svg create mode 100644 public/img/icons_dark_theme/icon_add_data_sources.svg create mode 100644 public/img/icons_dark_theme/icon_add_folder.svg create mode 100644 public/img/icons_dark_theme/icon_add_link.svg create mode 100644 public/img/icons_dark_theme/icon_add_notification_channel.svg create mode 100644 public/img/icons_dark_theme/icon_add_panel.svg create mode 100644 public/img/icons_dark_theme/icon_add_team.svg create mode 100644 public/img/icons_dark_theme/icon_add_user.svg create mode 100644 public/img/icons_dark_theme/icon_add_variable.svg create mode 100644 public/img/icons_dark_theme/icon_advanced.svg create mode 100644 public/img/icons_dark_theme/icon_advanced_active.svg create mode 100644 public/img/icons_dark_theme/icon_alert.svg create mode 100644 public/img/icons_dark_theme/icon_alert_alt.svg create mode 100644 public/img/icons_dark_theme/icon_alert_off.svg create mode 100644 public/img/icons_dark_theme/icon_alert_rules.svg create mode 100644 public/img/icons_dark_theme/icon_alerting.svg create mode 100644 public/img/icons_dark_theme/icon_alerting_active.svg create mode 100644 public/img/icons_dark_theme/icon_annotation.svg create mode 100644 public/img/icons_dark_theme/icon_annotation_alt.svg create mode 100644 public/img/icons_dark_theme/icon_apikeys.svg create mode 100644 public/img/icons_dark_theme/icon_arrow_left.svg create mode 100644 public/img/icons_dark_theme/icon_arrow_left_circle.svg create mode 100644 public/img/icons_dark_theme/icon_cog.svg create mode 100644 public/img/icons_dark_theme/icon_dashboard.svg create mode 100644 public/img/icons_dark_theme/icon_dashboard_fav.svg create mode 100644 public/img/icons_dark_theme/icon_dashboard_list.svg create mode 100644 public/img/icons_dark_theme/icon_data_sources.svg create mode 100644 public/img/icons_dark_theme/icon_editor.svg create mode 100644 public/img/icons_dark_theme/icon_explore.svg create mode 100644 public/img/icons_dark_theme/icon_home.svg create mode 100644 public/img/icons_dark_theme/icon_import_dashboard.svg create mode 100644 public/img/icons_dark_theme/icon_json.svg create mode 100644 public/img/icons_dark_theme/icon_link.svg create mode 100644 public/img/icons_dark_theme/icon_new_dashboard.svg create mode 100644 public/img/icons_dark_theme/icon_notification_channels.svg create mode 100644 public/img/icons_dark_theme/icon_org.svg create mode 100644 public/img/icons_dark_theme/icon_playlist.svg create mode 100644 public/img/icons_dark_theme/icon_plugins.svg create mode 100644 public/img/icons_dark_theme/icon_preferences.svg create mode 100644 public/img/icons_dark_theme/icon_query.svg create mode 100644 public/img/icons_dark_theme/icon_query_active.svg create mode 100644 public/img/icons_dark_theme/icon_question.svg create mode 100644 public/img/icons_dark_theme/icon_remove.svg create mode 100644 public/img/icons_dark_theme/icon_reports.svg create mode 100644 public/img/icons_dark_theme/icon_shield.svg create mode 100644 public/img/icons_dark_theme/icon_sitemap.svg create mode 100644 public/img/icons_dark_theme/icon_snapshots.svg create mode 100644 public/img/icons_dark_theme/icon_team.svg create mode 100644 public/img/icons_dark_theme/icon_user.svg create mode 100644 public/img/icons_dark_theme/icon_variable.svg create mode 100644 public/img/icons_dark_theme/icon_viewer.svg create mode 100644 public/img/icons_dark_theme/icon_visualize.svg create mode 100644 public/img/icons_dark_theme/icon_visualize_active.svg create mode 100644 public/img/icons_dark_theme/icon_zoom_out.svg create mode 100644 public/img/icons_light_theme/icon_add_annotation.svg create mode 100644 public/img/icons_light_theme/icon_add_annotation_alt.svg create mode 100644 public/img/icons_light_theme/icon_add_data_sources.svg create mode 100644 public/img/icons_light_theme/icon_add_folder.svg create mode 100644 public/img/icons_light_theme/icon_add_link.svg create mode 100644 public/img/icons_light_theme/icon_add_notification_channel.svg create mode 100644 public/img/icons_light_theme/icon_add_panel.svg create mode 100644 public/img/icons_light_theme/icon_add_team.svg create mode 100644 public/img/icons_light_theme/icon_add_user.svg create mode 100644 public/img/icons_light_theme/icon_add_variable.svg create mode 100644 public/img/icons_light_theme/icon_advanced.svg create mode 100644 public/img/icons_light_theme/icon_advanced_active.svg create mode 100644 public/img/icons_light_theme/icon_alert.svg create mode 100644 public/img/icons_light_theme/icon_alert_alt.svg create mode 100644 public/img/icons_light_theme/icon_alert_off.svg create mode 100644 public/img/icons_light_theme/icon_alert_rules.svg create mode 100644 public/img/icons_light_theme/icon_alerting.svg create mode 100644 public/img/icons_light_theme/icon_alerting_active.svg create mode 100644 public/img/icons_light_theme/icon_annotation.svg create mode 100644 public/img/icons_light_theme/icon_annotation_alt.svg create mode 100644 public/img/icons_light_theme/icon_apikeys.svg create mode 100644 public/img/icons_light_theme/icon_arrow_left.svg create mode 100644 public/img/icons_light_theme/icon_arrow_left_circle.svg create mode 100644 public/img/icons_light_theme/icon_cog.svg create mode 100644 public/img/icons_light_theme/icon_dashboard.svg create mode 100644 public/img/icons_light_theme/icon_dashboard_fav.svg create mode 100644 public/img/icons_light_theme/icon_dashboard_list.svg create mode 100644 public/img/icons_light_theme/icon_data_sources.svg create mode 100644 public/img/icons_light_theme/icon_editor.svg create mode 100644 public/img/icons_light_theme/icon_explore.svg create mode 100644 public/img/icons_light_theme/icon_home.svg create mode 100644 public/img/icons_light_theme/icon_import_dashboard.svg create mode 100644 public/img/icons_light_theme/icon_json.svg create mode 100644 public/img/icons_light_theme/icon_link.svg create mode 100644 public/img/icons_light_theme/icon_new_dashboard.svg create mode 100644 public/img/icons_light_theme/icon_notification_channels.svg create mode 100644 public/img/icons_light_theme/icon_org.svg create mode 100644 public/img/icons_light_theme/icon_playlist.svg create mode 100644 public/img/icons_light_theme/icon_plugins.svg create mode 100644 public/img/icons_light_theme/icon_preferences.svg create mode 100644 public/img/icons_light_theme/icon_query.svg create mode 100644 public/img/icons_light_theme/icon_query_active.svg create mode 100644 public/img/icons_light_theme/icon_question.svg create mode 100644 public/img/icons_light_theme/icon_remove.svg create mode 100644 public/img/icons_light_theme/icon_reports.svg create mode 100644 public/img/icons_light_theme/icon_shield.svg create mode 100644 public/img/icons_light_theme/icon_sitemap.svg create mode 100644 public/img/icons_light_theme/icon_snapshots.svg create mode 100644 public/img/icons_light_theme/icon_team.svg create mode 100644 public/img/icons_light_theme/icon_user.svg create mode 100644 public/img/icons_light_theme/icon_variable.svg create mode 100644 public/img/icons_light_theme/icon_viewer.svg create mode 100644 public/img/icons_light_theme/icon_visualize.svg create mode 100644 public/img/icons_light_theme/icon_visualize_active.svg create mode 100644 public/img/icons_light_theme/icon_zoom_out.svg create mode 100644 public/img/licensing/checkmark.svg create mode 100644 public/img/licensing/customer_support.svg create mode 100644 public/img/licensing/handinhand_support.svg create mode 100644 public/img/licensing/header_dark.svg create mode 100644 public/img/licensing/header_light.svg create mode 100644 public/img/licensing/plugin_enterprise.svg create mode 100644 public/img/licensing/sla.svg create mode 100644 public/img/light.png create mode 100644 public/img/load.gif create mode 100644 public/img/load_big.gif create mode 100644 public/img/loading-pulse.svg create mode 100644 public/img/login_background_dark.svg create mode 100644 public/img/login_background_light.svg create mode 100644 public/img/logo_transparent_200x.png create mode 100644 public/img/logo_transparent_200x75.png create mode 100644 public/img/logo_transparent_400x.png create mode 100644 public/img/microsoft_auth_icon.svg create mode 100644 public/img/mixed_styles.png create mode 100644 public/img/mstile-150x150.png create mode 100644 public/img/okta_logo_white.png create mode 100644 public/img/online.svg create mode 100644 public/img/page_header_line.png create mode 100644 public/img/panel-tabs/alert-selected.svg create mode 100644 public/img/panel-tabs/alert.svg create mode 100644 public/img/panel-tabs/general-selected.svg create mode 100644 public/img/panel-tabs/general.svg create mode 100644 public/img/panel-tabs/queries-selected.svg create mode 100644 public/img/panel-tabs/queries.svg create mode 100644 public/img/panel-tabs/visualization-selected.svg create mode 100644 public/img/panel-tabs/visualization.svg create mode 100644 public/img/plugin-default-logo_dark.svg create mode 100644 public/img/plugin-default-logo_light.svg create mode 100644 public/img/plugins/appdynamics.svg create mode 100644 public/img/plugins/datadog.png create mode 100644 public/img/plugins/dynatrace.png create mode 100644 public/img/plugins/mongodb.svg create mode 100644 public/img/plugins/newrelic.svg create mode 100644 public/img/plugins/oracle.png create mode 100644 public/img/plugins/servicenow.svg create mode 100644 public/img/plugins/snowflake.svg create mode 100644 public/img/plugins/splunk_logo_128.png create mode 100644 public/img/plugins/wavefront.svg create mode 100644 public/img/rendering_error.png create mode 100644 public/img/rendering_limit.png create mode 100644 public/img/rendering_plugin_not_installed.png create mode 100644 public/img/rendering_timeout.png create mode 100644 public/img/transparent.png create mode 100644 public/img/user_profile.png create mode 100644 public/img/warn-tiny.svg create mode 100644 public/img/warn.svg create mode 100644 public/robots.txt create mode 100644 public/sass/.sass-lint.yml create mode 100644 public/sass/_grafana.scss create mode 100644 public/sass/_old_responsive.scss create mode 100644 public/sass/_variables.dark.generated.scss create mode 100644 public/sass/_variables.generated.scss create mode 100644 public/sass/_variables.light.generated.scss create mode 100644 public/sass/base/_code.scss create mode 100644 public/sass/base/_font_awesome.scss create mode 100644 public/sass/base/_fonts.scss create mode 100644 public/sass/base/_forms.scss create mode 100644 public/sass/base/_grafana_icons.scss create mode 100644 public/sass/base/_grid.scss create mode 100644 public/sass/base/_icons.scss create mode 100644 public/sass/base/_normalize.scss create mode 100644 public/sass/base/_reboot.scss create mode 100644 public/sass/base/_type.scss create mode 100644 public/sass/base/font-awesome/_animated.scss create mode 100644 public/sass/base/font-awesome/_bordered-pulled.scss create mode 100644 public/sass/base/font-awesome/_core.scss create mode 100644 public/sass/base/font-awesome/_fixed-width.scss create mode 100644 public/sass/base/font-awesome/_icons.scss create mode 100644 public/sass/base/font-awesome/_larger.scss create mode 100644 public/sass/base/font-awesome/_list.scss create mode 100644 public/sass/base/font-awesome/_mixins.scss create mode 100644 public/sass/base/font-awesome/_path.scss create mode 100644 public/sass/base/font-awesome/_rotated-flipped.scss create mode 100644 public/sass/base/font-awesome/_screen-reader.scss create mode 100644 public/sass/base/font-awesome/_stacked.scss create mode 100644 public/sass/base/font-awesome/_variables.scss create mode 100644 public/sass/components/_add_data_source.scss create mode 100644 public/sass/components/_alerts.scss create mode 100644 public/sass/components/_buttons.scss create mode 100644 public/sass/components/_cards.scss create mode 100644 public/sass/components/_code_editor.scss create mode 100644 public/sass/components/_dashboard_grid.scss create mode 100644 public/sass/components/_dashboard_list.scss create mode 100644 public/sass/components/_dashboard_settings.scss create mode 100644 public/sass/components/_drop.scss create mode 100644 public/sass/components/_dropdown.scss create mode 100644 public/sass/components/_filter-controls.scss create mode 100644 public/sass/components/_filter-list.scss create mode 100644 public/sass/components/_filter-table.scss create mode 100644 public/sass/components/_footer.scss create mode 100644 public/sass/components/_gf-form.scss create mode 100644 public/sass/components/_infobox.scss create mode 100644 public/sass/components/_json_explorer.scss create mode 100644 public/sass/components/_jsontree.scss create mode 100644 public/sass/components/_modals.scss create mode 100644 public/sass/components/_navbar.scss create mode 100644 public/sass/components/_navs.scss create mode 100644 public/sass/components/_old_stuff.scss create mode 100644 public/sass/components/_page_header.scss create mode 100644 public/sass/components/_page_loader.scss create mode 100644 public/sass/components/_panel_alertlist.scss create mode 100644 public/sass/components/_panel_dashlist.scss create mode 100644 public/sass/components/_panel_editor.scss create mode 100644 public/sass/components/_panel_gettingstarted.scss create mode 100644 public/sass/components/_panel_graph.scss create mode 100644 public/sass/components/_panel_header.scss create mode 100644 public/sass/components/_panel_heatmap.scss create mode 100644 public/sass/components/_panel_piechart.scss create mode 100644 public/sass/components/_panel_pluginlist.scss create mode 100644 public/sass/components/_panel_singlestat.scss create mode 100644 public/sass/components/_panel_table.scss create mode 100644 public/sass/components/_panel_text.scss create mode 100644 public/sass/components/_popover-box.scss create mode 100644 public/sass/components/_query_editor.scss create mode 100644 public/sass/components/_query_part.scss create mode 100644 public/sass/components/_row.scss create mode 100644 public/sass/components/_scrollbar.scss create mode 100644 public/sass/components/_search.scss create mode 100644 public/sass/components/_shortcuts.scss create mode 100644 public/sass/components/_sidemenu.scss create mode 100644 public/sass/components/_slate_editor.scss create mode 100644 public/sass/components/_submenu.scss create mode 100644 public/sass/components/_switch.scss create mode 100644 public/sass/components/_tabbed_view.scss create mode 100644 public/sass/components/_tables_lists.scss create mode 100644 public/sass/components/_tabs.scss create mode 100644 public/sass/components/_tags.scss create mode 100644 public/sass/components/_tagsinput.scss create mode 100644 public/sass/components/_toggle_button_group.scss create mode 100644 public/sass/components/_toolbar.scss create mode 100644 public/sass/components/_tooltip.scss create mode 100644 public/sass/components/_typeahead.scss create mode 100644 public/sass/components/_view_states.scss create mode 100644 public/sass/components/edit_sidemenu.scss create mode 100644 public/sass/fonts.scss create mode 100644 public/sass/grafana.dark.scss create mode 100644 public/sass/grafana.light.scss create mode 100644 public/sass/icons.json create mode 100644 public/sass/layout/_lists.scss create mode 100644 public/sass/layout/_page.scss create mode 100644 public/sass/mixins/_animations.scss create mode 100644 public/sass/mixins/_breakpoints.scss create mode 100644 public/sass/mixins/_buttons.scss create mode 100644 public/sass/mixins/_drop_element.scss create mode 100644 public/sass/mixins/_forms.scss create mode 100644 public/sass/mixins/_grid-framework.scss create mode 100644 public/sass/mixins/_grid.scss create mode 100644 public/sass/mixins/_hover.scss create mode 100644 public/sass/mixins/_mixins.scss create mode 100644 public/sass/pages/_admin.scss create mode 100644 public/sass/pages/_alerting.scss create mode 100644 public/sass/pages/_dashboard.scss create mode 100644 public/sass/pages/_errorpage.scss create mode 100644 public/sass/pages/_explore.scss create mode 100644 public/sass/pages/_history.scss create mode 100644 public/sass/pages/_login.scss create mode 100644 public/sass/pages/_playlist.scss create mode 100644 public/sass/pages/_plugins.scss create mode 100644 public/sass/pages/_signup.scss create mode 100644 public/sass/utils/_angular.scss create mode 100644 public/sass/utils/_flex.scss create mode 100644 public/sass/utils/_hacks.scss create mode 100644 public/sass/utils/_spacings.scss create mode 100644 public/sass/utils/_utils.scss create mode 100644 public/sass/utils/_validation.scss create mode 100644 public/sass/utils/_widths.scss create mode 100644 public/test/.jshintrc create mode 100644 public/test/core/redux/mocks.ts create mode 100644 public/test/core/redux/reducerTester.test.ts create mode 100644 public/test/core/redux/reducerTester.ts create mode 100644 public/test/core/redux/reduxTester.ts create mode 100644 public/test/core/thunk/thunkTester.ts create mode 100644 public/test/core/utils/silenceConsoleOutput.ts create mode 100644 public/test/core/utils/version.test.ts create mode 100644 public/test/helpers/convertToStoreState.ts create mode 100644 public/test/helpers/createFetchResponse.ts create mode 100644 public/test/helpers/fieldConfig.ts create mode 100644 public/test/helpers/getDashboardModel.ts create mode 100644 public/test/helpers/getQueryOptions.ts create mode 100644 public/test/helpers/getTemplateSrvDependencies.ts create mode 100644 public/test/helpers/initTemplateSrv.ts create mode 100644 public/test/jest-setup.ts create mode 100644 public/test/jest-shim.ts create mode 100644 public/test/lib/common.ts create mode 100644 public/test/matchers/index.ts create mode 100644 public/test/matchers/toEmitValues.test.ts create mode 100644 public/test/matchers/toEmitValues.ts create mode 100644 public/test/matchers/toEmitValuesWith.test.ts create mode 100644 public/test/matchers/toEmitValuesWith.ts create mode 100644 public/test/matchers/types.ts create mode 100644 public/test/matchers/utils.ts create mode 100644 public/test/mocks/angular.ts create mode 100644 public/test/mocks/backend_srv.ts create mode 100644 public/test/mocks/common.ts create mode 100644 public/test/mocks/dashboard-mock.js create mode 100644 public/test/mocks/datasource_srv.ts create mode 100644 public/test/mocks/monaco.ts create mode 100644 public/test/mocks/style.ts create mode 100644 public/test/mocks/svg.ts create mode 100644 public/test/setupTests.ts create mode 100644 public/test/specs/helpers.ts create mode 100644 public/views/error-template.html create mode 100644 public/views/index-template.html create mode 100755 scripts/build/build.sh create mode 100644 scripts/build/ci-build-windows/Dockerfile create mode 100644 scripts/build/ci-build-windows/Magefile.go create mode 100644 scripts/build/ci-build-windows/README.md create mode 100644 scripts/build/ci-build/.gitignore create mode 100644 scripts/build/ci-build/Dockerfile create mode 100644 scripts/build/ci-build/Makefile create mode 100644 scripts/build/ci-build/README.md create mode 100755 scripts/build/ci-build/bootstrap.sh create mode 100755 scripts/build/ci-build/build-deploy.sh create mode 100644 scripts/build/ci-deploy/Dockerfile create mode 100755 scripts/build/ci-deploy/build-deploy.sh create mode 100644 scripts/build/ci-e2e/Dockerfile create mode 100644 scripts/build/ci-msi-build/Dockerfile create mode 100644 scripts/build/ci-msi-build/README.md create mode 100755 scripts/build/ci-msi-build/ci-msi-build-ee.sh create mode 100755 scripts/build/ci-msi-build/ci-msi-build-oss.sh create mode 100644 scripts/build/ci-msi-build/msigenerator/Makefile create mode 100755 scripts/build/ci-msi-build/msigenerator/cache/nssm-2.24.zip create mode 100755 scripts/build/ci-msi-build/msigenerator/ci-wrapper.sh create mode 100644 scripts/build/ci-msi-build/msigenerator/docker-compose.yml create mode 100755 scripts/build/ci-msi-build/msigenerator/generator/build.py create mode 100644 scripts/build/ci-msi-build/msigenerator/generator/utils.py create mode 100644 scripts/build/ci-msi-build/msigenerator/light.exe.config create mode 100644 scripts/build/ci-msi-build/msigenerator/requirements.txt create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_dialog_background.bmp create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_dialog_background.png create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_icon.ico create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_top_banner.bmp create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_top_banner.png create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/images/grafana_top_banner_white.bmp create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/license/EE_LICENSE.rtf create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/license/LICENSE.md create mode 100644 scripts/build/ci-msi-build/msigenerator/resources/license/LICENSE.rtf create mode 100644 scripts/build/ci-msi-build/msigenerator/templates/common/grafana-firewall.wxs.j2 create mode 100644 scripts/build/ci-msi-build/msigenerator/templates/common/grafana-service.wxs.j2 create mode 100644 scripts/build/ci-msi-build/msigenerator/templates/common/product.wxs.j2 create mode 100644 scripts/build/ci-msi-build/msigenerator/templates/oracle/oracle-environment.wxs.j2 create mode 100755 scripts/build/ci-msi-build/msigenerator/wrapper.sh create mode 100755 scripts/build/ci-msi-build/testbuild.sh create mode 100644 scripts/build/ci-wix/Dockerfile create mode 100644 scripts/build/ci-wix/Magefile.go create mode 100644 scripts/build/ci-wix/README.md create mode 100644 scripts/build/gpg-test-vars.sh create mode 100644 scripts/build/old_appveyor.yml create mode 100755 scripts/build/prepare_signing_key.sh create mode 100755 scripts/build/publish.sh create mode 100755 scripts/build/release-packages.sh create mode 100644 scripts/build/release_publisher/externalrelease.go create mode 100644 scripts/build/release_publisher/main.go create mode 100644 scripts/build/release_publisher/publisher.go create mode 100644 scripts/build/release_publisher/publisher_test.go create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.linux-amd64.tar.gz.sha256 create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.msi.sha256 create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.windows-amd64.zip.sha256 create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise-5.4.0-123pre1.x86_64.rpm.sha256 create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb create mode 100644 scripts/build/release_publisher/testdata/grafana-enterprise_5.4.0-123pre1_amd64.deb.sha256 create mode 100644 scripts/build/rpmmacros create mode 100755 scripts/build/sign_packages.sh create mode 100644 scripts/build/update_repo/README.md create mode 100644 scripts/build/update_repo/aptly.conf create mode 100755 scripts/build/update_repo/init-deb-repo.sh create mode 100755 scripts/build/update_repo/load-signing-key.sh create mode 100755 scripts/build/update_repo/publish-deb.sh create mode 100755 scripts/build/update_repo/publish-rpm.sh create mode 100755 scripts/build/update_repo/sign-rpm-repo.sh create mode 100755 scripts/build/update_repo/test-publish-deb-repo.sh create mode 100755 scripts/build/update_repo/test-publish-rpm-repo.sh create mode 100755 scripts/build/update_repo/test-update-deb-repo.sh create mode 100755 scripts/build/update_repo/test-update-rpm-repo.sh create mode 100755 scripts/build/update_repo/unlock-gpg-key.sh create mode 100755 scripts/build/update_repo/update-deb.sh create mode 100755 scripts/build/update_repo/update-rpm.sh create mode 100755 scripts/build/verify_signed_packages.sh create mode 100755 scripts/ci-check-strict.sh create mode 100755 scripts/ci-frontend-metrics.sh create mode 100755 scripts/ci-job-failed.sh create mode 100755 scripts/ci-job-started.sh create mode 100755 scripts/ci-job-succeeded.sh create mode 100755 scripts/ci-reference-docs-build.sh create mode 100755 scripts/ci-reference-docs-lint.sh create mode 100755 scripts/circle-release-canary-packages.sh create mode 100755 scripts/circle-test-backend.sh create mode 100755 scripts/circle-test-cache-servers.sh create mode 100755 scripts/circle-test-frontend.sh create mode 100755 scripts/circle-test-mysql.sh create mode 100755 scripts/circle-test-postgres.sh create mode 100644 scripts/cli/generateSassVariableFiles.ts create mode 100644 scripts/cli/tsconfig.json create mode 100644 scripts/go/configs/.golangci.toml create mode 100644 scripts/go/configs/revive-strict.toml create mode 100644 scripts/go/configs/revive.toml create mode 100644 scripts/go/go.mod create mode 100644 scripts/go/go.sum create mode 100644 scripts/go/tools.go create mode 100644 scripts/helpers/exit-if-fail.sh create mode 100755 scripts/import_many_dashboards.sh create mode 100644 scripts/lib.star create mode 100644 scripts/master.star create mode 100755 scripts/mixin-check.sh create mode 100644 scripts/pr.star create mode 100755 scripts/protobuf-check.sh create mode 100644 scripts/release.star create mode 100755 scripts/revive-strict create mode 100755 scripts/tag_release.sh create mode 100755 scripts/trigger_docker_build.sh create mode 100755 scripts/trigger_grafana_packer.sh create mode 100755 scripts/trigger_windows_build.sh create mode 100644 scripts/verify-repo-update/Dockerfile.deb create mode 100644 scripts/verify-repo-update/Dockerfile.rpm create mode 100644 scripts/verify-repo-update/deb-ee-beta.list create mode 100644 scripts/verify-repo-update/deb-ee-stable.list create mode 100644 scripts/verify-repo-update/deb-oss-beta.list create mode 100644 scripts/verify-repo-update/deb-oss-stable.list create mode 100644 scripts/verify-repo-update/rpm-ee-beta.list create mode 100644 scripts/verify-repo-update/rpm-ee-stable.list create mode 100644 scripts/verify-repo-update/rpm-oss-beta.list create mode 100644 scripts/verify-repo-update/rpm-oss-stable.list create mode 100755 scripts/verify-repo-update/verify-repos.sh create mode 100644 scripts/version.star create mode 100644 scripts/webpack/dependencies.js create mode 100644 scripts/webpack/loaders/blobUrl.js create mode 100644 scripts/webpack/loaders/compile.js create mode 100644 scripts/webpack/postcss.config.js create mode 100644 scripts/webpack/sass.rule.js create mode 100644 scripts/webpack/webpack.common.js create mode 100644 scripts/webpack/webpack.dev.js create mode 100644 scripts/webpack/webpack.hot.js create mode 100644 scripts/webpack/webpack.prod.js create mode 100644 tools/.gitkeep create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..0fd6e61 --- /dev/null +++ b/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "browsers": "last 3 versions" + }, + "useBuiltIns": "entry", + "modules": "false", + } + ] + ] +} diff --git a/.bra.toml b/.bra.toml new file mode 100644 index 0000000..2e222e4 --- /dev/null +++ b/.bra.toml @@ -0,0 +1,19 @@ +[run] +init_cmds = [ + ["go", "run", "build.go", "-dev", "build-cli"], + ["go", "run", "build.go", "-dev", "build-server"], + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] +] +watch_all = true +follow_symlinks = true +watch_dirs = [ + "$WORKDIR/pkg", + "$WORKDIR/public/views", + "$WORKDIR/conf", +] +watch_exts = [".go", ".ini", ".toml", ".template.html"] +build_delay = 1500 +cmds = [ + ["go", "run", "build.go", "-dev", "build-server"], + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] +] diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..4220fec --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,4 @@ +>1%, +Chrome > 20 +last 4 versions, +Firefox ESR diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..e85e67e --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,60 @@ +version: 2.1 + +aliases: + # Workflow filters + - &filter-only-master + branches: + only: master + +jobs: + scan-docker-image: + description: "Scans a docker image for vulnerabilities using trivy" + parameters: + image: + type: string + tag: + type: string + docker: + - image: circleci/buildpack-deps:stretch + steps: + - setup_remote_docker + - restore_cache: + key: vulnerability-db + - run: + name: Install trivy + command: | + VERSION=$( + curl --silent "https://api.github.com/repos/aquasecurity/trivy/releases/latest" | \ + grep '"tag_name":' | \ + sed -E 's/.*"v([^"]+)".*/\1/' + ) + + wget https://github.com/aquasecurity/trivy/releases/download/v${VERSION}/trivy_${VERSION}_Linux-64bit.tar.gz + tar zxvf trivy_${VERSION}_Linux-64bit.tar.gz + sudo mv trivy /usr/local/bin + - run: + name: Clear trivy cache + command: trivy --clear-cache + - run: + name: Scan Docker image for unkown/low/medium vulnerabilities + command: trivy --exit-code 0 --severity UNKNOWN,LOW,MEDIUM << parameters.image >>:<< parameters.tag >> + - run: + name: Scan Docker image for high/critical vulnerabilities + command: trivy --exit-code 1 --severity HIGH,CRITICAL << parameters.image >>:<< parameters.tag >> + - save_cache: + key: vulnerability-db + paths: + - $HOME/.cache/trivy + +workflows: + nightly: + triggers: + - schedule: + cron: "0 0 * * *" + filters: *filter-only-master + jobs: + - scan-docker-image: + matrix: + parameters: + image: [grafana/grafana, grafana/grafana-enterprise] + tag: [latest, master, latest-ubuntu, master-ubuntu] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c535fa4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.awcache +.dockerignore +.git +.gitignore +.github +.vscode +bin +data* +dist +docker +Dockerfile +docs +dump.rdb +node_modules +/local +/tmp +*.yml +*.md diff --git a/.drone.star b/.drone.star new file mode 100644 index 0000000..8a87614 --- /dev/null +++ b/.drone.star @@ -0,0 +1,9 @@ +load('scripts/pr.star', 'pr_pipelines') +load('scripts/master.star', 'master_pipelines') +load('scripts/release.star', 'release_pipelines', 'test_release_pipelines') +load('scripts/version.star', 'version_branch_pipelines') + +def main(ctx): + edition = 'oss' + return pr_pipelines(edition=edition) + master_pipelines(edition=edition) + release_pipelines() + \ + test_release_pipelines() + version_branch_pipelines() diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..c024f2a --- /dev/null +++ b/.drone.yml @@ -0,0 +1,3328 @@ +--- +kind: pipeline +type: docker +name: test-pr + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --variants linux-x64,linux-x64-musl,osx64,win64 --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps + depends_on: + - initialize + - lint-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + +- name: package + image: grafana/build-container:1.4.1 + commands: + - . scripts/build/gpg-test-vars.sh && ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-x64,linux-x64-musl,osx64,win64 + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: build-frontend-docs + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + +- name: build-docs-website + image: grafana/docs-base:latest + commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + depends_on: + - initialize + - build-frontend-docs + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + archs: amd64 + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + event: + - pull_request + +--- +kind: pipeline +type: docker +name: build-master + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: trigger-enterprise-downstream + image: grafana/drone-downstream + settings: + params: + - SOURCE_BUILD_NUMBER=${DRONE_BUILD_NUMBER} + - SOURCE_COMMIT=${DRONE_COMMIT} + repositories: + - grafana/grafana-enterprise + server: https://drone.grafana.net + token: + from_secret: drone_token + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: publish-frontend-metrics + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-frontend-metrics.sh | ./bin/grabpl publish-metrics $${GRAFANA_MISC_STATS_API_KEY} + environment: + GRAFANA_MISC_STATS_API_KEY: + from_secret: grafana_misc_stats_api_key + failure: ignore + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/canary + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: build-frontend-docs + image: grafana/build-container:1.4.1 + commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: release-canary-npm-packages + image: grafana/build-container:1.4.1 + commands: + - ./scripts/circle-release-canary-packages.sh + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + depends_on: + - end-to-end-tests + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + branch: + - master + event: + - push + +--- +kind: pipeline +type: docker +name: windows-master + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss --build-id $$env:DRONE_BUILD_NUMBER + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/oss/master/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/master/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + branch: + - master + event: + - push + +depends_on: +- build-master + +--- +kind: pipeline +type: docker +name: publish-master + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + branch: + - master + event: + - push + +depends_on: +- build-master +- windows-master + +--- +kind: pipeline +type: docker +name: notify-master + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + branch: + - master + event: + - push + status: + - failure + +depends_on: +- build-master +- windows-master +- publish-master + +--- +kind: pipeline +type: docker +name: oss-build-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: oss + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/latest + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://grafana-storybook/${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: release-npm-packages + image: grafana/build-container:1.4.1 + commands: + - ./scripts/build/release-packages.sh ${DRONE_TAG} + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + NPM_TOKEN: + from_secret: npm_token + depends_on: + - publish-storybook + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/tags/v* + +--- +kind: pipeline +type: docker +name: oss-windows-release + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/oss/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/oss/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/tags/v* + +depends_on: +- oss-build-release + +--- +kind: pipeline +type: docker +name: enterprise-build-release + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise ${DRONE_TAG} + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise ${DRONE_TAG} + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + - lint-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version ${DRONE_TAG} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: enterprise + password: + from_secret: docker_password + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + edition: enterprise + password: + from_secret: docker_password + ubuntu: true + username: + from_secret: docker_user + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/tags/v* + +--- +kind: pipeline +type: docker +name: enterprise-windows-release + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition enterprise ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads/enterprise/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads/enterprise/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/tags/v* + +depends_on: +- enterprise-build-release + +--- +kind: pipeline +type: docker +name: publish-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version ${DRONE_TAG} + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json ${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +- name: publish-packages-enterprise + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json ${DRONE_TAG} + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + ref: + - refs/tags/v* + +depends_on: +- oss-build-release +- oss-windows-release +- enterprise-build-release +- enterprise-windows-release + +--- +kind: pipeline +type: docker +name: notify-release + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + ref: + - refs/tags/v* + status: + - failure + +depends_on: +- oss-build-release +- oss-windows-release +- enterprise-build-release +- enterprise-windows-release +- publish-release + +--- +kind: pipeline +type: docker +name: oss-build-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition oss --no-pull-enterprise v7.3.0-test + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version v7.3.0-test + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: publish-storybook + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - echo Testing release + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - build-storybook + - end-to-end-tests + +- name: release-npm-packages + image: grafana/build-container:1.4.1 + environment: + GITHUB_PACKAGE_TOKEN: + from_secret: github_package_token + NPM_TOKEN: + from_secret: npm_token + depends_on: + - publish-storybook + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + event: + - custom + +--- +kind: pipeline +type: docker +name: oss-windows-test-release + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition oss --packages-bucket grafana-downloads-test v7.3.0-test + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads-test/oss/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads-test/oss/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + event: + - custom + +depends_on: +- oss-build-test-release + +--- +kind: pipeline +type: docker +name: enterprise-build-test-release + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout master + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps --edition enterprise --no-pull-enterprise v7.3.0-test + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + - lint-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version v7.3.0-test + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} --no-pull-enterprise --sign v7.3.0-test + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-test + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + event: + - custom + +--- +kind: pipeline +type: docker +name: enterprise-windows-test-release + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout master + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + - .\grabpl.exe windows-installer --edition enterprise --packages-bucket grafana-downloads-test v7.3.0-test + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://grafana-downloads-test/enterprise/release/ + - gsutil cp "$$fname.sha256" gs://grafana-downloads-test/enterprise/release/ + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + event: + - custom + +depends_on: +- enterprise-build-test-release + +--- +kind: pipeline +type: docker +name: publish-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - ./bin/grabpl verify-version v7.3.0-test + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: publish-packages-oss + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +- name: publish-packages-enterprise + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json + - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test + environment: + GCP_KEY: + from_secret: gcp_key + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_COM_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + +trigger: + event: + - custom + +depends_on: +- oss-build-test-release +- oss-windows-test-release +- enterprise-build-test-release +- enterprise-windows-test-release + +--- +kind: pipeline +type: docker +name: notify-test-release + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + event: + - custom + status: + - failure + +depends_on: +- oss-build-test-release +- oss-windows-test-release +- enterprise-build-test-release +- enterprise-windows-test-release +- publish-test-release + +--- +kind: pipeline +type: docker +name: oss-build-release-branch + +platform: + os: linux + arch: amd64 + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition oss + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition oss + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PORT: 3001 + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: oss + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/heads/v* + +--- +kind: pipeline +type: docker +name: oss-windows-release-branch + +platform: + os: windows + arch: amd64 + version: 1809 + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - .\grabpl.exe verify-drone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/heads/v* + +depends_on: +- oss-build-release-branch + +--- +kind: pipeline +type: docker +name: enterprise-build-release-branch + +platform: + os: linux + arch: amd64 + +clone: + disable: true + +steps: +- name: identify-runner + image: alpine:3.13 + commands: + - echo $DRONE_RUNNER_NAME + +- name: clone + image: grafana/build-container:1.4.1 + commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/grabpl + - chmod +x bin/grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_BRANCH} + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/build-container:1.4.1 + commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mkdir bin + - mv /tmp/grabpl bin/ + - ./bin/grabpl verify-drone + - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - rm dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz + - yarn install --frozen-lockfile --no-progress + environment: + DOCKERIZE_VERSION: 0.6.1 + depends_on: + - clone + +- name: lint-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: codespell + image: grafana/build-container:1.4.1 + commands: + - "echo -e \"unknwon\nreferer\nerrorstring\neror\niam\nwan\" > words_to_ignore.txt" + - codespell -I words_to_ignore.txt docs/ + depends_on: + - initialize + +- name: shellcheck + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + +- name: check-dashboard-schemas + image: grafana/build-container:1.4.1 + commands: + - cue export --out openapi -o - ./dashboard-schemas/... + depends_on: + - initialize + +- name: test-backend + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + - lint-backend + +- name: test-frontend + image: grafana/build-container:1.4.1 + commands: + - yarn run ci:test-frontend + environment: + TEST_MAX_WORKERS: 50% + depends_on: + - initialize + +- name: build-backend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - lint-backend + - test-backend + +- name: build-frontend + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + - test-frontend + +- name: build-plugins + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign --signing-admin + environment: + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - initialize + - lint-backend + +- name: lint-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl lint-backend --edition enterprise2 + environment: + CGO_ENABLED: 1 + depends_on: + - initialize + +- name: test-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" + - ./bin/grabpl test-backend --edition enterprise2 + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + - lint-backend-enterprise2 + +- name: build-backend-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --variants linux-x64 --no-pull-enterprise + depends_on: + - initialize + - lint-backend-enterprise2 + - test-backend-enterprise2 + +- name: gen-version + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - build-backend + - build-frontend + - build-plugins + - test-backend + - test-frontend + - codespell + - shellcheck + - check-dashboard-schemas + - build-backend-enterprise2 + - test-backend-enterprise2 + +- name: package + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: end-to-end-tests-server + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz + PORT: 3001 + RUNDIR: e2e/tmp-grafana-enterprise + depends_on: + - package + +- name: end-to-end-tests + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3001 --tries 3 + environment: + HOST: end-to-end-tests-server + depends_on: + - end-to-end-tests-server + +- name: build-storybook + image: grafana/build-container:1.4.1 + commands: + - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 + depends_on: + - package + +- name: copy-packages-for-docker + image: grafana/build-container:1.4.1 + commands: + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ + depends_on: + - package + +- name: build-docker-images + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + depends_on: + - copy-packages-for-docker + +- name: build-docker-images-ubuntu + image: grafana/drone-grafana-docker:0.3.2 + settings: + dry_run: true + edition: enterprise + ubuntu: true + depends_on: + - copy-packages-for-docker + +- name: postgres-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq postgresql-client + - ./bin/dockerize -wait tcp://postgres:5432 -timeout 120s + - psql -p 5432 -h postgres -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql + - go clean -testcache + - ./bin/grabpl integration-tests --database postgres + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + depends_on: + - test-backend + - test-frontend + +- name: mysql-integration-tests + image: grafana/build-container:1.4.1 + commands: + - apt-get update + - apt-get install -yq default-mysql-client + - ./bin/dockerize -wait tcp://mysql:3306 -timeout 120s + - cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h mysql -P 3306 -u root -prootpass + - go clean -testcache + - ./bin/grabpl integration-tests --database mysql + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + depends_on: + - test-backend + - test-frontend + +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - end-to-end-tests + - mysql-integration-tests + - postgres-integration-tests + +- name: package-enterprise2 + image: grafana/build-container:1.4.1 + commands: + - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise --variants linux-x64 --sign + environment: + GITHUB_TOKEN: + from_secret: github_token + GPG_KEY_PASSWORD: + from_secret: gpg_key_password + GPG_PRIV_KEY: + from_secret: gpg_priv_key + GPG_PUB_KEY: + from_secret: gpg_pub_key + GRAFANA_API_KEY: + from_secret: grafana_api_key + depends_on: + - gen-version + +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-server-enterprise2 + image: grafana/build-container:1.4.1 + detach: true + commands: + - ./e2e/start-server + environment: + PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz + PORT: 3002 + RUNDIR: e2e/tmp-grafana-enterprise2 + depends_on: + - package-enterprise2 + +- name: end-to-end-tests-enterprise2 + image: grafana/ci-e2e:12.19.0-1 + commands: + - ./node_modules/.bin/cypress install + - ./bin/grabpl e2e-tests --port 3002 --tries 3 + environment: + HOST: end-to-end-tests-server-enterprise2 + depends_on: + - end-to-end-tests-server-enterprise2 + +- name: upload-packages-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - end-to-end-tests-enterprise2 + - mysql-integration-tests + - postgres-integration-tests + +services: +- name: postgres + image: postgres:12.3-alpine + environment: + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + +- name: mysql + image: mysql:5.6.48 + environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + +trigger: + ref: + - refs/heads/v* + +--- +kind: pipeline +type: docker +name: enterprise-windows-release-branch + +platform: + os: windows + arch: amd64 + version: 1809 + +clone: + disable: true + +steps: +- name: identify-runner + image: mcr.microsoft.com/windows:1809 + commands: + - echo $env:DRONE_RUNNER_NAME + +- name: clone + image: grafana/ci-wix:0.1.1 + commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.43/windows/grabpl.exe -OutFile grabpl.exe + - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout $$env:DRONE_BRANCH + environment: + GITHUB_TOKEN: + from_secret: github_token + +- name: initialize + image: grafana/ci-wix:0.1.1 + commands: + - cp -r grafana-enterprise C:\App\grafana-enterprise + - rm -r -force grafana-enterprise + - cp grabpl.exe C:\App\grabpl.exe + - rm -force grabpl.exe + - C:\App\grabpl.exe init-enterprise C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe + - .\grabpl.exe verify-drone + depends_on: + - clone + +- name: build-windows-installer + image: grafana/ci-wix:0.1.1 + commands: + - $$gcpKey = $$env:GCP_KEY + - "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($$gcpKey)) > gcpkey.json" + - dos2unix gcpkey.json + - gcloud auth activate-service-account --key-file=gcpkey.json + - rm gcpkey.json + - cp C:\App\nssm-2.24.zip . + environment: + GCP_KEY: + from_secret: gcp_key + depends_on: + - initialize + +trigger: + ref: + - refs/heads/v* + +depends_on: +- enterprise-build-release-branch + +--- +kind: pipeline +type: docker +name: notify-release-branch + +platform: + os: linux + arch: amd64 + +steps: +- name: slack + image: plugins/slack + settings: + channel: grafana-ci-notifications + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" + webhook: + from_secret: slack_webhook + +trigger: + ref: + - refs/heads/v* + status: + - failure + +depends_on: +- oss-build-release-branch +- oss-windows-release-branch +- enterprise-build-release-branch +- enterprise-windows-release-branch + +... diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a8c0c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.go] +indent_style = tab +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js,ts,tsx,scss}] +quote_type = single + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab +indent_size = 2 + +[*.star] +indent_size = 4 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..82f41c2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules +compiled +build +vendor +devenv +data +dist +e2e/tmp diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..01ec2d6 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,23 @@ +{ + "extends": ["@grafana/eslint-config"], + "root": true, + "plugins": [ + "no-only-tests" + ], + "rules": { + "no-only-tests/no-only-tests": "error", + "react/prop-types": "off" + }, + "overrides": [ + { + "files": [ + "packages/grafana-ui/src/components/uPlot/**/*.{ts,tsx}", + "public/app/**/*.{ts,tsx}" + ], + "rules": { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ae2fcfc --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,76 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Documentation owner: Diana Payton +/docs/ @grafana/docs-squad +/contribute/ @marcusolsson @grafana/docs-squad +/docs/sources/developers/plugins/ @marcusolsson +/docs/sources/enterprise/ @osg-grafana + +# Backend code +*.go @grafana/backend-platform +go.mod @grafana/backend-platform +go.sum @grafana/backend-platform + +#Cloud Datasources backend code +/pkg/tsdb/cloudwatch @grafana/cloud-datasources +/pkg/tsdb/azuremonitor @grafana/cloud-datasources +/pkg/tsdb/cloudmonitoring @grafana/cloud-datasources + +# Database migrations +/pkg/services/sqlstore/migrations @grafana/backend-platform @grafana/hosted-grafana-team +*_mig.go @grafana/backend-platform @grafana/hosted-grafana-team + +# Backend code docs +/contribute/style-guides/backend.md @grafana/backend-platform + +/e2e @grafana/grafana-frontend-platform +/packages @grafana/grafana-frontend-platform +/plugins-bundled @grafana/grafana-frontend-platform +/public @grafana/grafana-frontend-platform +/scripts/build/release-packages.sh @grafana/grafana-frontend-platform +/scripts/circle-release-next-packages.sh @grafana/grafana-frontend-platform +/scripts/ci-frontend-metrics.sh @grafana/grafana-frontend-platform +/scripts/grunt @grafana/grafana-frontend-platform +/scripts/webpack @grafana/grafana-frontend-platform +package.json @grafana/grafana-frontend-platform +tsconfig.json @grafana/grafana-frontend-platform +lerna.json @grafana/grafana-frontend-platform +.babelrc @grafana/grafana-frontend-platform +.prettierrc.js @grafana/grafana-frontend-platform +.eslintrc @grafana/grafana-frontend-platform + +# @grafana/ui component documentation +*.mdx @marcusolsson @jessover9000 @grafana/grafana-frontend-platform + +/public/app/features/explore/ @grafana/observability-squad +/packages/jaeger-ui-components/ @grafana/observability-squad + +# Core datasources +/public/app/plugins/datasource/cloudwatch @grafana/cloud-datasources @grafana/observability-squad +/public/app/plugins/datasource/elasticsearch @grafana/observability-squad +/public/app/plugins/datasource/grafana-azure-monitor-datasource @grafana/cloud-datasources +/public/app/plugins/datasource/graphite @grafana/observability-squad +/public/app/plugins/datasource/influxdb @grafana/observability-squad +/public/app/plugins/datasource/jaeger @grafana/observability-squad +/public/app/plugins/datasource/loki @grafana/observability-squad +/public/app/plugins/datasource/mssql @grafana/backend-platform +/public/app/plugins/datasource/mysql @grafana/backend-platform +/public/app/plugins/datasource/opentsdb @grafana/backend-platform +/public/app/plugins/datasource/postgres @grafana/backend-platform +/public/app/plugins/datasource/prometheus @grafana/observability-squad +/public/app/plugins/datasource/cloud-monitoring @grafana/cloud-datasources +/public/app/plugins/datasource/zipkin @grafana/observability-squad + +# Cloud middleware +/grafana-mixin/ @grafana/cloud-middleware diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.md b/.github/ISSUE_TEMPLATE/1-bug_report.md new file mode 100644 index 0000000..9bec0f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Report a bug you found when using Grafana +labels: 'type: bug' +--- + + + +**What happened**: + +**What you expected to happen**: + +**How to reproduce it (as minimally and precisely as possible)**: + +**Anything else we need to know?**: + +**Environment**: +- Grafana version: +- Data source type & version: +- OS Grafana is installed on: +- User OS & Browser: +- Grafana plugins: +- Others: diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.md b/.github/ISSUE_TEMPLATE/2-feature_request.md new file mode 100644 index 0000000..ccb9f3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature_request.md @@ -0,0 +1,11 @@ +--- +name: Enhancement request +about: Suggest an enhancement or new feature for the Grafana project +labels: 'type: feature request' +--- + + + +**What would you like to be added**: + +**Why is this needed**: diff --git a/.github/ISSUE_TEMPLATE/3-accessibility.md b/.github/ISSUE_TEMPLATE/3-accessibility.md new file mode 100644 index 0000000..51b3ecc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-accessibility.md @@ -0,0 +1,26 @@ +--- +name: Accessibility issue +about: Help make Grafana be better at keyboard navigation, screen-readable and accessible to all. +labels: 'type: accessibility' +--- + + + +**Steps to reproduce**: + +**Actual Result**: + +**Expected Result** + +**Relevant WCAG Criteria:** [#.#.# WCAG Criterion](link to https://www.w3.org/WAI/WCAG21/quickref/?versions=2.0) + +**Environment**: +- Grafana version: +- Data source type & version: +- User OS & Browser: +- Others: diff --git a/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md b/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md new file mode 100644 index 0000000..3920ed7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-grafana_ui_component.md @@ -0,0 +1,39 @@ +--- +name: '@grafana/ui component request' +about: Suggest a component for the @grafana/ui package +labels: 'area/grafana/ui' +--- + + + +**Why is this component needed**: + +___ + - [ ] Is/could it be used in more than one place in Grafana? + +**Where is/could it be used?**: + +___ +- [ ] Post screenshots possible. +- [ ] It has a single use case. +- [ ] It is/could be used in multiple places. + +**Implementation** (Checklist meant for the person implementing the component) + +- [ ] Component has a story in Storybook. +- [ ] Props and naming follows [our style guide](https://github.com/grafana/grafana/blob/master/contribute/style-guides/frontend.md). +- [ ] It is extendable (rest props are spread, styles with className work, and so on). +- [ ] Uses [theme for spacing, colors, and so on](https://github.com/grafana/grafana/blob/master/contribute/style-guides/themes.md). +- [ ] Works with both light and dark theme. + +**Documentation** + +- [ ] Properties are documented. +- [ ] Use cases are described. +- [ ] Code examples for the different use cases. +- [ ] Dos and don'ts. +- [ ] Styling guidelines, specific color usage (if applicable). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8211d8e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Help + url: https://community.grafana.com + about: Please ask and answer questions here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ecbcbdb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ + + +**What this PR does / why we need it**: + +**Which issue(s) this PR fixes**: + + + +Fixes # + +**Special notes for your reviewer**: + diff --git a/.github/bot.md b/.github/bot.md new file mode 100644 index 0000000..7804f3b --- /dev/null +++ b/.github/bot.md @@ -0,0 +1,30 @@ +# GitHub & grafanabot automation + +The bot is configured via [commands.json](https://github.com/grafana/grafana/blob/master/.github/commands.json) and some other GitHub workflows [workflows](https://github.com/grafana/grafana/tree/master/.github/workflows). + +Comment commands: + +* Write the word `/duplicate #` anywhere in a comment and the bot will add the correct label and standard message. +* Write the word `/needsMoreInfo` anywhere in a comment and the bot will add the correct label and standard message. + +Label commands: + +* Add label `bot/question` the the bot will close with standard question message and add label `type/question` +* Add label `bot/duplicate` the the bot will close with standard duplicate message and add label `type/duplicate` +* Add label `bot/needs more info` for bot to request more info (or use comment command mentioned above) +* Add label `bot/close feature request` for bot to close a feature request with standard message and adds label `not implemented` +* Add label `bot/no new info` for bot to close an issue where we asked for more info but has not received any updates in at least 14 days. + +## Metrics + +Metrics are configured in [metrics-collector.json](https://github.com/grafana/grafana/blob/master/.github/metrics-collector.json) and are also defined in the +[metrics-collector](https://github.com/grafana/grafana-github-actions/blob/main/metrics-collector/index.ts) GitHub action. + +## Backport PR + +To automatically backport a PR to a release branch like v7.3.x add a label named `backport v7.3.x`. The label name should follow the pattern `backport `. Once merged grafanabot will automatically +try to cherry-pick the PR merge commit into that branch and open a PR. It will sync the milestone with the source PR so make sure the source PR also is assigned the milestone for the patch release. If the PR is already merged you can still add this label and trigger the backport automation. + +If there are merge conflicts the bot will write a comment on the source PR saying the cherry-pick failed. In this case you have to do the cherry pick and backport PR manually. + +The backport logic is written [here](https://github.com/grafana/grafana-github-actions/blob/main/backport/backport.ts) diff --git a/.github/commands.json b/.github/commands.json new file mode 100644 index 0000000..f07072c --- /dev/null +++ b/.github/commands.json @@ -0,0 +1,53 @@ +[ + { + "type": "label", + "name": "bot/question", + "addLabel": "type/question", + "removeLabel": "bot/question", + "action": "close", + "comment": "Please ask your question on [community.grafana.com/](https://community.grafana.com/). To avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "comment", + "name": "duplicate", + "allowUsers": [], + "action": "updateLabels", + "addLabel": "type/duplicate" + }, + { + "type": "label", + "name": "bot/duplicate", + "addLabel": "type/duplicate", + "removeLabel": "bot/duplicate", + "action": "close", + "comment": "Thanks for creating this issue! It looks like this has already been reported by another user. We’ve closed this in favor of the existing one. Please consider adding any details you think is missing to that issue.\n\nTo avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "comment", + "name": "needsMoreInfo", + "allowUsers": [], + "action": "updateLabels", + "addLabel": "bot/needs more info" + }, + { + "type": "label", + "name": "bot/needs more info", + "action": "updateLabels", + "addLabel": "needs more info", + "removeLabel": "bot/needs more info", + "comment": "Thanks for creating this issue! We think it's missing some basic information. \r\n\r\nFollow the issue template and add additional information that will help us replicate the problem. \r\nFor data visualization issues: \r\n- Query results from the inspect drawer (data tab & query inspector)\r\n- Panel settings can be extracted in the panel inspect drawer JSON tab\r\n\r\nFor dashboard related issues: \r\n- Dashboard JSON can be found in the dashboard settings JSON model view\r\n\r\nFor authentication, provisioning and alerting issues, Grafana server logs are useful. \r\n\r\nHappy graphing!" + }, + { + "type": "label", + "name": "bot/no new info", + "action": "close", + "comment": "We've closed this issue since it needs more information and hasn't had any activity recently. We can re-open it after you you add more information. To avoid having your issue closed in the future, please read our [CONTRIBUTING](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guidelines.\n\nHappy graphing!" + }, + { + "type": "label", + "name": "bot/close feature request", + "action": "close", + "addLabel": "not implemented", + "comment": "This feature request has been open for a long time with few received upvotes or comments, so we are closing it. We're trying to limit open GitHub issues in order to better track planned work and features. \r\n\r\nThis doesn't mean that we'll never ever implement it or that we will never accept a PR for it. A closed issue can still attract upvotes and act as a ticket to track feature demand\/interest. \r\n\r\nThank You to you for taking the time to create this issue!" + } +] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5db8a10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/metrics-collector.json b/.github/metrics-collector.json new file mode 100644 index 0000000..f301440 --- /dev/null +++ b/.github/metrics-collector.json @@ -0,0 +1,32 @@ +{ + "queries": [ + { + "name": "type_bug", + "query": "label:\"type/bug\" is:open" + }, + { + "name": "type_docs", + "query": "label:\"type/docs\" is:open" + }, + { + "name": "needs_investigation", + "query": "label:\"needs investigation\" is:open" + }, + { + "name": "needs_more_info", + "query": "label:\"needs more info\" is:open" + }, + { + "name": "unlabeled", + "query": "is:open is:issue no:label" + }, + { + "name": "open_prs", + "query": "is:open is:pr" + }, + { + "name": "milestone_7_4_open", + "query": "is:open is:issue milestone:7.4" + } + ] +} diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..701b0b2 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,47 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# General configuration +# Label to use when marking as stale +staleLabel: stale + +# Pull request specific configuration +pulls: + # Number of days of inactivity before an Issue or Pull Request becomes stale + daysUntilStale: 14 + # Number of days of inactivity before a stale Issue or Pull Request is closed. + # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. + daysUntilClose: 30 + # Comment to post when marking as stale. Set to `false` to disable + markComment: > + This pull request has been automatically marked as stale because it has not had + activity in the last 2 weeks. It will be closed in 30 days if no further activity occurs. Please + feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! + # Comment to post when closing a stale Issue or Pull Request. + closeComment: > + This pull request has been automatically closed because it has not had + activity in the last 30 days. Please feel free to give a status update now, ping for review, or re-open when it's ready. + Thank you for your contributions! + # Limit the number of actions per hour, from 1-30. Default is 30 + limitPerRun: 1 + +exemptLabels: + - help wanted + - type/bug + - type/feature-request + - Epic + - no stalebot + +# Issue specific configuration +issues: + limitPerRun: 1 + daysUntilStale: 100000 + daysUntilClose: 100000 + markComment: > + This issue has been automatically marked as stale because it has not had activity in the + last 100 days. It will be closed in the next 100 days if no activity occurs. + Thank you for your contributions. + closeComment: > + This issue has been automatically closed because it has not had activity in the + last month and a half. If this issue is still valid, please ping a maintainer and ask them to check this again. + Thank you for your contributions. diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..55dc5c8 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,26 @@ +name: Backport PR Creator +on: + pull_request_target: + types: + - closed + - labeled + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run backport + uses: ./actions/backport + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + labelsToAdd: "backport" + title: "[{{base}}] {{originalTitle}}" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..072e3e8 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,27 @@ +name: Bump version +on: + workflow_dispatch: + inputs: + version: + required: true + default: '7.x.x' +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - uses: actions/setup-node@v2.1.4 + with: + node-version: '14' + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run bump version + uses: ./actions/bump-version + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..c2d9e48 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [master, v1.8.x, v2.0.x, v2.1.x, v2.6.x, v3.0.x, v3.1.x, v4.0.x, v4.1.x, v4.2.x, v4.3.x, v4.4.x, v4.5.x, v4.6.x, v4.7.x, v5.0.x, v5.1.x, v5.2.x, v5.3.x, v5.4.x, v6.0.x, v6.1.x, v6.2.x, v6.3.x, v6.4.x, v6.5.x, v6.6.x, v6.7.x, v7.0.x, v7.1.x, v7.2.x] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: '0 4 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['javascript', 'go', 'python'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000..91af697 --- /dev/null +++ b/.github/workflows/commands.yml @@ -0,0 +1,25 @@ +name: Run commands when issues are labeled or comments added +on: + issues: + types: [labeled] + issue_comment: + types: [created] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run Commands + uses: ./actions/commands + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + configPath: commands diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..9f1f1fe --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,24 @@ +name: Create or update GitHub release +on: + workflow_dispatch: + inputs: + version: + required: true + description: Needs to match, exactly, the name of a milestone (NO v prefix) +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run github release action + uses: ./actions/github-release + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.github/workflows/metrics-collector.yml b/.github/workflows/metrics-collector.yml new file mode 100644 index 0000000..84dec4e --- /dev/null +++ b/.github/workflows/metrics-collector.yml @@ -0,0 +1,35 @@ +# +# When triggered by the cron job it will also collect metrics for: +# * number of issues without label +# * number of issues with "needs more info" +# * number of issues with "needs investigation" +# * number of issues with label type/bug +# * number of open issues in current milestone +# +# https://github.com/grafana/grafana-github-actions/blob/main/metrics-collector/index.ts +# +name: Github issue metrics collection +on: + schedule: + - cron: "*/10 * * * *" + issues: + types: [opened, closed] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run metrics collector + uses: ./actions/metrics-collector + with: + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + configPath: "metrics-collector" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..73f5fbb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,44 @@ +name: publish_docs + +on: + push: + branches: + - v7.5.x + paths: + - 'docs/sources/**' + - 'packages/grafana-*/**' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync + - uses: actions/cache@v2.1.4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: generate-packages-docs + uses: actions/setup-node@v2.1.4 + id: generate-docs + with: + node-version: '14' + - run: yarn install --pure-lockfile --no-progress + - run: ./scripts/ci-reference-docs-build.sh + - name: publish-to-git + uses: ./.github/actions/website-sync + id: publish + with: + repository: grafana/website + branch: master + host: github.com + github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' + source_folder: docs/sources + target_folder: content/docs/grafana/latest + allow_no_changes: 'true' + - shell: bash + run: | + test -n "${{ steps.publish.outputs.commit_hash }}" + test -n "${{ steps.publish.outputs.working_directory }}" diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..ecc7ff9 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,24 @@ +name: Update changelog +on: + workflow_dispatch: + inputs: + version: + required: true + description: Needs to match, exactly, the name of a milestone +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Actions + uses: actions/checkout@v2 + with: + repository: "grafana/grafana-github-actions" + path: ./actions + ref: main + - name: Install Actions + run: npm install --production --prefix ./actions + - name: Run update changelog + uses: ./actions/update-changelog + with: + token: ${{secrets.GH_BOT_ACCESS_TOKEN}} + metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849d998 --- /dev/null +++ b/.gitignore @@ -0,0 +1,146 @@ +node_modules +npm-debug.log +yarn-error.log +coverage/ +.aws-config.json +awsconfig +/.awcache +/dist +/public/build +/public/views/index.html +/public/views/error.html + +/public_riya/build +/public_riya/views/index.html +/public_riya/views/error.html + +/public_tipnomics/build +/public_tipnomics/views/index.html +/public_tipnomics/views/error.html + +/emails/dist +/reports +/e2e/tmp +.yarnrc +.yarn/ +vendor/ +/docs/menu.yaml + +# Enterprise emails +/emails/templates/enterprise_* +/public/emails/enterprise_* + +/public_riya/emails/enterprise_* + +/public_tipnomics/emails/enterprise_* + +# Enterprise reporting fonts +/public/fonts/dejavu +/public_riya/fonts/dejavu +/public_tipnomics/fonts/dejavu + +# Enterprise devenv +/devenv/docker/blocks/grafana-enterprise + +/tmp +tools/phantomjs/phantomjs +tools/phantomjs/phantomjs.exe +profile.out +coverage.txt + +docs/AWS_S3_BUCKET +docs/GIT_BRANCH +docs/GITCOMMIT +docs/changed-files + +# locally required config files +public/css/*.min.css +public_riya/css/*.min.css +public_tipnomics/css/*.min.css + +# Editor junk +*.sublime-workspace +*.swp +.idea/ +*.iml +*.tmp +.DS_Store +.vscode/ +.vs/ +.eslintcache + +/data/* +/bin/* + +# devenv +/devenv/docker-compose.yaml +/devenv/.env + +conf/custom.ini +/conf/provisioning/**/custom.yaml +/conf/provisioning/**/dev.yaml +/conf/ldap_dev.toml +/conf/ldap_freeipa.toml +profile.cov +/grafana +/local +.notouch +/Makefile.local +/pkg/cmd/grafana-cli/grafana-cli +/pkg/cmd/grafana-server/grafana-server +/pkg/cmd/grafana-server/debug +/pkg/extensions/* +!/pkg/extensions/main.go +/public/app/extensions +/public_riya/app/extensions +/public_tipnomics/app/extensions +debug.test +/examples/*/dist +/packaging/**/*.rpm +/packaging/**/*.deb +/packaging/**/*.tar.gz + +# Ignore OSX indexing +.DS_Store + +/vendor/**/*.py +/vendor/**/*.xml +/vendor/**/*.yml +/vendor/**/*_test.go +/vendor/**/.editorconfig +*.orig + +/devenv/bulk-dashboards/*.json +/devenv/bulk_alerting_dashboards/*.json +/devenv/datasources_bulk.yaml +/devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml + +/scripts/build/release_publisher/release_publisher +*.patch + +# Ignoring frontend packages specifics +/packages/**/dist +/packages/**/compiled +/packages/**/.rpt2_cache +/packages/**/tsdoc-metadata.json + +# Ignore go local build dependencies +/scripts/go/bin/** + +# Ignore compilation stats from `yarn stats` +compilation-stats.json + +# e2e tests +/packages/grafana-e2e/cypress/screenshots +/packages/grafana-e2e/cypress/videos +/packages/grafana-e2e/cypress/logs +/e2e/server.log +/e2e/**/screenshots +!/e2e/**/screenshots/expected/* +/e2e/**/videos/* + +# report dumping the whole system env +/report.*.json + +# auto generated frontend docs +/docs/sources/packages_api diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2a78eb0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +.git +.github +dist/ +pkg/ +node_modules +public/vendor/ +vendor/ +data/ +e2e/tmp +public/build/ +devenv/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..c14684b --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('@grafana/toolkit/src/config/prettier.plugin.config.json'), +}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2f73ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2744 @@ + + + +# 7.5.4 (2021-04-14) + +### Features and enhancements + +* **AzureMonitor**: Add support for Microsoft.AppConfiguration/configurationStores namespace. [#32123](https://github.com/grafana/grafana/pull/32123), [@deesejohn](https://github.com/deesejohn) +* **TablePanel**: Make sorting case-insensitive. [#32435](https://github.com/grafana/grafana/pull/32435), [@kaydelaney](https://github.com/kaydelaney) + +### Bug fixes + +* **AzureMonitor**: Add support for Virtual WAN namespaces. [#32935](https://github.com/grafana/grafana/pull/32935), [@joshhunt](https://github.com/joshhunt) +* **Bugfix**: Add proper padding when scrolling is added to bar gauge. [#32411](https://github.com/grafana/grafana/pull/32411), [@mckn](https://github.com/mckn) +* **Datasource**: Prevent default data source named "default" from causing infinite loop. [#32949](https://github.com/grafana/grafana/pull/32949), [@jackw](https://github.com/jackw) +* **Prometheus**: Allow query_exemplars endpoint in data source proxy. [#32802](https://github.com/grafana/grafana/pull/32802), [@zoltanbedi](https://github.com/zoltanbedi) +* **Table**: Fix table data links so they refer to correct row after sorting. [#32571](https://github.com/grafana/grafana/pull/32571), [@torkelo](https://github.com/torkelo) + + + + + +# 7.5.3 (2021-04-07) + +### Features and enhancements + +* **Dashboard**: Do not include default datasource when externally exporting dashboard with row. [#32494](https://github.com/grafana/grafana/pull/32494), [@kaydelaney](https://github.com/kaydelaney) +* **Loki**: Remove empty annotations tags. [#32359](https://github.com/grafana/grafana/pull/32359), [@conorevans](https://github.com/conorevans) + +### Bug fixes + +* **AdHocVariable**: Add default data source to picker. [#32470](https://github.com/grafana/grafana/pull/32470), [@hugohaggmark](https://github.com/hugohaggmark) +* **Configuration**: Prevent browser hanging / crashing with large number of org users. [#32546](https://github.com/grafana/grafana/pull/32546), [@jackw](https://github.com/jackw) +* **Elasticsearch**: Fix bucket script variable duplication in UI. [#32705](https://github.com/grafana/grafana/pull/32705), [@Elfo404](https://github.com/Elfo404) +* **Explore**: Fix bug where navigating to explore would result in wrong query and datasource to be shown. [#32558](https://github.com/grafana/grafana/pull/32558), [@aocenas](https://github.com/aocenas) +* **FolderPicker**: Prevent dropdown menu from disappearing off screen. [#32603](https://github.com/grafana/grafana/pull/32603), [@jackw](https://github.com/jackw) +* **SingleStat**: Fix issue with panel links. [#32721](https://github.com/grafana/grafana/pull/32721), [@gjulianm](https://github.com/gjulianm) +* **Variables**: Confirm selection before opening new picker. [#32586](https://github.com/grafana/grafana/pull/32586), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Confirm selection before opening new picker. [#32503](https://github.com/grafana/grafana/pull/32503), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix unsupported data format error for null values. [#32480](https://github.com/grafana/grafana/pull/32480), [@hugohaggmark](https://github.com/hugohaggmark) + + + + + +# 7.5.2 (2021-03-30) + +### Features and enhancements + +* **Explore**: Set Explore's GraphNG to use default value for connected null values setting. [#32471](https://github.com/grafana/grafana/pull/32471), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +* **DashboardDataSource**: Fix query not being executed after selecting source panel. [#32383](https://github.com/grafana/grafana/pull/32383), [@torkelo](https://github.com/torkelo) +* **Graph**: Fix setting right y-axis when standard option unit is configured. [#32426](https://github.com/grafana/grafana/pull/32426), [@torkelo](https://github.com/torkelo) +* **Table**: Fix links for image cells. [#32370](https://github.com/grafana/grafana/pull/32370), [@kaydelaney](https://github.com/kaydelaney) +* **Variables**: Fix data source variable when default data source is selected. [#32384](https://github.com/grafana/grafana/pull/32384), [@torkelo](https://github.com/torkelo) +* **Variables**: Fix manually entering non-matching custom value in variable input/picker error. [#32390](https://github.com/grafana/grafana/pull/32390), [@torkelo](https://github.com/torkelo) + + + + + +# 7.5.1 (2021-03-26) + +### Bug fixes + +* **MSSQL**: Fix panic not implemented by upgrading go-mssqldb dependency. [#32347](https://github.com/grafana/grafana/pull/32347), [@aknuds1](https://github.com/aknuds1) + + + + + +# 7.5.0 (2021-03-25) + +### Features and enhancements + +* **Alerting**: Add ability to include aliases with hyphen in InfluxDB. [#32262](https://github.com/grafana/grafana/pull/32262), [@grafanabot](https://github.com/grafanabot) +* **CloudWatch**: Use latest version of aws sdk. [#32217](https://github.com/grafana/grafana/pull/32217), [@sunker](https://github.com/sunker) + +### Bug fixes + +* **Alerting**: Add ability to include aliases with hyphen in InfluxDB. [#32224](https://github.com/grafana/grafana/pull/32224), [@dsotirakis](https://github.com/dsotirakis) +* **DashboardSettings**: Fixes issue with tags list not updating changes are made. [#32241](https://github.com/grafana/grafana/pull/32241), [@huynhsamha](https://github.com/huynhsamha) +* **DashboardSettings**: Fixes issue with tags list not updating changes are made. [#32189](https://github.com/grafana/grafana/pull/32189), [@huynhsamha](https://github.com/huynhsamha) +* **Loki**: Fix text search in Label browser. [#32293](https://github.com/grafana/grafana/pull/32293), [@ivanahuckova](https://github.com/ivanahuckova) + + + + + +# 7.5.0-beta2 (2021-03-19) + +### Features and enhancements + +* **CloudWatch**: Add support for EC2 IAM role. [#31804](https://github.com/grafana/grafana/pull/31804), [@sunker](https://github.com/sunker) +* **CloudWatch**: Consume the grafana/aws-sdk. [#31807](https://github.com/grafana/grafana/pull/31807), [@sunker](https://github.com/sunker) +* **CloudWatch**: Restrict auth provider and assume role usage according to Grafana configuration. [#31805](https://github.com/grafana/grafana/pull/31805), [@sunker](https://github.com/sunker) +* **Cloudwatch**: ListMetrics API page limit. [#31788](https://github.com/grafana/grafana/pull/31788), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Use shared library for aws auth. [#29550](https://github.com/grafana/grafana/pull/29550), [@ryantxu](https://github.com/ryantxu) +* **DataLinks**: Bring back single click links for Stat, Gauge and BarGauge panel. [#31692](https://github.com/grafana/grafana/pull/31692), [@dprokop](https://github.com/dprokop) +* **Docker**: Support pre-installed plugins from other sources in custom Dockerfiles. [#31234](https://github.com/grafana/grafana/pull/31234), [@sgnsys3](https://github.com/sgnsys3) +* **Elasticseach**: Add support for histogram fields. [#29079](https://github.com/grafana/grafana/pull/29079), [@simianhacker](https://github.com/simianhacker) +* **Exemplars**: Always query exemplars. [#31673](https://github.com/grafana/grafana/pull/31673), [@zoltanbedi](https://github.com/zoltanbedi) +* **Explore**: Support full inspect drawer. [#32005](https://github.com/grafana/grafana/pull/32005), [@ivanahuckova](https://github.com/ivanahuckova) +* **HttpServer**: Make read timeout configurable but disabled by default. [#31575](https://github.com/grafana/grafana/pull/31575), [@bergquist](https://github.com/bergquist) +* **SQLStore**: Close session in withDbSession. [#31775](https://github.com/grafana/grafana/pull/31775), [@aknuds1](https://github.com/aknuds1) +* **Templating**: Use dashboard timerange when variables are set to refresh 'On Dashboard Load'. [#31721](https://github.com/grafana/grafana/pull/31721), [@Elfo404](https://github.com/Elfo404) +* **Tempo**: Convert to backend data source. [#31618](https://github.com/grafana/grafana/pull/31618), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug fixes + +* **Admin**: Keeps expired api keys visible in table after delete. [#31636](https://github.com/grafana/grafana/pull/31636), [@hugohaggmark](https://github.com/hugohaggmark) +* **Data proxy**: Fix encoded characters in URL path should be proxied as encoded. [#30597](https://github.com/grafana/grafana/pull/30597), [@marefr](https://github.com/marefr) +* **Explore/Logs**: Fix escaping in ANSI logs. [#31731](https://github.com/grafana/grafana/pull/31731), [@ivanahuckova](https://github.com/ivanahuckova) +* **GraphNG**: Fix tooltip series color for multi data frame scenario. [#32098](https://github.com/grafana/grafana/pull/32098), [@dprokop](https://github.com/dprokop) +* **GraphNG**: Make sure data set and config are in sync when initializing and re-initializing uPlot. [#32106](https://github.com/grafana/grafana/pull/32106), [@dprokop](https://github.com/dprokop) +* **Loki**: Fix autocomplete when re-editing Loki label values. [#31828](https://github.com/grafana/grafana/pull/31828), [@ivanahuckova](https://github.com/ivanahuckova) +* **MixedDataSource**: Name is updated when data source variables change. [#32090](https://github.com/grafana/grafana/pull/32090), [@hugohaggmark](https://github.com/hugohaggmark) +* **PanelInspect**: Interpolates variables in CSV file name. [#31936](https://github.com/grafana/grafana/pull/31936), [@hugohaggmark](https://github.com/hugohaggmark) +* **ReduceTransform**: Include series with numeric string names. [#31763](https://github.com/grafana/grafana/pull/31763), [@hugohaggmark](https://github.com/hugohaggmark) +* **Snapshots**: Fix usage of sign in link from the snapshot page. [#31986](https://github.com/grafana/grafana/pull/31986), [@marefr](https://github.com/marefr) +* **TimePicker**: Fixes hidden time picker shown in kiosk TV mode. [#32062](https://github.com/grafana/grafana/pull/32062), [@torkelo](https://github.com/torkelo) +* **ValueMappings**: Fixes value 0 not being mapped. [#31924](https://github.com/grafana/grafana/pull/31924), [@Willena](https://github.com/Willena) +* **Variables**: Fixes filtering in picker with null items. [#31979](https://github.com/grafana/grafana/pull/31979), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Improves inspection performance and unknown filtering. [#31811](https://github.com/grafana/grafana/pull/31811), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +* **Auth**: Allow soft token revocation. [#31601](https://github.com/grafana/grafana/pull/31601), [@joanlopez](https://github.com/joanlopez) + + + + + +# 7.5.0-beta1 (2021-03-04) + +### Features and enhancements + +* **Alerting**: Customise OK notification priorities for Pushover notifier. [#30169](https://github.com/grafana/grafana/pull/30169), [@acaire](https://github.com/acaire) +* **Alerting**: Improve default message for SensuGo notifier. [#31428](https://github.com/grafana/grafana/pull/31428), [@M4teo](https://github.com/M4teo) +* **Alerting**: PagerDuty: adding current state to the payload. [#29270](https://github.com/grafana/grafana/pull/29270), [@Eraac](https://github.com/Eraac) +* **AzureMonitor**: Add deprecation message for App Insights/Insights Analytics. [#30633](https://github.com/grafana/grafana/pull/30633), [@joshhunt](https://github.com/joshhunt) +* **CloudMonitoring**: Allow free text input for GCP project on dashboard variable query. [#28048](https://github.com/grafana/grafana/issues/28048) +* **CloudMonitoring**: Increase service api page size. [#30892](https://github.com/grafana/grafana/pull/30892), [@sunker](https://github.com/sunker) +* **CloudMonitoring**: Show service and SLO display name in SLO Query editor. [#30900](https://github.com/grafana/grafana/pull/30900), [@sunker](https://github.com/sunker) +* **CloudWatch**: Add AWS Ground Station metrics and dimensions. [#31362](https://github.com/grafana/grafana/pull/31362), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add AWS Network Firewall metrics and dimensions. [#31498](https://github.com/grafana/grafana/pull/31498), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add AWS Timestream Metrics and Dimensions. [#31624](https://github.com/grafana/grafana/pull/31624), [@ilyastoli](https://github.com/ilyastoli) +* **CloudWatch**: Add RDS Proxy metrics. [#31595](https://github.com/grafana/grafana/pull/31595), [@sunker](https://github.com/sunker) +* **CloudWatch**: Add eu-south-1 Cloudwatch region. [#31198](https://github.com/grafana/grafana/pull/31198), [@rubycut](https://github.com/rubycut) +* **CloudWatch**: Make it possible to specify custom api endpoint. [#31402](https://github.com/grafana/grafana/pull/31402), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Add AWS/DDoSProtection metrics and dimensions. [#31297](https://github.com/grafana/grafana/pull/31297), [@relvira](https://github.com/relvira) +* **Dashboard**: Remove template variables option from ShareModal. [#30395](https://github.com/grafana/grafana/pull/30395), [@oscarkilhed](https://github.com/oscarkilhed) +* **Docs**: Define TLS/SSL terminology. [#30533](https://github.com/grafana/grafana/pull/30533), [@aknuds1](https://github.com/aknuds1) +* **Elasticsearch**: Add word highlighting to search results. [#30293](https://github.com/grafana/grafana/pull/30293), [@simianhacker](https://github.com/simianhacker) +* **Folders**: Editors should be able to edit name and delete folders. [#31242](https://github.com/grafana/grafana/pull/31242), [@torkelo](https://github.com/torkelo) +* **Graphite/SSE**: update graphite to work with server side expressions. [#31455](https://github.com/grafana/grafana/pull/31455), [@kylebrandt](https://github.com/kylebrandt) +* **InfluxDB**: Improve maxDataPoints error-message in Flux-mode, raise limits. [#31259](https://github.com/grafana/grafana/pull/31259), [@gabor](https://github.com/gabor) +* **InfluxDB**: In flux query editor, do not run query when disabled. [#31324](https://github.com/grafana/grafana/pull/31324), [@gabor](https://github.com/gabor) +* **LogsPanel**: Add deduplication option for logs. [#31019](https://github.com/grafana/grafana/pull/31019), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add line limit for annotations. [#31183](https://github.com/grafana/grafana/pull/31183), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add support for alerting. [#31424](https://github.com/grafana/grafana/pull/31424), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Label browser. [#30351](https://github.com/grafana/grafana/pull/30351), [@davkal](https://github.com/davkal) +* **PieChart**: Add color changing options to pie chart. [#31588](https://github.com/grafana/grafana/pull/31588), [@oscarkilhed](https://github.com/oscarkilhed) +* **PostgreSQL**: Allow providing TLS/SSL certificates as text in addition to file paths. [#30353](https://github.com/grafana/grafana/pull/30353), [@ying-jeanne](https://github.com/ying-jeanne) +* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne) +* **Profile**: Prevent OAuth users from changing user details or password. [#27886](https://github.com/grafana/grafana/pull/27886), [@dupondje](https://github.com/dupondje) +* **Prometheus**: Change default httpMethod for new instances to POST. [#31292](https://github.com/grafana/grafana/pull/31292), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Min step defaults to seconds when no unit is set. [#30966](https://github.com/grafana/grafana/pull/30966), [@nutmos](https://github.com/nutmos) +* **Stats**: Exclude folders from total dashboard count. [#31320](https://github.com/grafana/grafana/pull/31320), [@bergquist](https://github.com/bergquist) +* **Tracing**: Specify type of data frame that is expected for TraceView. [#31465](https://github.com/grafana/grafana/pull/31465), [@aocenas](https://github.com/aocenas) +* **Transformers**: Add search to transform selection. [#30854](https://github.com/grafana/grafana/pull/30854), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +* **Alerting**: Ensure Discord notification is sent when metric name is absent. [#31257](https://github.com/grafana/grafana/pull/31257), [@LeviHarrison](https://github.com/LeviHarrison) +* **Alerting**: Fix case when Alertmanager notifier fails if a URL is not working. [#31079](https://github.com/grafana/grafana/pull/31079), [@kurokochin](https://github.com/kurokochin) +* **CloudMonitoring**: Prevent resource type variable function from crashing. [#30901](https://github.com/grafana/grafana/pull/30901), [@sunker](https://github.com/sunker) +* **Color**: Fix issue where colors are reset to gray when switching panels. [#31611](https://github.com/grafana/grafana/pull/31611), [@torkelo](https://github.com/torkelo) +* **Explore**: Show ANSI colored logs in logs context. [#31510](https://github.com/grafana/grafana/pull/31510), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: keep enabled/disabled state in angular based QueryEditors correctly. [#31558](https://github.com/grafana/grafana/pull/31558), [@gabor](https://github.com/gabor) +* **Graph**: Fix tooltip not being displayed when close to edge of viewport. [#31493](https://github.com/grafana/grafana/pull/31493), [@msober](https://github.com/msober) +* **Heatmap**: Fix missing value in legend. [#31430](https://github.com/grafana/grafana/pull/31430), [@kurokochin](https://github.com/kurokochin) +* **InfluxDB**: Handle columns named "table". [#30985](https://github.com/grafana/grafana/pull/30985), [@gabor](https://github.com/gabor) +* **Prometheus**: Use configured HTTP method for /series and /labels endpoints. [#31401](https://github.com/grafana/grafana/pull/31401), [@ivanahuckova](https://github.com/ivanahuckova) +* **RefreshPicker**: Make valid intervals in url visible in RefreshPicker. [#30474](https://github.com/grafana/grafana/pull/30474), [@hugohaggmark](https://github.com/hugohaggmark) +* **TimeSeriesPanel**: Fix overlapping time axis ticks. [#31332](https://github.com/grafana/grafana/pull/31332), [@torkelo](https://github.com/torkelo) +* **TraceViewer**: Fix show log marker in spanbar. [#30742](https://github.com/grafana/grafana/pull/30742), [@zoltanbedi](https://github.com/zoltanbedi) + +### Plugin development fixes & changes + +* **Plugins**: Add autoEnabled plugin JSON field to auto enable App plugins and add configuration link to menu by default. [#31354](https://github.com/grafana/grafana/pull/31354), [@torkelo](https://github.com/torkelo) +* **Pagination**: Improve pagination for large number of pages. [#30151](https://github.com/grafana/grafana/pull/30151), [@nathanrodman](https://github.com/nathanrodman) + + + + + +# 7.4.5 (2021-03-18) + +### Bug fixes + +* **Security**: Fix API permissions issues related to team-sync CVE-2021-28146, CVE-2021-28147. (Enterprise) +* **Security**: Usage insights requires signed in users CVE-2021-28148. (Enterprise) +* **Security**: Do not allow editors to incorrectly bypass permissions on the default data source. CVE-2021-27962. (Enterprise) + + + + + +# 7.4.3 (2021-02-24) + +### Bug fixes + +* **AdHocVariables**: Fixes crash when values are stored as numbers. [#31382](https://github.com/grafana/grafana/pull/31382), [@hugohaggmark](https://github.com/hugohaggmark) +* **DashboardLinks**: Fix an issue where the dashboard links were causing a full page reload. [#31334](https://github.com/grafana/grafana/pull/31334), [@torkelo](https://github.com/torkelo) +* **Elasticsearch**: Fix query initialization logic & query transformation from Prometheus/Loki. [#31322](https://github.com/grafana/grafana/pull/31322), [@Elfo404](https://github.com/Elfo404) +* **QueryEditor**: Fix disabling queries in dashboards. [#31336](https://github.com/grafana/grafana/pull/31336), [@gabor](https://github.com/gabor) +* **Streaming**: Fix an issue with the time series panel and streaming data source when scrolling back from being out of view. [#31431](https://github.com/grafana/grafana/pull/31431), [@torkelo](https://github.com/torkelo) +* **Table**: Fix an issue regarding the fixed min and auto max values in bar gauge cell. [#31316](https://github.com/grafana/grafana/pull/31316), [@torkelo](https://github.com/torkelo) + + + + + +# 7.4.2 (2021-02-17) + +### Features and enhancements + +* **Explore**: Do not show non queryable data sources in data source picker. [#31144](https://github.com/grafana/grafana/pull/31144), [@torkelo](https://github.com/torkelo) +* **Snapshots**: Do not allow an anonymous user to create snapshots. [#31263](https://github.com/grafana/grafana/pull/31263), [@marefr](https://github.com/marefr) + +### Bug fixes + +* **CloudWatch**: Ensure empty query row errors are not passed to the panel. [#31172](https://github.com/grafana/grafana/pull/31172), [@sunker](https://github.com/sunker) +* **DashboardLinks**: Fix the links that always cause a full page to reload. [#31178](https://github.com/grafana/grafana/pull/31178), [@torkelo](https://github.com/torkelo) +* **DashboardListPanel**: Fix issue with folder picker always showing All and using old form styles. [#31160](https://github.com/grafana/grafana/pull/31160), [@torkelo](https://github.com/torkelo) +* **IPv6**: Support host address configured with enclosing square brackets. [#31226](https://github.com/grafana/grafana/pull/31226), [@aknuds1](https://github.com/aknuds1) +* **Permissions**: Fix team and role permissions on folders/dashboards not displayed for non Grafana Admin users. [#31132](https://github.com/grafana/grafana/pull/31132), [@AgnesToulet](https://github.com/AgnesToulet) +* **Postgres**: Fix timeGroup macro converts long intervals to invalid numbers when TimescaleDB is enabled. [#31179](https://github.com/grafana/grafana/pull/31179), [@kurokochin](https://github.com/kurokochin) +* **Prometheus**: Fix enabling of disabled queries when editing in dashboard. [#31055](https://github.com/grafana/grafana/pull/31055), [@ivanahuckova](https://github.com/ivanahuckova) +* **QueryEditors**: Fix an issue that happens after moving queries then editing would update other queries. [#31193](https://github.com/grafana/grafana/pull/31193), [@torkelo](https://github.com/torkelo) +* **SqlDataSources**: Fix the Show Generated SQL button in query editors. [#31236](https://github.com/grafana/grafana/pull/31236), [@torkelo](https://github.com/torkelo) +* **StatPanels**: Fix an issue where the palette color scheme is not cleared when loading panel. [#31126](https://github.com/grafana/grafana/pull/31126), [@torkelo](https://github.com/torkelo) +* **Variables**: Add the default option back for the data source variable. [#31208](https://github.com/grafana/grafana/pull/31208), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix missing empty elements from regex filters. [#31156](https://github.com/grafana/grafana/pull/31156), [@hugohaggmark](https://github.com/hugohaggmark) + + + + + + + + + + +# 7.4.1 (2021-02-11) + +### Features and enhancements + +* **Influx**: Make max series limit configurable and show the limiting message if applied. [#31025](https://github.com/grafana/grafana/pull/31025), [@aocenas](https://github.com/aocenas) +* **Make value mappings correctly interpret numeric-like strings**. [#30893](https://github.com/grafana/grafana/pull/30893), [@dprokop](https://github.com/dprokop) +* **Variables**: Adds queryparam formatting option. [#30858](https://github.com/grafana/grafana/pull/30858), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +* **Alerting**: Fixes so notification channels are properly deleted. [#31040](https://github.com/grafana/grafana/pull/31040), [@hugohaggmark](https://github.com/hugohaggmark) +* **BarGauge**: Improvements to value sizing and table inner width calculations. [#30990](https://github.com/grafana/grafana/pull/30990), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Fixes crash when link has no title. [#31008](https://github.com/grafana/grafana/pull/31008), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Fix alias field value not being shown in query editor. [#30992](https://github.com/grafana/grafana/pull/30992), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix log row context errors. [#31088](https://github.com/grafana/grafana/pull/31088), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Show Size setting for raw_data metric. [#30980](https://github.com/grafana/grafana/pull/30980), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixes so graph is shown for non numeric time values. [#30972](https://github.com/grafana/grafana/pull/30972), [@hugohaggmark](https://github.com/hugohaggmark) +* **Logging**: Ignore 'file already closed' error when closing file. [#31119](https://github.com/grafana/grafana/pull/31119), [@aknuds1](https://github.com/aknuds1) +* **Plugins**: Fix plugin signature validation for manifest v2 on Windows. [#31045](https://github.com/grafana/grafana/pull/31045), [@wbrowne](https://github.com/wbrowne) +* **TextPanel**: Fixes so panel title is updated when variables change. [#30884](https://github.com/grafana/grafana/pull/30884), [@hugohaggmark](https://github.com/hugohaggmark) +* **Transforms**: Fixes Outer join issue with duplicate field names not getting the same unique field names as before. [#31121](https://github.com/grafana/grafana/pull/31121), [@torkelo](https://github.com/torkelo) + + + + + +# 7.4.0 (2021-02-04) + +### Features and enhancements + +* **CDN**: Adds support for serving assets over a CDN. [#30691](https://github.com/grafana/grafana/pull/30691), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Support variable expression in to tooltip - Issue #30409. [#30569](https://github.com/grafana/grafana/pull/30569), [@huynhsamha](https://github.com/huynhsamha) +* **Explore**: Set Explore's GraphNG to be connected. [#30707](https://github.com/grafana/grafana/pull/30707), [@ivanahuckova](https://github.com/ivanahuckova) +* **InfluxDB**: Add http configuration when selecting InfluxDB v2 flavor. [#30827](https://github.com/grafana/grafana/pull/30827), [@aocenas](https://github.com/aocenas) +* **InfluxDB**: Show all datapoints for dynamically windowed flux query. [#30688](https://github.com/grafana/grafana/pull/30688), [@davkal](https://github.com/davkal) +* **Loki**: Improve live tailing errors. [#30517](https://github.com/grafana/grafana/pull/30517), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +* **Admin**: Fixes so form values are filled in from backend. [#30544](https://github.com/grafana/grafana/pull/30544), [@hugohaggmark](https://github.com/hugohaggmark) +* **Admin**: Fixes so whole org drop down is visible when adding users to org. [#30481](https://github.com/grafana/grafana/pull/30481), [@hugohaggmark](https://github.com/hugohaggmark) +* **Alerting**: Hides threshold handle for percentual thresholds. [#30431](https://github.com/grafana/grafana/pull/30431), [@hugohaggmark](https://github.com/hugohaggmark) +* **CloudWatch**: Prevent field config from being overwritten. [#30437](https://github.com/grafana/grafana/pull/30437), [@sunker](https://github.com/sunker) +* **Decimals**: Big Improvements to auto decimals and fixes to auto decimals bug found in 7.4-beta1. [#30519](https://github.com/grafana/grafana/pull/30519), [@torkelo](https://github.com/torkelo) +* **Explore**: Fix jumpy live tailing. [#30650](https://github.com/grafana/grafana/pull/30650), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Fix loading visualisation on the top of the new time series panel. [#30553](https://github.com/grafana/grafana/pull/30553), [@ivanahuckova](https://github.com/ivanahuckova) +* **Footer**: Fixes layout issue in footer. [#30443](https://github.com/grafana/grafana/pull/30443), [@torkelo](https://github.com/torkelo) +* **Graph**: Fixes so only users with correct permissions can add annotations. [#30419](https://github.com/grafana/grafana/pull/30419), [@hugohaggmark](https://github.com/hugohaggmark) +* **Mobile**: Fixes issue scrolling on mobile in chrome. [#30746](https://github.com/grafana/grafana/pull/30746), [@torkelo](https://github.com/torkelo) +* **PanelEdit**: Trigger refresh when changing data source. [#30744](https://github.com/grafana/grafana/pull/30744), [@torkelo](https://github.com/torkelo) +* **Panels**: Fixes so panels are refreshed when scrolling past them fast. [#30784](https://github.com/grafana/grafana/pull/30784), [@hugohaggmark](https://github.com/hugohaggmark) +* **Prometheus**: Fix show query instead of Value if no __name__ and metric. [#30511](https://github.com/grafana/grafana/pull/30511), [@zoltanbedi](https://github.com/zoltanbedi) +* **TimeSeriesPanel**: Fixes default value for Gradient mode. [#30484](https://github.com/grafana/grafana/pull/30484), [@torkelo](https://github.com/torkelo) +* **Variables**: Clears drop down state when leaving dashboard. [#30810](https://github.com/grafana/grafana/pull/30810), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes display value when using capture groups in regex. [#30636](https://github.com/grafana/grafana/pull/30636), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so queries work for numbers values too. [#30602](https://github.com/grafana/grafana/pull/30602), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so text format will show All instead of custom all value. [#30730](https://github.com/grafana/grafana/pull/30730), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +* **Plugins**: Fix failing plugin builds because of wrong internal import. [#30439](https://github.com/grafana/grafana/pull/30439), [@aocenas](https://github.com/aocenas) + + + + + +# 7.4.0-beta1 (2021-01-20) + +### Features and enhancements + +* **API**: Add ID to snapshot API responses. [#29600](https://github.com/grafana/grafana/pull/29600), [@AgnesToulet](https://github.com/AgnesToulet) +* **AlertListPanel**: Add options to sort by Time(asc) and Time(desc). [#29764](https://github.com/grafana/grafana/pull/29764), [@dboslee](https://github.com/dboslee) +* **AlertListPanel**: Changed alert url to to go the panel view instead of panel edit. [#29060](https://github.com/grafana/grafana/pull/29060), [@zakiharis](https://github.com/zakiharis) +* **Alerting**: Add support for Sensu Go notification channel. [#28012](https://github.com/grafana/grafana/pull/28012), [@nixwiz](https://github.com/nixwiz) +* **Alerting**: Add support for alert notification query label interpolation. [#29908](https://github.com/grafana/grafana/pull/29908), [@wbrowne](https://github.com/wbrowne) +* **Annotations**: Remove annotation_tag entries as part of annotations cleanup. [#29534](https://github.com/grafana/grafana/pull/29534), [@dafydd-t](https://github.com/dafydd-t) +* **Azure Monitor**: Add Microsoft.Network/natGateways. [#29479](https://github.com/grafana/grafana/pull/29479), [@JoeyLemur](https://github.com/JoeyLemur) +* **Backend plugins**: Support Forward OAuth Identity for backend data source plugins. [#27055](https://github.com/grafana/grafana/pull/27055), [@billoley](https://github.com/billoley) +* **Cloud Monitoring**: MQL support. [#26551](https://github.com/grafana/grafana/pull/26551), [@mtanda](https://github.com/mtanda) +* **CloudWatch**: Add 'EventBusName' dimension to CloudWatch 'AWS/Events' namespace. [#28402](https://github.com/grafana/grafana/pull/28402), [@tomdaly](https://github.com/tomdaly) +* **CloudWatch**: Add support for AWS DirectConnect ConnectionErrorCount metric. [#29583](https://github.com/grafana/grafana/pull/29583), [@haeringer](https://github.com/haeringer) +* **CloudWatch**: Add support for AWS/ClientVPN metrics and dimensions. [#29055](https://github.com/grafana/grafana/pull/29055), [@marefr](https://github.com/marefr) +* **CloudWatch**: Added HTTP API Gateway specific metrics and dimensions. [#28780](https://github.com/grafana/grafana/pull/28780), [@karlatkinson](https://github.com/karlatkinson) +* **Configuration**: Add an option to hide certain users in the UI. [#28942](https://github.com/grafana/grafana/pull/28942), [@AgnesToulet](https://github.com/AgnesToulet) +* **Currency**: Adds Indonesian IDR currency. [#28363](https://github.com/grafana/grafana/pull/28363), [@hiddenrebel](https://github.com/hiddenrebel) +* **Dashboards**: Delete related data (permissions, stars, tags, versions, annotations) when deleting a dashboard or a folder. [#28826](https://github.com/grafana/grafana/pull/28826), [@AgnesToulet](https://github.com/AgnesToulet) +* **Dependencies**: Update angularjs to 1.8.2. [#28736](https://github.com/grafana/grafana/pull/28736), [@torkelo](https://github.com/torkelo) +* **Docker**: Use root group in the custom Dockerfile. [#28639](https://github.com/grafana/grafana/pull/28639), [@chugunov](https://github.com/chugunov) +* **Elasticsearch**: Add Moving Function Pipeline Aggregation. [#28131](https://github.com/grafana/grafana/pull/28131), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Add Support for Serial Differencing Pipeline Aggregation. [#28618](https://github.com/grafana/grafana/pull/28618), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Deprecate browser access mode. [#29649](https://github.com/grafana/grafana/pull/29649), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Interpolate variables in Filters Bucket Aggregation. [#28969](https://github.com/grafana/grafana/pull/28969), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Support extended stats and percentiles in terms order by. [#28910](https://github.com/grafana/grafana/pull/28910), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: View in context feature for logs. [#28764](https://github.com/grafana/grafana/pull/28764), [@simianhacker](https://github.com/simianhacker) +* **Explore/Logs**: Alphabetically sort unique labels, labels and parsed fields. [#29030](https://github.com/grafana/grafana/pull/29030), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore/Logs**: Update Parsed fields to Detected fields. [#28881](https://github.com/grafana/grafana/pull/28881), [@ivanahuckova](https://github.com/ivanahuckova) +* **Field overrides**: Added matcher to match all fields returned by a specific query. [#28872](https://github.com/grafana/grafana/pull/28872), [@mckn](https://github.com/mckn) +* **Graph**: Add support for spline interpolation (smoothing) added in new time series panel. [#4303](https://github.com/grafana/grafana/issues/4303) +* **Instrumentation**: Add histograms for database queries. [#29662](https://github.com/grafana/grafana/pull/29662), [@dafydd-t](https://github.com/dafydd-t) +* **Jaeger**: Remove browser access mode. [#30349](https://github.com/grafana/grafana/pull/30349), [@zoltanbedi](https://github.com/zoltanbedi) +* **LogsPanel**: Don't show scroll bars when not needed. [#28972](https://github.com/grafana/grafana/pull/28972), [@aocenas](https://github.com/aocenas) +* **Loki**: Add query type and line limit to query editor in dashboard. [#29356](https://github.com/grafana/grafana/pull/29356), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Add query type selector to query editor in Explore. [#28817](https://github.com/grafana/grafana/pull/28817), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Retry web socket connection when connection is closed abnormally. [#29438](https://github.com/grafana/grafana/pull/29438), [@ivanahuckova](https://github.com/ivanahuckova) +* **MS SQL**: Integrated security. [#30369](https://github.com/grafana/grafana/pull/30369), [@daniellee](https://github.com/daniellee) +* **Middleware**: Add CSP support. [#29740](https://github.com/grafana/grafana/pull/29740), [@aknuds1](https://github.com/aknuds1) +* **OAuth**: Configurable user name attribute. [#28286](https://github.com/grafana/grafana/pull/28286), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **PanelEditor**: Render panel field config categories as separate option group sections. [#30301](https://github.com/grafana/grafana/pull/30301), [@dprokop](https://github.com/dprokop) +* **Postgres**: SSL certification. [#30352](https://github.com/grafana/grafana/pull/30352), [@ying-jeanne](https://github.com/ying-jeanne) +* **Prometheus**: Add support for Exemplars. [#28057](https://github.com/grafana/grafana/pull/28057), [@zoltanbedi](https://github.com/zoltanbedi) +* **Prometheus**: Improve autocomplete performance and remove disabling of dynamic label lookup. [#30199](https://github.com/grafana/grafana/pull/30199), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Update default query type option to "Both" in Explore query editor. [#28935](https://github.com/grafana/grafana/pull/28935), [@ivanahuckova](https://github.com/ivanahuckova) +* **Prometheus**: Use customQueryParameters for all queries. [#28949](https://github.com/grafana/grafana/pull/28949), [@alexbumbacea](https://github.com/alexbumbacea) +* **Security**: Prefer server cipher suites for http2. [#29379](https://github.com/grafana/grafana/pull/29379), [@bergquist](https://github.com/bergquist) +* **Security**: Remove insecure cipher suit as default option. [#29378](https://github.com/grafana/grafana/pull/29378), [@bergquist](https://github.com/bergquist) +* **StatPanels**: Add new calculation option for percentage difference. [#26369](https://github.com/grafana/grafana/pull/26369), [@jedstar](https://github.com/jedstar) +* **StatPanels**: Change default stats option to "Last (not null)". [#28617](https://github.com/grafana/grafana/pull/28617), [@ryantxu](https://github.com/ryantxu) +* **Table**: migrate old-table config to new table config. [#30142](https://github.com/grafana/grafana/pull/30142), [@jackw](https://github.com/jackw) +* **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda) +* **TimeSeriesPanel**: The new graph panel now supports y-axis value mapping. [#30272](https://github.com/grafana/grafana/pull/30272), [@torkelo](https://github.com/torkelo) +* **Tracing**: Tag spans with user login and datasource name instead of id. [#29183](https://github.com/grafana/grafana/pull/29183), [@bergquist](https://github.com/bergquist) +* **Transformations**: Add "Rename By Regex" transformer. [#29281](https://github.com/grafana/grafana/pull/29281), [@simianhacker](https://github.com/simianhacker) +* **Transformations**: Added new transform for excluding and including rows based on their values. [#26884](https://github.com/grafana/grafana/pull/26884), [@Totalus](https://github.com/Totalus) +* **Transforms**: Add sort by transformer. [#30370](https://github.com/grafana/grafana/pull/30370), [@ryantxu](https://github.com/ryantxu) +* **Variables**: Add deprecation warning for value group tags. [#30160](https://github.com/grafana/grafana/pull/30160), [@torkelo](https://github.com/torkelo) +* **Variables**: Added __user.email to global variable. [#28853](https://github.com/grafana/grafana/pull/28853), [@mckn](https://github.com/mckn) +* **Variables**: Adds description field. [#29332](https://github.com/grafana/grafana/pull/29332), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Adds variables inspection. [#25214](https://github.com/grafana/grafana/pull/25214), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: New Variables are stored immediately. [#29178](https://github.com/grafana/grafana/pull/29178), [@hugohaggmark](https://github.com/hugohaggmark) +* **Zipkin**: Remove browser access mode. [#30360](https://github.com/grafana/grafana/pull/30360), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug fixes + +* **API**: Query database from /api/health endpoint. [#28349](https://github.com/grafana/grafana/pull/28349), [@ceh](https://github.com/ceh) +* **Alerting**: Return proper status code when trying to create alert notification channel with duplicate name or uid. [#28043](https://github.com/grafana/grafana/pull/28043), [@jgulick48](https://github.com/jgulick48) +* **Auth**: Fix default maximum lifetime an authenticated user can be logged in. [#30030](https://github.com/grafana/grafana/pull/30030), [@papagian](https://github.com/papagian) +* **Backend**: Fix IPv6 address parsing erroneous. [#28585](https://github.com/grafana/grafana/pull/28585), [@taciomcosta](https://github.com/taciomcosta) +* **CloudWatch**: Make sure stats grow horizontally and not vertically in the Query Editor. [#30106](https://github.com/grafana/grafana/pull/30106), [@sunker](https://github.com/sunker) +* **Cloudwatch**: Fix issue with field calculation transform not working properly with Cloudwatch data. [#28761](https://github.com/grafana/grafana/pull/28761), [@torkelo](https://github.com/torkelo) +* **Dashboards**: Hide playlist edit functionality from viewers and snapshots link from unauthenticated users. [#28992](https://github.com/grafana/grafana/pull/28992), [@jackw](https://github.com/jackw) +* **Data source proxy**: Convert 401 HTTP status code from data source to 400. [#28962](https://github.com/grafana/grafana/pull/28962), [@aknuds1](https://github.com/aknuds1) +* **Decimals**: Improving auto decimals logic for high numbers and scaled units. [#30262](https://github.com/grafana/grafana/pull/30262), [@torkelo](https://github.com/torkelo) +* **Elasticsearch**: Fix date histogram auto interval handling for alert queries. [#30049](https://github.com/grafana/grafana/pull/30049), [@simianhacker](https://github.com/simianhacker) +* **Elasticsearch**: Fix index pattern not working with multiple base sections. [#28348](https://github.com/grafana/grafana/pull/28348), [@tomdaly](https://github.com/tomdaly) +* **Explore**: Clear errors after running a new query. [#30367](https://github.com/grafana/grafana/pull/30367), [@ivanahuckova](https://github.com/ivanahuckova) +* **Graph**: Fixes stacking issues like floating bars when data is not aligned. [#29051](https://github.com/grafana/grafana/pull/29051), [@torkelo](https://github.com/torkelo) +* **Graph**: Staircase and null value=null calculates auto Y-Min incorrectly (fixed in new Time series panel). [#12995](https://github.com/grafana/grafana/issues/12995) +* **Graph**: Staircase mode, do now draw line segment from zero when drawing null values as null (Fixed in new Time series panel). [#17838](https://github.com/grafana/grafana/issues/17838) +* **Image uploader**: Fix uploading of images to GCS. [#26493](https://github.com/grafana/grafana/pull/26493), [@gastonqiu](https://github.com/gastonqiu) +* **Influx**: Fixes issue with many queries being issued as you type in the variable query field. [#29968](https://github.com/grafana/grafana/pull/29968), [@dprokop](https://github.com/dprokop) +* **Logs Panel**: Fix inconsistent highlighting. [#28971](https://github.com/grafana/grafana/pull/28971), [@ivanahuckova](https://github.com/ivanahuckova) +* **Logs Panel**: Fixes problem dragging scrollbar inside logs panel. [#28974](https://github.com/grafana/grafana/pull/28974), [@aocenas](https://github.com/aocenas) +* **Loki**: Fix hiding of series in table if labels have number values. [#30185](https://github.com/grafana/grafana/pull/30185), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Lower min step to 1ms. [#30135](https://github.com/grafana/grafana/pull/30135), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Remove showing of unique labels with the empty string value. [#30363](https://github.com/grafana/grafana/pull/30363), [@ivanahuckova](https://github.com/ivanahuckova) +* **Loki**: Timeseries should not produce 0-values for missing data. [#30116](https://github.com/grafana/grafana/pull/30116), [@davkal](https://github.com/davkal) +* **Plugins**: Fix panic when using complex dynamic URLs in app plugin routes. [#27977](https://github.com/grafana/grafana/pull/27977), [@cinaglia](https://github.com/cinaglia) +* **Prometheus**: Fix link to Prometheus graph in dashboard. [#29543](https://github.com/grafana/grafana/pull/29543), [@ivanahuckova](https://github.com/ivanahuckova) +* **Provisioning**: Build paths in an os independent way. [#29143](https://github.com/grafana/grafana/pull/29143), [@amattheisen](https://github.com/amattheisen) +* **Provisioning**: Fixed problem with getting started panel being added to custom home dashboard. [#28750](https://github.com/grafana/grafana/pull/28750), [@torkelo](https://github.com/torkelo) +* **SAML**: Fixes bug in processing SAML response with empty element by updating saml library (Enterprise). [#29991](https://github.com/grafana/grafana/pull/29991), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **SQL**: Define primary key for tables without it. [#22255](https://github.com/grafana/grafana/pull/22255), [@azhiltsov](https://github.com/azhiltsov) +* **Tracing**: Fix issue showing more than 300 spans. [#29377](https://github.com/grafana/grafana/pull/29377), [@zoltanbedi](https://github.com/zoltanbedi) +* **Units**: Changes FLOP/s to FLOPS and some other rates per second units get /s suffix. [#28825](https://github.com/grafana/grafana/pull/28825), [@Berbe](https://github.com/Berbe) +* **Variables**: Fixes Constant variable persistence confusion. [#29407](https://github.com/grafana/grafana/pull/29407), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes Textbox current value persistence. [#29481](https://github.com/grafana/grafana/pull/29481), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes loading with a custom all value in url. [#28958](https://github.com/grafana/grafana/pull/28958), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fixes so clicking on Selected in drop down will exclude All value from selection. [#29844](https://github.com/grafana/grafana/pull/29844), [@hugohaggmark](https://github.com/hugohaggmark) + +### Breaking changes + + +#### Constant variables + +In order to minimize the confusion with Constant variable usage, we've removed the ability to make Constant variables visible. This change will also migrate __`all`__ existing __`visible`__ Constant variables to Textbox variables because which we think this is a more appropriate type of variable for this use case. + Issue [#29407](https://github.com/grafana/grafana/issues/29407) + + +#### Plugin compatibility + +We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an `$onInit` function. For more details on how to migrate AngularJS code open the [migration guide](https://docs.angularjs.org/guide/migration) and search for **pre-assigning bindings**. + +In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/master/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. Issue [#28736](https://github.com/grafana/grafana/issues/28736) + +### Deprecations + + +#### Query variable value group tags + +This option to group query variable values into groups by tags has been an experimental feature since it was introduced. It was introduced to work around the lack of tags support in time series databases at the time. Now that tags (ie. labels) are the norm there is no longer any great need for this feature. This feature will be removed in Grafana v8 later this year. Issue [#30160](https://github.com/grafana/grafana/issues/30160) + +### Plugin development fixes & changes + +* **AngularPlugins**: Angular controller events emitter is now a separate emitter and not the same as PanelModel events emitter. [#30379](https://github.com/grafana/grafana/pull/30379), [@torkelo](https://github.com/torkelo) +* **FieldConfig API**: Add ability to hide field option or disable it from the overrides. [#29879](https://github.com/grafana/grafana/pull/29879), [@dprokop](https://github.com/dprokop) +* **Select**: Changes default menu placement for Select from auto to bottom. [#29837](https://github.com/grafana/grafana/pull/29837), [@hugohaggmark](https://github.com/hugohaggmark) +* **Collapse**: Allow component children to use height: 100% styling. [#29776](https://github.com/grafana/grafana/pull/29776), [@aocenas](https://github.com/aocenas) +* **DataSourceWithBackend**: Throw error if health check fails in DataSourceWithBackend. [#29743](https://github.com/grafana/grafana/pull/29743), [@aocenas](https://github.com/aocenas) +* **NodeGraph**: Add node graph visualization. [#29706](https://github.com/grafana/grafana/pull/29706), [@aocenas](https://github.com/aocenas) +* **FieldColor**: Handling color changes when switching panel types. [#28875](https://github.com/grafana/grafana/pull/28875), [@dprokop](https://github.com/dprokop) +* **CodeEditor**: Added support for javascript language. [#28818](https://github.com/grafana/grafana/pull/28818), [@ae3e](https://github.com/ae3e) +* **grafana/toolkit**: Allow builds with lint warnings. [#28810](https://github.com/grafana/grafana/pull/28810), [@dprokop](https://github.com/dprokop) +* **grafana/toolkit**: Drop console and debugger statements by default when building plugin. [#28776](https://github.com/grafana/grafana/pull/28776), [@dprokop](https://github.com/dprokop) +* **Card**: Add new Card component. [#28216](https://github.com/grafana/grafana/pull/28216), [@Clarity-89](https://github.com/Clarity-89) +* **FieldConfig**: Implementation slider editor (#27592). [#28007](https://github.com/grafana/grafana/pull/28007), [@isaozlerfm](https://github.com/isaozlerfm) +* **MutableDataFrame**: Remove unique field name constraint and values field index and unused/seldom used stuff. [#27573](https://github.com/grafana/grafana/pull/27573), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.7 (2021-01-14) + +### Bug fixes + +* **Auth**: Add missing request headers to SigV4 middleware allowlist. [#30115](https://github.com/grafana/grafana/pull/30115), [@wbrowne](https://github.com/wbrowne) +* **Elasticsearch**: Sort results by index order as well as @timestamp. [#29761](https://github.com/grafana/grafana/pull/29761), [@STEELBADGE](https://github.com/STEELBADGE) +* **SAML**: Fixes bug in processing SAML response with empty element by updating saml library (Enterprise). [#30179](https://github.com/grafana/grafana/pull/30179), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **SeriesToRows**: Fixes issue in transform so that value field is always named Value. [#30054](https://github.com/grafana/grafana/pull/30054), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.6 (2020-12-17) + +### Security + +* **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise. [#29875](https://github.com/grafana/grafana/issues/29875) + + + + + +# 7.3.5 (2020-12-10) + +### Features and enhancements + +* **Alerting**: Improve Prometheus Alert Rule error message. [#29390](https://github.com/grafana/grafana/pull/29390), [@wbrowne](https://github.com/wbrowne) + +### Bug fixes + +* **Alerting**: Fix alarm message formatting in Dingding. [#29482](https://github.com/grafana/grafana/pull/29482), [@tomowang](https://github.com/tomowang) +* **AzureMonitor**: Fix unit translation for MilliSeconds. [#29399](https://github.com/grafana/grafana/pull/29399), [@secustor](https://github.com/secustor) +* **Instrumentation**: Fix bug with invalid handler label value for HTTP request metrics. [#29529](https://github.com/grafana/grafana/pull/29529), [@bergquist](https://github.com/bergquist) +* **Prometheus**: Fixes problem where changing display name in Field tab had no effect. [#29441](https://github.com/grafana/grafana/pull/29441), [@zoltanbedi](https://github.com/zoltanbedi) +* **Tracing**: Fixed issue showing more than 300 spans. [#29377](https://github.com/grafana/grafana/pull/29377), [@zoltanbedi](https://github.com/zoltanbedi) + + + + + +# 7.3.4 (2020-11-24) + +### Bug fixes + +* **Dashboard**: Fixes kiosk state after being redirected to login page and back. [#29273](https://github.com/grafana/grafana/pull/29273), [@torkelo](https://github.com/torkelo) +* **InfluxDB**: Update flux library to fix support for boolean label values. [#29310](https://github.com/grafana/grafana/pull/29310), [@ryantxu](https://github.com/ryantxu) +* **Security**: Fixes minor security issue with alert notification webhooks that allowed GET & DELETE requests. [#29330](https://github.com/grafana/grafana/pull/29330), [@wbrowne](https://github.com/wbrowne) +* **Table**: Fixes issues with phantom extra 0 for zero values. [#29165](https://github.com/grafana/grafana/pull/29165), [@dprokop](https://github.com/dprokop) + + + + + +# 7.3.3 (2020-11-17) + +### Bug fixes + +* **Cloud monitoring**: Fix for multi-value template variable for project selector. [#29042](https://github.com/grafana/grafana/pull/29042), [@papagian](https://github.com/papagian) +* **LogsPanel**: Fixes problem dragging scrollbar inside logs panel. [#28974](https://github.com/grafana/grafana/pull/28974), [@aocenas](https://github.com/aocenas) +* **Provisioning**: Fixes application not pinned to the sidebar when it's enabled. [#29084](https://github.com/grafana/grafana/pull/29084), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **StatPanel**: Fixes hanging issue when all values are zero. [#29077](https://github.com/grafana/grafana/pull/29077), [@torkelo](https://github.com/torkelo) +* **Thresholds**: Fixes color assigned to null values. [#29010](https://github.com/grafana/grafana/pull/29010), [@torkelo](https://github.com/torkelo) + + + + + +# 7.3.2 (2020-11-11) + + ### Features / Enhancements + * **CloudWatch Logs**: Change how we measure query progress. [#28912](https://github.com/grafana/grafana/pull/28912), [@aocenas](https://github.com/aocenas) + * **Dashboards / Folders**: delete related data (permissions, stars, tags, versions, annotations) when deleting a dashboard or a folder. [#28826](https://github.com/grafana/grafana/pull/28826), [@AgnesToulet](https://github.com/AgnesToulet) + * **Gauge**: Improve font size auto sizing. [#28797](https://github.com/grafana/grafana/pull/28797), [@torkelo](https://github.com/torkelo) + * **Short URL**: Cleanup unvisited/stale short URLs. [#28867](https://github.com/grafana/grafana/pull/28867), [@wbrowne](https://github.com/wbrowne) + * **Templating**: Custom variable edit UI, change options input into textarea. [#28322](https://github.com/grafana/grafana/pull/28322), [@darrylsepeda](https://github.com/darrylsepeda) + + ### Bug Fixes + * **Cloudwatch**: Fix issue with field calculation transform not working properly with Cloudwatch data. [#28761](https://github.com/grafana/grafana/pull/28761), [@torkelo](https://github.com/torkelo) + * **Dashboard**: fix view panel mode for Safari / iOS. [#28702](https://github.com/grafana/grafana/pull/28702), [@jackw](https://github.com/jackw) + * **Elasticsearch**: Exclude pipeline aggregations from order by options. [#28620](https://github.com/grafana/grafana/pull/28620), [@simianhacker](https://github.com/simianhacker) + * **Panel inspect**: Interpolate variables in panel inspect title. [#28779](https://github.com/grafana/grafana/pull/28779), [@dprokop](https://github.com/dprokop) + * **Prometheus**: Fix copy paste behaving as cut and paste. [#28622](https://github.com/grafana/grafana/pull/28622), [@aocenas](https://github.com/aocenas) + * **StatPanels**: Fixes auto min max when latest value is zero. [#28982](https://github.com/grafana/grafana/pull/28982), [@torkelo](https://github.com/torkelo) + * **TableFilters**: Fixes filtering with field overrides. [#28690](https://github.com/grafana/grafana/pull/28690), [@hugohaggmark](https://github.com/hugohaggmark) + * **Templating**: Speeds up certain variable queries for Postgres MySql MSSql. [#28686](https://github.com/grafana/grafana/pull/28686), [@hugohaggmark](https://github.com/hugohaggmark) + * **Units**: added support to handle negative fractional numbers. [#28849](https://github.com/grafana/grafana/pull/28849), [@mckn](https://github.com/mckn) + * **Variables**: Fix backward compatibility in custom variable options that contain colon. [#28896](https://github.com/grafana/grafana/pull/28896), [@mckn](https://github.com/mckn) + + + +# 7.3.1 (2020-10-30) + + ### Bug Fixes + * **Cloudwatch**: Fix duplicate metric data. [#28642](https://github.com/grafana/grafana/pull/28642), [@zoltanbedi](https://github.com/zoltanbedi) + * **Loki**: Fix error when some queries return zero results. [#28645](https://github.com/grafana/grafana/pull/28645), [@ivanahuckova](https://github.com/ivanahuckova) + * **PanelMenu**: Fix panel submenu not being accessible for panels close to the right edge of the screen. [#28666](https://github.com/grafana/grafana/pull/28666), [@torkelo](https://github.com/torkelo) + * **Plugins**: Fix descendent frontend plugin signature validation. [#28638](https://github.com/grafana/grafana/pull/28638), [@wbrowne](https://github.com/wbrowne) + * **StatPanel**: Fix value being under graph and reduced likelihood for white and dark value text mixing. [#28641](https://github.com/grafana/grafana/pull/28641), [@torkelo](https://github.com/torkelo) + * **TextPanel**: Fix problems where text panel would show old content. [#28643](https://github.com/grafana/grafana/pull/28643), [@torkelo](https://github.com/torkelo) + +# 7.3.0 (2020-10-28) + + ### Features / Enhancements + * **AzureMonitor**: Support decimal (as float64) type in analytics/logs. [#28480](https://github.com/grafana/grafana/pull/28480), [@kylebrandt](https://github.com/kylebrandt) + * **Plugins signing**: UI information. [#28469](https://github.com/grafana/grafana/pull/28469), [@dprokop](https://github.com/dprokop) + * **Short URL**: Update last seen at when visiting a short URL. [#28565](https://github.com/grafana/grafana/pull/28565), [@marefr](https://github.com/marefr) + + ### Bug Fixes + * **Alerting**: Log warnings for obsolete notifiers when extracting alerts and remove frequent error log messages. [#28162](https://github.com/grafana/grafana/pull/28162), [@papagian](https://github.com/papagian) + * **Auth**: Fix SigV4 request verification step for Amazon Elasticsearch Service. [#28481](https://github.com/grafana/grafana/pull/28481), [@wbrowne](https://github.com/wbrowne) + * **Auth**: Should redirect to login when anonymous enabled and URL with different org than anonymous specified. [#28158](https://github.com/grafana/grafana/pull/28158), [@marefr](https://github.com/marefr) + * **Elasticsearch**: Fix handling of errors when testing data source. [#28498](https://github.com/grafana/grafana/pull/28498), [@marefr](https://github.com/marefr) + * **Graphite**: Fix default version to be 1.1. [#28471](https://github.com/grafana/grafana/pull/28471), [@ivanahuckova](https://github.com/ivanahuckova) + * **StatPanel**: Fixes BizChart error max: yyy should not be less than min zzz. [#28587](https://github.com/grafana/grafana/pull/28587), [@hugohaggmark](https://github.com/hugohaggmark) + + +# 7.3.0-beta2 (2020-10-22) + +### Features / Enhancements +* **Add monitoring mixing for Grafana**. [#28285](https://github.com/grafana/grafana/pull/28285), [@bergquist](https://github.com/bergquist) +* **CloudWatch**: Missing Namespace AWS/EC2CapacityReservations. [#28309](https://github.com/grafana/grafana/pull/28309), [@nonamef](https://github.com/nonamef) +* **Explore**: Support wide data frames. [#28393](https://github.com/grafana/grafana/pull/28393), [@aocenas](https://github.com/aocenas) +* **Instrumentation**: Add counters and histograms for database queries. [#28236](https://github.com/grafana/grafana/pull/28236), [@bergquist](https://github.com/bergquist) +* **Loki**: Visually distinguish error logs for LogQL2. [#28359](https://github.com/grafana/grafana/pull/28359), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug Fixes +* **API**: Fix short URLs. [#28300](https://github.com/grafana/grafana/pull/28300), [@aknuds1](https://github.com/aknuds1) +* **BackendSrv**: Fixes queue countdown when unsubscribe is before response. [#28323](https://github.com/grafana/grafana/pull/28323), [@hugohaggmark](https://github.com/hugohaggmark) +* **CloudWatch/Athena - valid metrics and dimensions.**. [#28436](https://github.com/grafana/grafana/pull/28436), [@kwarunek](https://github.com/kwarunek) +* **Dashboard links**: Places drop down list so it's always visible. [#28330](https://github.com/grafana/grafana/pull/28330), [@maknik](https://github.com/maknik) +* **Graph**: Fix for graph size not taking up full height or width. [#28314](https://github.com/grafana/grafana/pull/28314), [@jackw](https://github.com/jackw) +* **Loki**: Base maxDataPoints limits on query type. [#28298](https://github.com/grafana/grafana/pull/28298), [@aocenas](https://github.com/aocenas) +* **Loki**: Run instant query only when doing metric query. [#28325](https://github.com/grafana/grafana/pull/28325), [@aocenas](https://github.com/aocenas) +* **Plugins**: Don't exit on duplicate plugin. [#28390](https://github.com/grafana/grafana/pull/28390), [@aknuds1](https://github.com/aknuds1) + +# 7.3.0-beta1 (2020-10-15) + +### Breaking changes + +- **CloudWatch**: The AWS CloudWatch data source's authentication scheme has changed. See the [upgrade notes](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v73) for details and how this may affect you. +- **Docker**: The Grafana docker image will run with the root group instead of the Grafana group. This may break builds for users who extend the official Docker images. Refer to the [upgrade notes](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v73) for details. + +### Features / Enhancements +* **Alerting**: Add labels to name when converting data frame to series. [#28085](https://github.com/grafana/grafana/pull/28085), [@kylebrandt](https://github.com/kylebrandt) +* **Alerting**: Ensuring LINE Notify notifications are sent for all alert states. [#27639](https://github.com/grafana/grafana/pull/27639), [@haraldkubota](https://github.com/haraldkubota) +* **Auth**: Add SigV4 auth option to datasources. [#27552](https://github.com/grafana/grafana/pull/27552), [@wbrowne](https://github.com/wbrowne) +* **AzureMonitor**: Pass through null values instead of setting 0. [#28126](https://github.com/grafana/grafana/pull/28126), [@kylebrandt](https://github.com/kylebrandt) +* **Cloud Monitoring**: Out-of-the-box dashboards. [#27864](https://github.com/grafana/grafana/pull/27864), [@papagian](https://github.com/papagian) +* **CloudWatch**: Add support for AWS DirectConnect virtual interface metrics and add missing dimensions. [#28008](https://github.com/grafana/grafana/pull/28008), [@jgulick48](https://github.com/jgulick48) +* **CloudWatch**: Adding support for Amazon ElastiCache Redis metrics. [#28040](https://github.com/grafana/grafana/pull/28040), [@jgulick48](https://github.com/jgulick48) +* **CloudWatch**: Adding support for additional Amazon CloudFront metrics. [#28069](https://github.com/grafana/grafana/pull/28069), [@darrylsepeda](https://github.com/darrylsepeda) +* **CloudWatch**: Re-implement authentication. [#25548](https://github.com/grafana/grafana/pull/25548), [@aknuds1](https://github.com/aknuds1),[@patstrom](https://github.com/patstrom) +* **Dashboard**: Allow shortlink generation. [#27409](https://github.com/grafana/grafana/pull/27409), [@MisterSquishy](https://github.com/MisterSquishy) +* **Docker**: OpenShift compatability. [#27813](https://github.com/grafana/grafana/pull/27813), [@xlson](https://github.com/xlson) +* **Elasticsearch**: Support multiple pipeline aggregations for a query. [#27945](https://github.com/grafana/grafana/pull/27945), [@simianhacker](https://github.com/simianhacker) +* **Explore**: Allow shortlink generation. [#28222](https://github.com/grafana/grafana/pull/28222), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Remove collapsing of visualisations. [#27026](https://github.com/grafana/grafana/pull/27026), [@ivanahuckova](https://github.com/ivanahuckova) +* **FieldColor**: Adds new standard color option for color. [#28039](https://github.com/grafana/grafana/pull/28039), [@torkelo](https://github.com/torkelo) +* **Gauge**: Improve text sizing and support non threshold color modes. [#28256](https://github.com/grafana/grafana/pull/28256), [@torkelo](https://github.com/torkelo) +* **NamedColors**: Named colors refactors. [#28235](https://github.com/grafana/grafana/pull/28235), [@torkelo](https://github.com/torkelo) +* **Panel Inspect**: Allow CSV download for Excel. [#27284](https://github.com/grafana/grafana/pull/27284), [@tomdaly](https://github.com/tomdaly) +* **Prometheus**: Add time range parameters to labels API. [#27548](https://github.com/grafana/grafana/pull/27548), [@kakkoyun](https://github.com/kakkoyun) +* **Snapshots**: Store dashboard data encrypted in the database. [#28129](https://github.com/grafana/grafana/pull/28129), [@wbrowne](https://github.com/wbrowne) +* **Table**: New cell hover behavior and image cell display mode. [#27669](https://github.com/grafana/grafana/pull/27669), [@torkelo](https://github.com/torkelo) +* **Timezones**: Include IANA timezone canonical name in TimeZoneInfo. [#27591](https://github.com/grafana/grafana/pull/27591), [@dprokop](https://github.com/dprokop) +* **Tracing**: Add Tempo data source. [#28204](https://github.com/grafana/grafana/pull/28204), [@aocenas](https://github.com/aocenas) +* **Transformations**: Add Concatenate fields transformer. [#28237](https://github.com/grafana/grafana/pull/28237), [@ryantxu](https://github.com/ryantxu) +* **Transformations**: improve the reduce transformer. [#27875](https://github.com/grafana/grafana/pull/27875), [@ryantxu](https://github.com/ryantxu) +* **Users**: Expire old user invites. [#27361](https://github.com/grafana/grafana/pull/27361), [@wbrowne](https://github.com/wbrowne) +* **Variables**: Adds loading state and indicators. [#27917](https://github.com/grafana/grafana/pull/27917), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Adds support for key/value mapping in Custom variable. [#27829](https://github.com/grafana/grafana/pull/27829), [@sartaj10](https://github.com/sartaj10) +* **grafana/toolkit**: expose Jest maxWorkers arg for plugin test & build tasks. [#27724](https://github.com/grafana/grafana/pull/27724), [@domasx2](https://github.com/domasx2) + +### Bug Fixes +* **Azure Analytics**: FormatAs Time series groups bool columns wrong. [#27713](https://github.com/grafana/grafana/issues/27713) +* **Azure**: Fixes cancellation of requests with different Azure sources. [#28180](https://github.com/grafana/grafana/pull/28180), [@hugohaggmark](https://github.com/hugohaggmark) +* **BackendSrv**: Reloads page instead of redirect on Unauthorized Error. [#28276](https://github.com/grafana/grafana/pull/28276), [@hugohaggmark](https://github.com/hugohaggmark) +* **Dashboard**: Do not allow users without edit permission to a folder to see new dashboard page. [#28249](https://github.com/grafana/grafana/pull/28249), [@torkelo](https://github.com/torkelo) +* **Dashboard**: Fixed issue accessing horizontal table scrollbar when placed at bottom of dashboard. [#28250](https://github.com/grafana/grafana/pull/28250), [@torkelo](https://github.com/torkelo) +* **DataProxy**: Add additional settings for dataproxy to help with network proxy timeouts. [#27841](https://github.com/grafana/grafana/pull/27841), [@kahinton](https://github.com/kahinton) +* **Database**: Adds new indices to alert_notification_state and alert_rule_tag tables. [#28166](https://github.com/grafana/grafana/pull/28166), [@KarineValenca](https://github.com/KarineValenca) +* **Explore**: Fix showing of Prometheus data in Query inspector. [#28128](https://github.com/grafana/grafana/pull/28128), [@ivanahuckova](https://github.com/ivanahuckova) +* **Explore**: Show results of Prometheus instant queries in formatted table. [#27767](https://github.com/grafana/grafana/pull/27767), [@ivanahuckova](https://github.com/ivanahuckova) +* **Graph**: Prevent legend from overflowing container. [#28254](https://github.com/grafana/grafana/pull/28254), [@jackw](https://github.com/jackw) +* **OAuth**: Fix token refresh failure when custom SSL settings are configured for OAuth provider. [#27523](https://github.com/grafana/grafana/pull/27523), [@billoley](https://github.com/billoley) +* **Plugins**: Let descendant plugins inherit their root's signature. [#27970](https://github.com/grafana/grafana/pull/27970), [@aknuds1](https://github.com/aknuds1) +* **Runtime**: Fix handling of short-lived background services. [#28025](https://github.com/grafana/grafana/pull/28025), [@ahlaw](https://github.com/ahlaw) +* **TemplateSrv**: Fix interpolating strings with object variables. [#28171](https://github.com/grafana/grafana/pull/28171), [@torkelo](https://github.com/torkelo) +* **Variables**: Fixes so constants set from url get completed state. [#28257](https://github.com/grafana/grafana/pull/28257), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Prevent adhoc filters from crashing when they are not loaded properly. [#28226](https://github.com/grafana/grafana/pull/28226), [@mckn](https://github.com/mckn) + + + +# 7.2.3 (2020-12-17) + +### Security + +- **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise [#29875](https://github.com/grafana/grafana/issues/29875), [@bergquist](https://github.com/bergquist) + + + +# 7.2.2 (2020-10-21) + +### Features / Enhancements + +**Caution:** Please do not use/enable the `database_metrics` feature flag. It will corrupt MySQL database tables. See [#28440](https://github.com/grafana/grafana/issues/28440) for more information. + +~~**Instrumentation**: Add counters and histograms for database queries. [#28236](https://github.com/grafana/grafana/pull/28236), [@bergquist](https://github.com/bergquist)~~ + +- **Instrumentation**: Add histogram for request duration. [#28364](https://github.com/grafana/grafana/pull/28364), [@bergquist](https://github.com/bergquist) +- **Instrumentation**: Adds environment_info metric. [#28355](https://github.com/grafana/grafana/pull/28355), [@bergquist](https://github.com/bergquist) + +### Bug Fixes + +- **CloudWatch**: Fix custom metrics. [#28391](https://github.com/grafana/grafana/pull/28391), [@aknuds1](https://github.com/aknuds1) + +# 7.2.1 (2020-10-08) + +### Features / Enhancements +* **Api**: Add /healthz endpoint for health checks. [#27536](https://github.com/grafana/grafana/pull/27536), [@bergquist](https://github.com/bergquist) +* **Api**: Healthchecks should not be rejected due to domain enforcement checks. [#27981](https://github.com/grafana/grafana/pull/27981), [@bergquist](https://github.com/bergquist) +* **Instrumentation**: Removes invalid chars from label names. [#27921](https://github.com/grafana/grafana/pull/27921), [@bergquist](https://github.com/bergquist) +* **Orgs**: Remove organisations deprecation notice from backend. [#27788](https://github.com/grafana/grafana/pull/27788), [@wbrowne](https://github.com/wbrowne) +* **grafana/toolkit**: Add --coverage flag to plugin build command. [#27743](https://github.com/grafana/grafana/pull/27743), [@gassiss](https://github.com/gassiss) + +### Bug Fixes +* **BarGauge**: Fixed scrollbar showing for bar gauge in Firefox. [#27784](https://github.com/grafana/grafana/pull/27784), [@torkelo](https://github.com/torkelo) +* **Dashboard**: Honour root_url for Explore link. [#27654](https://github.com/grafana/grafana/pull/27654), [@tiagomotasantos](https://github.com/tiagomotasantos) +* **DashboardLinks**: values in links are updated when variables change. [#27926](https://github.com/grafana/grafana/pull/27926), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Add query's refId to each series returned by a query. [#27614](https://github.com/grafana/grafana/pull/27614), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix ad-hoc filter support for Raw Data query and new table panel. [#28064](https://github.com/grafana/grafana/pull/28064), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixed histogram bucket calculations to avoid missing buckets. [#27883](https://github.com/grafana/grafana/pull/27883), [@torkelo](https://github.com/torkelo) +* **Loki**: Run instant query only in Explore. [#27974](https://github.com/grafana/grafana/pull/27974), [@ivanahuckova](https://github.com/ivanahuckova) +* **Units**: bps & Bps default scale remains decimal (backwards-compatibility). [#27838](https://github.com/grafana/grafana/pull/27838), [@Berbe](https://github.com/Berbe) +* **ValueMappings**: Fix issue with value mappings in override applying to all columns. [#27718](https://github.com/grafana/grafana/pull/27718), [@torkelo](https://github.com/torkelo) + +# 7.2.0 (2020-09-23) + +### Features / Enhancements +- **Alerting**: Ensuring notifications displayed correctly in mobile device with Google Chat. [#27578](https://github.com/grafana/grafana/pull/27578), [@alvarolmedo](https://github.com/alvarolmedo) +- **TraceView**: Show full traceID and better discern multiple stackTraces in span details. [#27710](https://github.com/grafana/grafana/pull/27710), [@aocenas](https://github.com/aocenas) + +### Bug Fixes +- **DataLinks**: Fixes issue with data links not interpolating values with correct field config. [#27622](https://github.com/grafana/grafana/pull/27622), [@torkelo](https://github.com/torkelo) +- **DataProxy**: Ignore empty URL's in plugin routes. [#27653](https://github.com/grafana/grafana/pull/27653), [@domasx2](https://github.com/domasx2) +- **Field config**: Respect config paths when rendering default value of field config property. [#27652](https://github.com/grafana/grafana/pull/27652), [@dprokop](https://github.com/dprokop) +- **Field config**: Fix mismatch in field config editor types. [#27657](https://github.com/grafana/grafana/pull/27657), [@dprokop](https://github.com/dprokop) +- **Panel editor**: Prevents adding transformations in panels with alerts. [#27706](https://github.com/grafana/grafana/pull/27706), [@hugohaggmark](https://github.com/hugohaggmark) +- **Stat panel**: Fix problem where string values where always green. [#27656](https://github.com/grafana/grafana/pull/27656), [@peterholmberg](https://github.com/peterholmberg) + +# 7.2.0-beta2 (2020-09-17) + +### Features / Enhancements + - **API**: Enrich add user to org endpoints with user ID in the response. [#27551](https://github.com/grafana/grafana/pull/27551), [@AgnesToulet](https://github.com/AgnesToulet) + - **API**: Enrich responses and improve error handling for alerting API endpoints. [#27550](https://github.com/grafana/grafana/pull/27550), [@AgnesToulet](https://github.com/AgnesToulet) + - **Auth**: Replace maximum inactive/lifetime settings of days to duration. [#27150](https://github.com/grafana/grafana/pull/27150), [@Hansuuuuuuuuuu](https://github.com/Hansuuuuuuuuuu) + - **Dashboard**: Support configuring default timezone via config file. [#27404](https://github.com/grafana/grafana/pull/27404), [@woutersmeenk](https://github.com/woutersmeenk) + - **Elasticsearch**: Add support for date_nanos type. [#27538](https://github.com/grafana/grafana/pull/27538), [@Elfo404](https://github.com/Elfo404) + - **Elasticsearch**: Allow fields starting with underscore. [#27520](https://github.com/grafana/grafana/pull/27520), [@Elfo404](https://github.com/Elfo404) + - **Elasticsearch**: Increase maximum geohash aggregation precision to 12. [#27539](https://github.com/grafana/grafana/pull/27539), [@Elfo404](https://github.com/Elfo404) + - **Field config**: Add support for paths in default field config setup. [#27570](https://github.com/grafana/grafana/pull/27570), [@dprokop](https://github.com/dprokop) + - **Postgres**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27478](https://github.com/grafana/grafana/pull/27478), [@hugohaggmark](https://github.com/hugohaggmark) + - **Provisioning**: Remove provisioned dashboards without parental reader. [#26143](https://github.com/grafana/grafana/pull/26143), [@nabokihms](https://github.com/nabokihms) + - **Variables**: Limit rendering of options in dropdown to improve search performance. [#27525](https://github.com/grafana/grafana/pull/27525), [@guoqn](https://github.com/guoqn) + - **Units**: Binary-prefixed data rates. [#27022](https://github.com/grafana/grafana/pull/27022), [@Berbe](https://github.com/Berbe) + + ### Bug Fixes + - **Admin**: Fixes close('X') button layout issue in API keys page. [#27625](https://github.com/grafana/grafana/pull/27625), [@nikasvan](https://github.com/nikasvan) + - **Alerting**: Fix integration key so it's stored encrypted for Pagerduty notifier. [#27484](https://github.com/grafana/grafana/pull/27484), [@marefr](https://github.com/marefr) + - **Annotations**: Fixes issue with showing error notice for cancelled annotation queries. [#27557](https://github.com/grafana/grafana/pull/27557), [@torkelo](https://github.com/torkelo) + - **Azure/Insights**: Fix handling of legacy dimension values. [#27513](https://github.com/grafana/grafana/pull/27513), [@marefr](https://github.com/marefr) + - **DataLinks**: Respects display name and adds field quoting. [#27616](https://github.com/grafana/grafana/pull/27616), [@hugohaggmark](https://github.com/hugohaggmark) + - **ImageRendering**: Fix rendering panel using shared query in png, PDF reports and embedded scenarios. [#27628](https://github.com/grafana/grafana/pull/27628), [@torkelo](https://github.com/torkelo) + - **InputControl**: Fixed using InputControl in unit tests from plugins. [#27615](https://github.com/grafana/grafana/pull/27615), [@torkelo](https://github.com/torkelo) + - **NewsPanel**: Fixed XSS issue when rendering rss links. [#27612](https://github.com/grafana/grafana/pull/27612), [@torkelo](https://github.com/torkelo) + - **Transforms**: Fix for issue in labels to fields transform where the new option value field name did not work properly. [#27501](https://github.com/grafana/grafana/pull/27501), [@torkelo](https://github.com/torkelo) + +# 7.2.0-beta1 (2020-09-09) + +### Breaking changes + +- **Units**: The date time units `YYYY-MM-DD HH:mm:ss` and `MM/DD/YYYY h:mm:ss a` have been renamed to `Datetime ISO` + and `Datetime US` respectively. This is no breaking change just a visual name change (the unit id is unchanged). The + unit behavior is different however, it no longer hides the date part if the date is today. If you want this old + behavior you need to change unit to `Datetime ISO (No date if today)` or `Datetime US (No date if today)`. + +### Features / Enhancements + +- **API**: Return ID of the deleted resource for dashboard, datasource and folder DELETE endpoints. [#26691](https://github.com/grafana/grafana/pull/26691), [@AgnesToulet](https://github.com/AgnesToulet) +- **API**: Support paging in the admin orgs list API. [#26932](https://github.com/grafana/grafana/pull/26932), [@benjaminjb](https://github.com/benjaminjb) +- **API**: return resource ID for auth key creation, folder permissions update and user invite complete endpoints. [#27419](https://github.com/grafana/grafana/pull/27419), [@AgnesToulet](https://github.com/AgnesToulet) +- **Alerting**: Add toggle to disable alert threshold visibility in graph panel. [#25705](https://github.com/grafana/grafana/pull/25705), [@jpalpant](https://github.com/jpalpant) +- **Alerting**: Adds support for overriding 'dedup_key' via alert tags when using the Pagerduty notifier. [#27356](https://github.com/grafana/grafana/pull/27356), [@alavrovinfb](https://github.com/alavrovinfb) +- **Alerting**: Change alert rule link in alert notifications to open panel in view mode. [#27378](https://github.com/grafana/grafana/pull/27378), [@robertlestak](https://github.com/robertlestak) +- **Alerting**: Support storing sensitive notifier settings securely/encrypted. [#25114](https://github.com/grafana/grafana/pull/25114), [@mtanda](https://github.com/mtanda) +- **Annotation**: Add clean up job for old annotations. [#26156](https://github.com/grafana/grafana/pull/26156), [@bergquist](https://github.com/bergquist) +- **AzureMonitor**: select plugin route from cloudname. [#27273](https://github.com/grafana/grafana/pull/27273), [@kylebrandt](https://github.com/kylebrandt) +- **BackendSrv**: Uses credentials, deprecates withCredentials & defaults to same-origin. [#27385](https://github.com/grafana/grafana/pull/27385), [@hugohaggmark](https://github.com/hugohaggmark) +- **Chore**: Upgrade to Go 1.15.1. [#27326](https://github.com/grafana/grafana/pull/27326), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch**: Update list of AmazonMQ metrics and dimensions. [#27332](https://github.com/grafana/grafana/pull/27332), [@szymonpk](https://github.com/szymonpk) +- **Cloudwatch**: Add Support for external ID in assume role. [#23685](https://github.com/grafana/grafana/pull/23685), [@gdhananjay](https://github.com/gdhananjay) +- **Cloudwatch**: Add af-south-1 region. [#26513](https://github.com/grafana/grafana/pull/26513), [@ruanbekker](https://github.com/ruanbekker) +- **Dashboard**: Add Duplicate dashboard links button to links list. [#26600](https://github.com/grafana/grafana/pull/26600), [@Hmerac](https://github.com/Hmerac) +- **Dashboard**: Adds folder name and link to the dashboard overview on the homepage. [#27214](https://github.com/grafana/grafana/pull/27214), [@michelengelen](https://github.com/michelengelen) +- **Database**: Set 0640 permissions on SQLite database file. [#26339](https://github.com/grafana/grafana/pull/26339), [@aknuds1](https://github.com/aknuds1) +- **DateFormats**: Default ISO & US formats never omit date part even if date is today (breaking change). [#27300](https://github.com/grafana/grafana/pull/27300), [@torkelo](https://github.com/torkelo) +- **Explore/Loki**: POC for toggling parsed fields in the list view. [#26178](https://github.com/grafana/grafana/pull/26178), [@fredr](https://github.com/fredr) +- **Explore**: Sort order of log results. [#26669](https://github.com/grafana/grafana/pull/26669), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Transform prometheus query to elasticsearch query. [#23670](https://github.com/grafana/grafana/pull/23670), [@melchiormoulin](https://github.com/melchiormoulin) +- **Field overrides**: Overrides UI improvements. [#27073](https://github.com/grafana/grafana/pull/27073), [@dprokop](https://github.com/dprokop) +- **Heatmap**: Reduce the aggressiveness of hiding ticks/labels when panel is small. [#27016](https://github.com/grafana/grafana/pull/27016), [@lrstanley](https://github.com/lrstanley) +- **Image Store**: Add support for using signed URLs when uploading images to GCS. [#26840](https://github.com/grafana/grafana/pull/26840), [@marcosrmendezthd](https://github.com/marcosrmendezthd) +- **Image Store**: Fallback to application default credentials when no key file is specified for GCS. [#25948](https://github.com/grafana/grafana/pull/25948), [@Eraac](https://github.com/Eraac) +- **InfluxDB/Flux**: Increase series limit for Flux datasource. [#26746](https://github.com/grafana/grafana/pull/26746), [@sneddrs](https://github.com/sneddrs) +- **InfluxDB**: exclude result and table column from Flux table results. [#27081](https://github.com/grafana/grafana/pull/27081), [@ryantxu](https://github.com/ryantxu) +- **InfluxDB**: return a table rather than an error when timeseries is missing time. [#27320](https://github.com/grafana/grafana/pull/27320), [@ryantxu](https://github.com/ryantxu) +- **Instrumentation**: Adds instrumentation for outgoing datasource requests. [#27427](https://github.com/grafana/grafana/pull/27427), [@bergquist](https://github.com/bergquist) +- **Loki**: Add scopedVars support in legend formatting for repeated variables. [#27046](https://github.com/grafana/grafana/pull/27046), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki**: Re-introduce running of instant queries. [#27213](https://github.com/grafana/grafana/pull/27213), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27265](https://github.com/grafana/grafana/pull/27265), [@hugohaggmark](https://github.com/hugohaggmark) +- **MixedDatasource**: Shows retrieved data even if a data source fails. [#27024](https://github.com/grafana/grafana/pull/27024), [@hugohaggmark](https://github.com/hugohaggmark) +- **OAuth**: Handle DEFLATE compressed payloads in JWT for Generic OAuth. [#26969](https://github.com/grafana/grafana/pull/26969), [@billoley](https://github.com/billoley) +- **OAuth**: Increase state cookie max age. [#27258](https://github.com/grafana/grafana/pull/27258), [@bergquist](https://github.com/bergquist) +- **Orgs**: Remove org deprecation notice as we have decided to preserve multi-org support. [#26853](https://github.com/grafana/grafana/pull/26853), [@torkelo](https://github.com/torkelo) +- **PanelInspector**: Adds a Raw display mode but defaults to Formatted display mode. [#27306](https://github.com/grafana/grafana/pull/27306), [@hugohaggmark](https://github.com/hugohaggmark) +- **Postgres**: Support Unix socket for host. [#25778](https://github.com/grafana/grafana/pull/25778), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Add scopedVars support in legend formatting for repeated variables. [#27047](https://github.com/grafana/grafana/pull/27047), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Support request cancellation properly (Uses new backendSrv.fetch Observable request API). [#27090](https://github.com/grafana/grafana/pull/27090), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: add $__rate_interval variable. [#26937](https://github.com/grafana/grafana/pull/26937), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Validate that datasource access field equals to direct or proxy. [#26440](https://github.com/grafana/grafana/pull/26440), [@nabokihms](https://github.com/nabokihms) +- **RangeUtils**: migrate logic from kbn to grafana/data. [#27347](https://github.com/grafana/grafana/pull/27347), [@ryantxu](https://github.com/ryantxu) +- **Table**: Adds column filtering. [#27225](https://github.com/grafana/grafana/pull/27225), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Support showing numbers in strings with full original value. [#27097](https://github.com/grafana/grafana/pull/27097), [@torkelo](https://github.com/torkelo) +- **TablePanel**: Add support for basic gauge as a cell display mode. [#26595](https://github.com/grafana/grafana/pull/26595), [@jutley](https://github.com/jutley) +- **Transformations**: Group by and aggregate on multiple fields. [#25498](https://github.com/grafana/grafana/pull/25498), [@Totalus](https://github.com/Totalus) +- **Transformations**: enable transformations reorder. [#27197](https://github.com/grafana/grafana/pull/27197), [@dprokop](https://github.com/dprokop) +- **Units**: Allow re-scaling nanoseconds up to days. [#26458](https://github.com/grafana/grafana/pull/26458), [@kaydelaney](https://github.com/kaydelaney) +- **grafana-cli**: Add ability to read password from stdin to reset admin password. [#26016](https://github.com/grafana/grafana/pull/26016), [@nabokihms](https://github.com/nabokihms) +- **Reporting**: add branding options. (Enterprise) +- **Reporting**: allow setting custom timerange. (Enterprise) + +### Bug Fixes +- **Auth**: Fix signup workflow and UI when verify email is enabled. [#26263](https://github.com/grafana/grafana/pull/26263), [@KamalGalrani](https://github.com/KamalGalrani) +- **AzureMonitor**: Change filterDimensions property to match what is stored. [#27459](https://github.com/grafana/grafana/pull/27459), [@kylebrandt](https://github.com/kylebrandt) +- **Cloud Monitoring**: Fix missing title and text from cloud monitoring annotations. [#27187](https://github.com/grafana/grafana/pull/27187), [@atotto](https://github.com/atotto) +- **CloudWatch**: Fix error message returned from tag:GetResources. [#27205](https://github.com/grafana/grafana/pull/27205), [@kichik](https://github.com/kichik) +- **Cloudwatch**: Update AWS/MediaConnect metrics and dimensions. [#26093](https://github.com/grafana/grafana/pull/26093), [@papagian](https://github.com/papagian) +- **DashboardSettings**: Fixes auto refresh crash with space in interval. [#27438](https://github.com/grafana/grafana/pull/27438), [@hugohaggmark](https://github.com/hugohaggmark) +- **Elasticsearch**: Fix localized dates in index pattern. [#27351](https://github.com/grafana/grafana/pull/27351), [@domasx2](https://github.com/domasx2) +- **Elasticsearch**: Fix using multiple bucket script aggregations when only grouping by terms. [#24064](https://github.com/grafana/grafana/pull/24064), [@MarceloNunesAlves](https://github.com/MarceloNunesAlves) +- **Explore**: Expand template variables when redirecting from dashboard panel. [#27354](https://github.com/grafana/grafana/pull/27354), [@Elfo404](https://github.com/Elfo404) +- **FolderPicker**: Fixes not being able to create new folder. [#27092](https://github.com/grafana/grafana/pull/27092), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graphite**: Show and hide query editor function popup on click. [#26923](https://github.com/grafana/grafana/pull/26923), [@ivanahuckova](https://github.com/ivanahuckova) +- **InfluxDB/Flux**: Fix for Alerts on InfluxDB Flux datasources only use the first series. [#27463](https://github.com/grafana/grafana/pull/27463), [@ryantxu](https://github.com/ryantxu) +- **Loki**: Send current time range when fetching labels and values. [#26622](https://github.com/grafana/grafana/pull/26622), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Add backslash escaping for template variables. [#26205](https://github.com/grafana/grafana/pull/26205), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Correctly format multi values variables in queries. [#26896](https://github.com/grafana/grafana/pull/26896), [@ivanahuckova](https://github.com/ivanahuckova) +- **Provisioning**: Add validation for missing organisations in datasource, dashboard, and notifier configurations. [#26601](https://github.com/grafana/grafana/pull/26601), [@nabokihms](https://github.com/nabokihms) +- **Rendering**: Fixed issue rendering text panel to image via image renderer plugin. [#27083](https://github.com/grafana/grafana/pull/27083), [@torkelo](https://github.com/torkelo) +- **Stats**: Use more efficient SQL and add timeouts. [#27390](https://github.com/grafana/grafana/pull/27390), [@sakjur](https://github.com/sakjur) +- **Table**: Support date unit formats on string values. [#26879](https://github.com/grafana/grafana/pull/26879), [@torkelo](https://github.com/torkelo) +- **Thresholds**: Fixed issue with thresholds in overrides not working after save and reload. [#27297](https://github.com/grafana/grafana/pull/27297), [@torkelo](https://github.com/torkelo) +- **Transformations**: Fixes outer join transformation when frames are missing field to join by. [#27453](https://github.com/grafana/grafana/pull/27453), [@hugohaggmark](https://github.com/hugohaggmark) +- **Transformations**: merge will properly handle empty frames and frames with multiple rows where values are overlapping. [#27362](https://github.com/grafana/grafana/pull/27362), [@mckn](https://github.com/mckn) +- **grafana-cli**: Fix installing of plugins missing directory entries in zip. [#26945](https://github.com/grafana/grafana/pull/26945), [@adrianlzt](https://github.com/adrianlzt) + +# 7.1.5 (2020-08-25) + +### Features / Enhancements +- **Stats**: Stop counting the same user multiple times. [#26777](https://github.com/grafana/grafana/pull/26777), [@sakjur](https://github.com/sakjur) + +### Bug Fixes +- **Alerting**: remove LongToWide call in alerting. [#27140](https://github.com/grafana/grafana/pull/27140), [@kylebrandt](https://github.com/kylebrandt) +- **AzureMonitor**: fix panic introduced in 7.1.4 when unit was unspecified and alias was used. [#27113](https://github.com/grafana/grafana/pull/27113), [@kylebrandt](https://github.com/kylebrandt) +- **Variables**: Fixes issue with All variable not being resolved. [#27151](https://github.com/grafana/grafana/pull/27151), [@hugohaggmark](https://github.com/hugohaggmark) + +# 7.1.4 (2020-08-20) + +### Features / Enhancements +- **Azure App Insights Alert error - tsdb.HandleRequest() failed to convert dataframe "" to tsdb.TimeSeriesSlice**. [#26897](https://github.com/grafana/grafana/issues/26897) +- **AzureMonitor**: map more units. [#26990](https://github.com/grafana/grafana/pull/26990), [@kylebrandt](https://github.com/kylebrandt) +- **Azuremonitor**: do not set unit if literal "Unspecified". [#26839](https://github.com/grafana/grafana/pull/26839), [@kylebrandt](https://github.com/kylebrandt) +- **Dataframe/Alerting**: to tsdb.TimeSeriesSlice - accept "empty" time series. [#26903](https://github.com/grafana/grafana/pull/26903), [@kylebrandt](https://github.com/kylebrandt) +- **Field overrides**: Filter by field name using regex. [#27070](https://github.com/grafana/grafana/pull/27070), [@dprokop](https://github.com/dprokop) +- **Overrides**: expose byType matcher UI. [#27056](https://github.com/grafana/grafana/pull/27056), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes +- **CloudWatch**: Add FreeStorageCapacity metric. [#26503](https://github.com/grafana/grafana/pull/26503), [@waqark3389](https://github.com/waqark3389) +- **CloudWatch**: Fix sorting of metrics results. [#26835](https://github.com/grafana/grafana/pull/26835), [@aknuds1](https://github.com/aknuds1) +- **Cloudwatch**: Add FileSystemId as a dimension key for the AWS/FSx namespace. [#26662](https://github.com/grafana/grafana/pull/26662), [@waqark3389](https://github.com/waqark3389) +- **InfluxDB**: Update Flux placeholder URL with respect to latest Go client. [#27086](https://github.com/grafana/grafana/pull/27086), [@aknuds1](https://github.com/aknuds1) +- **InfluxDB**: Upgrade Go client, use data source HTTP client. [#27012](https://github.com/grafana/grafana/pull/27012), [@aknuds1](https://github.com/aknuds1) +- **Proxy**: Fix updating refresh token in OAuth pass-thru. [#26885](https://github.com/grafana/grafana/pull/26885), [@seanlaff](https://github.com/seanlaff) +- **Templating**: Fixes so texts show in picker not the values. [#27002](https://github.com/grafana/grafana/pull/27002), [@hugohaggmark](https://github.com/hugohaggmark) + +# 7.1.3 (2020-08-06) + +### Bug Fixes + - **Templating**: Templating: Fix undefined result when using raw interpolation format [#26818](https://github.com/grafana/grafana/pull/26818) + +# 7.1.2 (2020-08-05) + +### Features / Enhancements + - **Explore**: Don't run queries on datasource change. [#26033](https://github.com/grafana/grafana/pull/26033), [@davkal](https://github.com/davkal) + - **TemplateSrv**: Formatting options for ${__from} and ${__to}, unix seconds epoch, ISO 8601/RFC 3339. [#26466](https://github.com/grafana/grafana/pull/26466), [@torkelo](https://github.com/torkelo) + - **Toolkit/Plugin**: throw an Error instead of a string. [#26618](https://github.com/grafana/grafana/pull/26618), [@leventebalogh](https://github.com/leventebalogh) + + ### Bug Fixes + - **Dashbard**: Fix refresh interval settings to allow setting it to equal min_refresh_interval. [#26615](https://github.com/grafana/grafana/pull/26615), [@torkelo](https://github.com/torkelo) + - **Flux**: Ensure connections to InfluxDB are closed. [#26735](https://github.com/grafana/grafana/pull/26735), [@sneddrs](https://github.com/sneddrs) + - **Query history**: Fix search filtering if null value. [#26768](https://github.com/grafana/grafana/pull/26768), [@ivanahuckova](https://github.com/ivanahuckova) + - **QueryOptions**: Fix not being able to change cache timeout setting. [#26614](https://github.com/grafana/grafana/pull/26614), [@torkelo](https://github.com/torkelo) + - **StatPanel**: Fix stat panel display name not showing when explicitly set. [#26616](https://github.com/grafana/grafana/pull/26616), [@torkelo](https://github.com/torkelo) + - **Templating**: Fixed access to system variables like __dashboard, __user & __org during dashboard load & variable queries. [#26637](https://github.com/grafana/grafana/pull/26637), [@torkelo](https://github.com/torkelo) + - **TextPanel**: Fix content overflowing panel boundaries. [#26612](https://github.com/grafana/grafana/pull/26612), [@torkelo](https://github.com/torkelo) + - **TimePicker**: Fix position and responsive behavior. [#26570](https://github.com/grafana/grafana/pull/26570), [@torkelo](https://github.com/torkelo) + - **TimePicker**: Fixes app crash when changing custom range to nothing. [#26775](https://github.com/grafana/grafana/pull/26775), [@hugohaggmark](https://github.com/hugohaggmark) + - **Units**: Remove duplicate SI prefix from mSv and µSv. [#26598](https://github.com/grafana/grafana/pull/26598), [@tofurky](https://github.com/tofurky) + +# 7.1.1 (2020-07-24) + +### Features / Enhancements +- **Graph**: Support setting field unit & override data source (automatic) unit. [#26529](https://github.com/grafana/grafana/pull/26529), [@ryantxu](https://github.com/ryantxu) +- **Tracing**: Add errorIconColor prop to TraceSpanData. [#26509](https://github.com/grafana/grafana/pull/26509), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug Fixes +- **Branding**: Fix login app title. [#26425](https://github.com/grafana/grafana/pull/26425), [@benrubson](https://github.com/benrubson) +- **Bring back scripts evaluation in TextPanel**. [#26413](https://github.com/grafana/grafana/pull/26413), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Fix empty panels after scrolling on Safari/iOS. [#26495](https://github.com/grafana/grafana/pull/26495), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix for viewer can enter panel edit mode by modifying url (but cannot not save anything). [#26556](https://github.com/grafana/grafana/pull/26556), [@torkelo](https://github.com/torkelo) +- **Elasticsearch**: Fix displaying of bucket script input. [#26552](https://github.com/grafana/grafana/pull/26552), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: parse queryType from explore url. [#26349](https://github.com/grafana/grafana/pull/26349), [@zoltanbedi](https://github.com/zoltanbedi) +- **Tracing**: upstream fix for hovering on log lines. [#26426](https://github.com/grafana/grafana/pull/26426), [@zoltanbedi](https://github.com/zoltanbedi) + +# 7.1.0 (2020-07-16) + +### Features / Enhancements +- **Backend**: Use latest go plugin sdk (0.74.0) to sort wide frames. [#26207](https://github.com/grafana/grafana/pull/26207), [@kylebrandt](https://github.com/kylebrandt) +- **Elasticsearch**: Create Raw Doc metric to render raw JSON docs in columns in the new table panel. [#26233](https://github.com/grafana/grafana/pull/26233), [@ivanahuckova](https://github.com/ivanahuckova) +- **PluginsListPage**: More plugins button should open in new window. [#26305](https://github.com/grafana/grafana/pull/26305), [@zoltanbedi](https://github.com/zoltanbedi) + +### Bug Fixes +- **AdminUsers**: Reset page to zero on query change. [#26293](https://github.com/grafana/grafana/pull/26293), [@hshoff](https://github.com/hshoff) +- **CloudWatch Logs**: Fixes grouping of results by numeric field. [#26298](https://github.com/grafana/grafana/pull/26298), [@kaydelaney](https://github.com/kaydelaney) +- **DashboardLinks**: Do not over-query search endpoint. [#26311](https://github.com/grafana/grafana/pull/26311), [@torkelo](https://github.com/torkelo) +- **Docker**: Make sure to create default plugin provisioning directory. [#26017](https://github.com/grafana/grafana/pull/26017), [@marefr](https://github.com/marefr) +- **Elastic**: Fix error "e.buckets[Symbol.iterator] is not a function" when using filter. [#26217](https://github.com/grafana/grafana/pull/26217), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Loki**: Escape \ in labels for show context queries. [#26116](https://github.com/grafana/grafana/pull/26116), [@ivanahuckova](https://github.com/ivanahuckova) +- **Jaeger/Zipkin**: URL-encode service names and trace ids for API calls. [#26115](https://github.com/grafana/grafana/pull/26115), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fix prom links in mixed mode. [#26244](https://github.com/grafana/grafana/pull/26244), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Fix bug when provision app plugins using Enterprise edition. [#26340](https://github.com/grafana/grafana/pull/26340), [@marefr](https://github.com/marefr) +- **Sign In** Use correct url for the Sign In button. [#26239](https://github.com/grafana/grafana/pull/26239), [@dprokop](https://github.com/dprokop) + +# 7.1.0-beta3 (2020-07-13) + +### Features / Enhancements +- **Explore**: Unification of logs/metrics/traces user interface. [#25890](https://github.com/grafana/grafana/pull/25890), [@aocenas](https://github.com/aocenas) +- **Graph panel**: Move Stacking and null values before Hover tooltip options (#26035). [#26037](https://github.com/grafana/grafana/pull/26037), [@jsoref](https://github.com/jsoref) +- **LDAP**: Get all groups for all group base search DNs. [#25825](https://github.com/grafana/grafana/pull/25825), [@Annegies](https://github.com/Annegies) +- **Table**: JSON Cell should try to convert strings to JSON. [#26024](https://github.com/grafana/grafana/pull/26024), [@ryantxu](https://github.com/ryantxu) +- **Transform**: adding missing "table"-transform and "series to rows"-transform to Grafana v7-transforms. [#26042](https://github.com/grafana/grafana/pull/26042), [@mckn](https://github.com/mckn) + +### Bug Fixes +- **AdminUsersTable**: Fix width issues. [#26019](https://github.com/grafana/grafana/pull/26019), [@tskarhed](https://github.com/tskarhed) +- **BarGauge**: Fix space bug in single series mode. [#26176](https://github.com/grafana/grafana/pull/26176), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Allow removing min refresh interval from refresh options (5s or other). [#26150](https://github.com/grafana/grafana/pull/26150), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Fixed interpolation of repeated variables used in Graph data links. [#26147](https://github.com/grafana/grafana/pull/26147), [@torkelo](https://github.com/torkelo) +- **Do not break dashboard settings UI when time intervals end with trailing comma**. [#26126](https://github.com/grafana/grafana/pull/26126), [@dprokop](https://github.com/dprokop) +- **Elastic**: Display correct log message based on selected log field. [#26020](https://github.com/grafana/grafana/pull/26020), [@ivanahuckova](https://github.com/ivanahuckova) +- **InfluxDB**: Fixed new group by dropdown now showing after first use. [#26031](https://github.com/grafana/grafana/pull/26031), [@torkelo](https://github.com/torkelo) +- **StatPanel**: Fixes issue with name showing for single series / field results. [#26070](https://github.com/grafana/grafana/pull/26070), [@torkelo](https://github.com/torkelo) +- **Templating**: Fix recursive loop of template variable queries when changing ad-hoc-variable. [#26191](https://github.com/grafana/grafana/pull/26191), [@torkelo](https://github.com/torkelo) + +# 7.0.6 (2020-07-09) + +### Bug fixes + +- **Templating**: Fixed recursive queries triggered when switching dashboard settings view [#26137](https://github.com/grafana/grafana/pull/26137) +- **Templating**: Fix recursive loop of template variable queries when changing ad-hoc-variable [#26191](https://github.com/grafana/grafana/pull/26191) +- **Auth**: Add support for forcing authentication in anonymous mode and modify SignIn to use it instead of redirect [#25567](https://github.com/grafana/grafana/pull/25567) +- **Auth**: Fix POST request failures with anonymous access [#26049](https://github.com/grafana/grafana/pull/26049) + +# 7.1.0-beta 2 (2020-07-02) + +### Features / Enhancements +- **Loki**: Allow aliasing Loki queries in dashboard. [#25706](https://github.com/grafana/grafana/pull/25706), [@bastjan](https://github.com/bastjan) + +### Bug Fixes +- **Explore**: Fix href when jumping from Explore to Add data source. [#25991](https://github.com/grafana/grafana/pull/25991), [@ivanahuckova](https://github.com/ivanahuckova) +- **Fix**: Build-in plugins failed to load in windows. [#25982](https://github.com/grafana/grafana/pull/25982), [@papagian](https://github.com/papagian) + +# 7.1.0-beta 1 (2020-07-01) + +### Features / Enhancements +- **Alerting**: Adds support for multiple URLs in Alertmanager notifier. [#24196](https://github.com/grafana/grafana/pull/24196), [@alistarle](https://github.com/alistarle) +- **Alerting**: updating the victorops alerter to handle the no_data alert type. [#23761](https://github.com/grafana/grafana/pull/23761), [@rrusso1982](https://github.com/rrusso1982) +- **Azure**: Application Insights metrics to Frame and support multiple query dimensions. [#25849](https://github.com/grafana/grafana/pull/25849), [@kylebrandt](https://github.com/kylebrandt) +- **Azure**: Multiple dimension support for Azure Monitor Service. [#25947](https://github.com/grafana/grafana/pull/25947), [@kylebrandt](https://github.com/kylebrandt) +- **Azure**: Split Insights into two services. [#25410](https://github.com/grafana/grafana/pull/25410), [@kylebrandt](https://github.com/kylebrandt) +- **Backend plugins**: Refactor to allow shared contract between core and external backend plugins. [#25472](https://github.com/grafana/grafana/pull/25472), [@marefr](https://github.com/marefr) +- **Branding**: Use AppTitle as document title. [#25271](https://github.com/grafana/grafana/pull/25271), [@benrubson](https://github.com/benrubson) +- **Chore**: upgrade to typescript 3.9.3. [#25154](https://github.com/grafana/grafana/pull/25154), [@ryantxu](https://github.com/ryantxu) +- **CloudWatch**: Add Route53 DNSQueries metric and dimension. [#25125](https://github.com/grafana/grafana/pull/25125), [@erkolson](https://github.com/erkolson) +- **CloudWatch**: Added AWS DataSync metrics and dimensions. [#25054](https://github.com/grafana/grafana/pull/25054), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS MediaStore metrics and dimensions. [#25492](https://github.com/grafana/grafana/pull/25492), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS RoboMaker metrics and dimensions. [#25090](https://github.com/grafana/grafana/pull/25090), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS SDKMetrics metrics and dimensions. [#25150](https://github.com/grafana/grafana/pull/25150), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS ServiceCatalog metrics and dimensions. [#25812](https://github.com/grafana/grafana/pull/25812), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added AWS WAFV2 metrics. [#24048](https://github.com/grafana/grafana/pull/24048), [@mikkokupsu](https://github.com/mikkokupsu) +- **Dashboards**: Make path to default dashboard configurable. [#25595](https://github.com/grafana/grafana/pull/25595), [@bergquist](https://github.com/bergquist) +- **Elastic**: Internal data links. [#25942](https://github.com/grafana/grafana/pull/25942), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Add support for template variable in date histogram min_doc_count. [#21064](https://github.com/grafana/grafana/pull/21064), [@faxm0dem](https://github.com/faxm0dem) +- **Elasticsearch**: Adds cumulative sum aggregation support. [#24820](https://github.com/grafana/grafana/pull/24820), [@retzkek](https://github.com/retzkek) +- **Elasticsearch**: Support using a variable for histogram and terms min doc count. [#25392](https://github.com/grafana/grafana/pull/25392), [@marefr](https://github.com/marefr) +- **Explore/Loki**: Show results of instant queries only in table and time series only in graph. [#25845](https://github.com/grafana/grafana/pull/25845), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Remove legend formatting when switching from panel to Explore. [#25848](https://github.com/grafana/grafana/pull/25848), [@ivanahuckova](https://github.com/ivanahuckova) +- **Footer**: Add back footer to login page. [#25656](https://github.com/grafana/grafana/pull/25656), [@torkelo](https://github.com/torkelo) +- **ForgottenPassword**: Move view to login screen. [#25366](https://github.com/grafana/grafana/pull/25366), [@tskarhed](https://github.com/tskarhed) +- **Gauge**: Hide orientation option in panel options. [#25511](https://github.com/grafana/grafana/pull/25511), [@torkelo](https://github.com/torkelo) +- **Grafana-UI**: Add FileUpload. [#25835](https://github.com/grafana/grafana/pull/25835), [@Clarity-89](https://github.com/Clarity-89) +- **GraphPanel**: Make legend values clickable series toggles. [#25581](https://github.com/grafana/grafana/pull/25581), [@hshoff](https://github.com/hshoff) +- **Influx**: Support flux in the influx datasource. [#25308](https://github.com/grafana/grafana/pull/25308), [@ryantxu](https://github.com/ryantxu) +- **Migration**: Select org. [#24739](https://github.com/grafana/grafana/pull/24739), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Settings forms. [#24741](https://github.com/grafana/grafana/pull/24741), [@tskarhed](https://github.com/tskarhed) +- **Panel Inspect**: use Monaco editor for json display. [#25251](https://github.com/grafana/grafana/pull/25251), [@ryantxu](https://github.com/ryantxu) +- **Panel edit**: Clicking twice on a visualization closes the VizPicker. [#25739](https://github.com/grafana/grafana/pull/25739), [@peterholmberg](https://github.com/peterholmberg) +- **PanelInspect**: Update UI for Data display options. [#25478](https://github.com/grafana/grafana/pull/25478), [@tskarhed](https://github.com/tskarhed) +- **Plugins**: move jaeger trace type to grafana data. [#25403](https://github.com/grafana/grafana/pull/25403), [@zoltanbedi](https://github.com/zoltanbedi) +- **Provisioning**: Adds support for enabling app plugins. [#25649](https://github.com/grafana/grafana/pull/25649), [@marefr](https://github.com/marefr) +- **Provisioning**: Use folders structure from the file system to create desired folders in dashboard provisioning. [#23117](https://github.com/grafana/grafana/pull/23117), [@nabokihms](https://github.com/nabokihms) +- **Query history**: Add keyboard shortcut support for commenting. [#24736](https://github.com/grafana/grafana/pull/24736), [@ivanahuckova](https://github.com/ivanahuckova) +- **Query history**: Add search for query history and starred queries. [#25747](https://github.com/grafana/grafana/pull/25747), [@ivanahuckova](https://github.com/ivanahuckova) +- **Rich history**: Updates for default settings and starred queries deletion. [#25732](https://github.com/grafana/grafana/pull/25732), [@ivanahuckova](https://github.com/ivanahuckova) +- **Search**: support URL query params. [#25541](https://github.com/grafana/grafana/pull/25541), [@Clarity-89](https://github.com/Clarity-89) +- **Stackdriver**: Deep linking from Grafana panels to the Metrics Explorer. [#25858](https://github.com/grafana/grafana/pull/25858), [@papagian](https://github.com/papagian) +- **Stackdriver**: Rename Stackdriver to Google Cloud Monitoring. [#25807](https://github.com/grafana/grafana/pull/25807), [@papagian](https://github.com/papagian) +- **StatPanel**: Option showing name instead of value and more. [#25676](https://github.com/grafana/grafana/pull/25676), [@torkelo](https://github.com/torkelo) +- **Switch**: Deprecate checked prop in favor of value. [#25862](https://github.com/grafana/grafana/pull/25862), [@tskarhed](https://github.com/tskarhed) +- **Tab**: Make active tab clickable and add hyperlink functionality. [#25546](https://github.com/grafana/grafana/pull/25546), [@tskarhed](https://github.com/tskarhed) +- **Table**: Adds adhoc filtering. [#25467](https://github.com/grafana/grafana/pull/25467), [@hugohaggmark](https://github.com/hugohaggmark) +- **Teams**: Add index for permission check. [#25736](https://github.com/grafana/grafana/pull/25736), [@sakjur](https://github.com/sakjur) +- **Template variable filters**: Hide overflowing text. [#25801](https://github.com/grafana/grafana/pull/25801), [@tskarhed](https://github.com/tskarhed) +- **Templating**: Add bult in __user {name, id, login, email} variable to templating system. [#23378](https://github.com/grafana/grafana/pull/23378), [@aidanmountford](https://github.com/aidanmountford) +- **Templating**: removes old Angular variable system and featureToggle. [#24779](https://github.com/grafana/grafana/pull/24779), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Adds proper editor for markdown and html. [#25618](https://github.com/grafana/grafana/pull/25618), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Removes Angular Text Panel. [#25504](https://github.com/grafana/grafana/pull/25504), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Removes text mode. [#25589](https://github.com/grafana/grafana/pull/25589), [@hugohaggmark](https://github.com/hugohaggmark) +- **TimeZone**: unify the time zone pickers to one that can rule them all. [#24803](https://github.com/grafana/grafana/pull/24803), [@mckn](https://github.com/mckn) +- **Transform**: added merge transform that will merge multiple series/tables into one table. [#25490](https://github.com/grafana/grafana/pull/25490), [@mckn](https://github.com/mckn) +- **Units**: add base-pascals and rotational speed units. [#22879](https://github.com/grafana/grafana/pull/22879), [@sakjur](https://github.com/sakjur) +- **Units**: add new unit for duration, it is optimized for displaying days, hours, minutes and seconds. [#24175](https://github.com/grafana/grafana/pull/24175), [@pabigot](https://github.com/pabigot) +- **Variables**: enables cancel for slow query variables queries. [#24430](https://github.com/grafana/grafana/pull/24430), [@hugohaggmark](https://github.com/hugohaggmark) +- **switches default value for security settings**. [#25175](https://github.com/grafana/grafana/pull/25175), [@bergquist](https://github.com/bergquist) +- **Reporting:** add monthly schedule option. (Enterprise) + +### Bug Fixes +- **DatatLinks**: Fix open in new tab state mismatch. [#25826](https://github.com/grafana/grafana/pull/25826), [@tskarhed](https://github.com/tskarhed) +- **Explore/Loki**: Fix field type in table for instant queries. [#25907](https://github.com/grafana/grafana/pull/25907), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Loki**: Fix scrolling of context when leaving context window. [#25838](https://github.com/grafana/grafana/pull/25838), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/SQL data sources**: Show correctly interpolated queries. [#25110](https://github.com/grafana/grafana/pull/25110), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore/Tooltip**: Fix label value in tooltip. [#25940](https://github.com/grafana/grafana/pull/25940), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix query editors on mobile. [#25148](https://github.com/grafana/grafana/pull/25148), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: adds an ability to exit log row context with ESC key. [#24205](https://github.com/grafana/grafana/pull/24205), [@Estrax](https://github.com/Estrax) +- **Fix**: Value mappings match against string values. [#25929](https://github.com/grafana/grafana/pull/25929), [@peterholmberg](https://github.com/peterholmberg) +- **GraphPanel**: Fix annotations overflowing panels. [#25606](https://github.com/grafana/grafana/pull/25606), [@hshoff](https://github.com/hshoff) +- **Instrumentation**: Fix setting Jaeger tracing address through Grafana config. [#25768](https://github.com/grafana/grafana/pull/25768), [@marefr](https://github.com/marefr) +- **Prometheus**: Fix performance issue in processing of histogram labels. [#25813](https://github.com/grafana/grafana/pull/25813), [@bsherrod](https://github.com/bsherrod) +- **Provisioning**: Makes file the default dashboard provisioner type. [#24856](https://github.com/grafana/grafana/pull/24856), [@bergquist](https://github.com/bergquist) +- **Templating**: fixes variables not being interpolated after dashboard refresh. [#25698](https://github.com/grafana/grafana/pull/25698), [@hugohaggmark](https://github.com/hugohaggmark) +- **Units**: Custom unit suffix and docs for custom units. [#25710](https://github.com/grafana/grafana/pull/25710), [@torkelo](https://github.com/torkelo) +- **ValueFormats**: Fix byte-format data rates. [#25424](https://github.com/grafana/grafana/pull/25424), [@mueslo](https://github.com/mueslo) +- **Variables**: Fixes maximum call stack bug for empty value. [#25503](https://github.com/grafana/grafana/pull/25503), [@hugohaggmark](https://github.com/hugohaggmark) + +### Security fixes +- **Graph**: Fix XSS vulnerability with series overrides [#25401](https://github.com/grafana/grafana/pull/25401). Thanks to Rotem Reiss for reporting this. + +# 7.0.5 (2020-06-30) + +### Bug Fixes + +- **Datasource**: Make sure data proxy timeout applies to HTTP client. [#25865](https://github.com/grafana/grafana/pull/25865), [@marefr](https://github.com/marefr) +- **Graphite**: Fix tag value dropdowns not showing in query editor. [#25889](https://github.com/grafana/grafana/pull/25889), [@torkelo](https://github.com/torkelo) + +# 7.0.4 (2020-06-25) + +### Features / Enhancements + +- **Dashboard**: Redirects for old (pre 7.0) edit & view panel urls. [#25653](https://github.com/grafana/grafana/pull/25653), [@torkelo](https://github.com/torkelo) +- **Stackdriver**: Use default project name if project name isn't set on the query. [#25413](https://github.com/grafana/grafana/pull/25413), [@alexashley](https://github.com/alexashley) +- **TablePanel**: Sort numbers correctly. [#25421](https://github.com/grafana/grafana/pull/25421), [@speakyourcode](https://github.com/speakyourcode) +- **Update Bitcoin currency to use proper symbol, add mBTC and μBTC**. [#24182](https://github.com/grafana/grafana/pull/24182), [@overcookedpanda](https://github.com/overcookedpanda) +- **Variables**: Links that update variables on current dashboard does not trigger refresh / update. [#25192](https://github.com/grafana/grafana/pull/25192), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **Azure Monitor**: fixes undefined is not iterable. [#25586](https://github.com/grafana/grafana/pull/25586), [@hugohaggmark](https://github.com/hugohaggmark) +- **Datasources**: Handle URL parsing error. [#25742](https://github.com/grafana/grafana/pull/25742), [@marefr](https://github.com/marefr) +- **InfluxDB**: Fix invalid memory address or nil pointer dereference when schema is missing in URL. [#25565](https://github.com/grafana/grafana/pull/25565), [@marefr](https://github.com/marefr) +- **Security**: Use Header.Set and Header.Del for X-Grafana-User header. [#25495](https://github.com/grafana/grafana/pull/25495), [@beardhatcode](https://github.com/beardhatcode) +- **Stackdriver**: Fix creating Label Values datasource query variable. [#25633](https://github.com/grafana/grafana/pull/25633), [@papagian](https://github.com/papagian) +- **Table**: Support custom date formats via custom unit. [#25195](https://github.com/grafana/grafana/pull/25195), [@torkelo](https://github.com/torkelo) +- **Templating**: Fixes query variable with \${\_\_searchFilter} value selection not causing refresh & url update. [#25770](https://github.com/grafana/grafana/pull/25770), [@torkelo](https://github.com/torkelo) + +# 7.0.3 (2020-06-03) + +### Features / Enhancements + +- **Stats**: include all fields. [#24829](https://github.com/grafana/grafana/pull/24829), [@ryantxu](https://github.com/ryantxu) +- **Variables**: change VariableEditorList row action Icon to IconButton. [#25217](https://github.com/grafana/grafana/pull/25217), [@hshoff](https://github.com/hshoff) + +### Bug Fixes + +- **Cloudwatch**: Fix dimensions of DDoSProtection. [#25317](https://github.com/grafana/grafana/pull/25317), [@papagian](https://github.com/papagian) +- **Configuration**: Fix env var override of sections containing hyphen. [#25178](https://github.com/grafana/grafana/pull/25178), [@marefr](https://github.com/marefr) +- **Dashboard**: Get panels in collapsed rows. [#25079](https://github.com/grafana/grafana/pull/25079), [@peterholmberg](https://github.com/peterholmberg) +- **Do not show alerts tab when alerting is disabled**. [#25285](https://github.com/grafana/grafana/pull/25285), [@dprokop](https://github.com/dprokop) +- **Jaeger**: fixes cascader option label duration value. [#25129](https://github.com/grafana/grafana/pull/25129), [@Estrax](https://github.com/Estrax) +- **Transformations**: Fixed Transform tab crash & no update after adding first transform. [#25152](https://github.com/grafana/grafana/pull/25152), [@torkelo](https://github.com/torkelo) + +# 7.0.2 (2020-06-03) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2020/06/03/grafana-6.7.4-and-7.0.2-released-with-important-security-fix/) + +# 7.0.1 (2020-05-26) + +### Features / Enhancements + +- **Datasource/CloudWatch**: Makes CloudWatch Logs query history more readable. [#24795](https://github.com/grafana/grafana/pull/24795), [@kaydelaney](https://github.com/kaydelaney) +- **Download CSV**: Add date and time formatting. [#24992](https://github.com/grafana/grafana/pull/24992), [@ryantxu](https://github.com/ryantxu) +- **Table**: Make last cell value visible when right aligned. [#24921](https://github.com/grafana/grafana/pull/24921), [@peterholmberg](https://github.com/peterholmberg) +- **TablePanel**: Adding sort order persistance. [#24705](https://github.com/grafana/grafana/pull/24705), [@torkelo](https://github.com/torkelo) +- **Transformations**: Display correct field name when using reduce transformation. [#25068](https://github.com/grafana/grafana/pull/25068), [@peterholmberg](https://github.com/peterholmberg) +- **Transformations**: Allow custom number input for binary operations. [#24752](https://github.com/grafana/grafana/pull/24752), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes + +- **Cloudwatch**: Fix AWS WAF and AWS DDoSProtection metrics. [#25071](https://github.com/grafana/grafana/pull/25071), [@papagian](https://github.com/papagian) +- **Dashboard/Links**: Fixes dashboard links by tags not working. [#24773](https://github.com/grafana/grafana/pull/24773), [@KamalGalrani](https://github.com/KamalGalrani) +- **Dashboard/Links**: Fixes open in new window for dashboard link. [#24772](https://github.com/grafana/grafana/pull/24772), [@KamalGalrani](https://github.com/KamalGalrani) +- **Dashboard/Links**: Variables are resolved and limits to 100. [#25076](https://github.com/grafana/grafana/pull/25076), [@hugohaggmark](https://github.com/hugohaggmark) +- **DataLinks**: Bring back variables interpolation in title. [#24970](https://github.com/grafana/grafana/pull/24970), [@dprokop](https://github.com/dprokop) +- **Datasource/CloudWatch**: Field suggestions no longer limited to prefix-only. [#24855](https://github.com/grafana/grafana/pull/24855), [@kaydelaney](https://github.com/kaydelaney) +- **Explore/Table**: Keep existing field types if possible. [#24944](https://github.com/grafana/grafana/pull/24944), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Fix wrap lines toggle for results of queries with filter expression. [#24915](https://github.com/grafana/grafana/pull/24915), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: fix undo in query editor. [#24797](https://github.com/grafana/grafana/pull/24797), [@zoltanbedi](https://github.com/zoltanbedi) +- **Explore**: fix word break in type head info. [#25014](https://github.com/grafana/grafana/pull/25014), [@zoltanbedi](https://github.com/zoltanbedi) +- **Graph**: Legend decimals now work as expected. [#24931](https://github.com/grafana/grafana/pull/24931), [@torkelo](https://github.com/torkelo) +- **LoginPage**: Fix hover color for service buttons. [#25009](https://github.com/grafana/grafana/pull/25009), [@tskarhed](https://github.com/tskarhed) +- **LogsPanel**: Fix scrollbar. [#24850](https://github.com/grafana/grafana/pull/24850), [@ivanahuckova](https://github.com/ivanahuckova) +- **MoveDashboard**: Fix for moving dashboard caused all variables to be lost. [#25005](https://github.com/grafana/grafana/pull/25005), [@torkelo](https://github.com/torkelo) +- **Organize transformer**: Use display name in field order comparer. [#24984](https://github.com/grafana/grafana/pull/24984), [@dprokop](https://github.com/dprokop) +- **Panel**: shows correct panel menu items in view mode. [#24912](https://github.com/grafana/grafana/pull/24912), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelEditor Fix missing labels and description if there is only single option in category**. [#24905](https://github.com/grafana/grafana/pull/24905), [@dprokop](https://github.com/dprokop) +- **PanelEditor**: Overrides name matcher still show all original field names even after Field default display name is specified. [#24933](https://github.com/grafana/grafana/pull/24933), [@torkelo](https://github.com/torkelo) +- **PanelInspector**: Makes sure Data display options are visible. [#24902](https://github.com/grafana/grafana/pull/24902), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelInspector**: Hides unsupported data display options for Panel type. [#24918](https://github.com/grafana/grafana/pull/24918), [@hugohaggmark](https://github.com/hugohaggmark) +- **PanelMenu**: Make menu disappear on button press. [#25015](https://github.com/grafana/grafana/pull/25015), [@tskarhed](https://github.com/tskarhed) +- **Postgres**: Fix add button. [#25087](https://github.com/grafana/grafana/pull/25087), [@phemmer](https://github.com/phemmer) +- **Prometheus**: Fix recording rules expansion. [#24977](https://github.com/grafana/grafana/pull/24977), [@ivanahuckova](https://github.com/ivanahuckova) +- **Stackdriver**: Fix creating Service Level Objectives (SLO) datasource query variable. [#25023](https://github.com/grafana/grafana/pull/25023), [@papagian](https://github.com/papagian) + +# 7.0.0 (2020-05-18) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. +- **Backend plugins**: Grafana now requires backend plugins to be signed, otherwise Grafana will not load/start them. This is an additional security measure to make sure backend plugin binaries and files haven't been tampered with. Refer to [Upgrade Grafana](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v7-0) for more information. +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) + +**Deprecation warnings** + +- Scripted dashboards is now deprecated. The feature is not removed but will be in a future release. We hope to address the underlying requirement of dynamic dashboards in a different way. [#24059](https://github.com/grafana/grafana/issues/24059) +- The unofficial first version of backend plugins together with usage of [grafana/grafana-plugin-model](https://github.com/grafana/grafana-plugin-model) is now deprecated and support for that will be removed in a future release. Please refer to [backend plugins documentation](https://grafana.com/docs/grafana/latest/developers/plugins/backend/) for information about the new officially supported backend plugins. + +## 7.0 Feature highlights + +### Data transformations + +Not just visualizing data from anywhere, in Grafana 7 you can transform it too. By chaining a simple set of point and click transformations users will be able join, pivot, filter, re-name and calculate to get the results they need. Perfect for operations across queries or data sources missing essential data transformations. + +Data transformations will provide a common set of data operations that were previously duplicated as custom features in many panels or data sources but are now an integral part of the Grafana data processing pipeline and something all data sources and panels can take advantage of. + +In Grafana 7.0 we have a shared data model for both time series and table data that we call [DataFrame](https://github.com/grafana/grafana/blob/master/docs/sources/plugins/developing/dataframe.md). A DataFrame is like a table with columns but we refer to columns as fields. A time series is simply a DataFrame with two fields (time & value). + +**Transformations shipping in 7.0** + +- **Reduce**: Reduce many rows / data points to a single value +- **Filter by name**: Filter fields by name or regex +- **Filter by refId**: Filter by query letter +- **Organize fields**: Reorder, rename and hide fields. +- **Labels to fields**: Transform time series with labels into a table where labels get's converted to fields and the result is joined by time +- **Join by field**: Join many result sets (series) together using for example the time field. Useful for transforming time series into a table with a shared time column and where each series get it's own column. +- **Add field from calculation**: This is a powerful transformation that allows you perform many different types of math operations and add the result as a new field. Examples: + - Calculate the difference between two series or fields and add the result to a new field + - Multiply one field with another another and add the result to a new field + +### New panel edit experience + +In Grafana 7 we have redesigned the UI for editing panels. The first visible change is that we have separated panel display settings to a right hand side pane that you can collapse or expand depending on what your focus is on. With this change we are also introducing our new unified option model & UI for defining data configuration and display options. This unified data configuration system powers a consistent UI for setting data options across visualizations as well as making all data display settings data driven and overridable. + +This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like `unit`, `min`, `max`, `thresholds`, `links`, `decimals`. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +We have yet to migrate all core panels to this new architecture so in 7.0 there will sadly be some big inconsistencies in the UI between panels. Hopefully this will be fixed soon in future releases as we update all the core panels and help the community update the community panel plugins. + +### New table panel + +Grafana 7.0 comes with a new table panel (and deprecates the old one). This new table panel supports horizontal scrolling and column resize. Paired with the new `Organize fields` transformation detailed above you can reorder, hide & rename columns. This new panel also supports new cell display modes, like showing a bar gauge inside a cell. + +### Panel inspector + +The panel inspector is a feature that every panel will support, including internal as well as external community plugins. In this new panel inspector, you can view the raw data in a table format, apply some pre-defined transformations, and download as CSV. You can find the **Inspect** setting in the panel menu. Use the keyboard shortcut `i` when hovering over a panel to get the panel inspector to appear. + +### Improved time zone support + +Starting in version 7.0, you can override the time zone used to display date and time values in a dashboard. + +With this feature, you can specify the local time zone of the service or system that you are monitoring. This can be helpful when monitoring a system or service that operates across several time zones. + +We have also extended the time zone options so you can select any of the standard [ISO 8601 time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +### Features / Enhancements + +- **Azure Monitor**: Deep linking from Log Analytic queries to the Azure Portal. [#24417](https://github.com/grafana/grafana/pull/24417), [@daniellee](https://github.com/daniellee) +- **Backend plugins**: Log deprecation warning when using the unofficial first version of backend plugins. [#24675](https://github.com/grafana/grafana/pull/24675), [@marefr](https://github.com/marefr) +- **CloudWatch/Logs**: Add data links to CloudWatch logs for deep linking to AWS. [#24334](https://github.com/grafana/grafana/pull/24334), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Unify look of query mode select between dashboard and explore. [#24648](https://github.com/grafana/grafana/pull/24648), [@aocenas](https://github.com/aocenas) +- **Docker**: Adds tzdata package to Ubuntu image. [#24422](https://github.com/grafana/grafana/pull/24422), [@xlson](https://github.com/xlson) +- **Editor**: New line on Enter, run query on Shift+Enter. [#24654](https://github.com/grafana/grafana/pull/24654), [@davkal](https://github.com/davkal) +- **Loki**: Allow multiple derived fields with the same name. [#24437](https://github.com/grafana/grafana/pull/24437), [@aocenas](https://github.com/aocenas) +- **Orgs**: Add future deprecation notice. [#24502](https://github.com/grafana/grafana/pull/24502), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **@grafana/toolkit**: Use process.cwd() instead of PWD to get directory. [#24677](https://github.com/grafana/grafana/pull/24677), [@zoltanbedi](https://github.com/zoltanbedi) +- **Admin**: Makes long settings values line break in settings page. [#24559](https://github.com/grafana/grafana/pull/24559), [@hugohaggmark](https://github.com/hugohaggmark) +- **Azure Monitor**: Fix failure when using table join in Log Analytics queries. [#24528](https://github.com/grafana/grafana/pull/24528), [@daniellee](https://github.com/daniellee) +- **CloudWatch/Logs**: Add error message when log groups are not selected. [#24361](https://github.com/grafana/grafana/pull/24361), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Allows a user to search for log groups that aren't there initially. [#24695](https://github.com/grafana/grafana/pull/24695), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Correctly interpolate variables in logs queries. [#24619](https://github.com/grafana/grafana/pull/24619), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Fix autocomplete after by keyword. [#24644](https://github.com/grafana/grafana/pull/24644), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix field autocomplete suggestions inside function. [#24406](https://github.com/grafana/grafana/pull/24406), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix fields not being refetched when log group changed. [#24529](https://github.com/grafana/grafana/pull/24529), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix panic on multiple aggregations queries. [#24683](https://github.com/grafana/grafana/pull/24683), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix query error when results were sparse. [#24702](https://github.com/grafana/grafana/pull/24702), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix suggestion for already inserted field. [#24581](https://github.com/grafana/grafana/pull/24581), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fix suggestions of fields after comma. [#24520](https://github.com/grafana/grafana/pull/24520), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Fixes various autocomplete issues. [#24583](https://github.com/grafana/grafana/pull/24583), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Handle errors that are not awserr.Error instances. [#24641](https://github.com/grafana/grafana/pull/24641), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch/Logs**: Handle invalidation of log groups when switching data source. [#24703](https://github.com/grafana/grafana/pull/24703), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Make stats hint show consistently. [#24392](https://github.com/grafana/grafana/pull/24392), [@aocenas](https://github.com/aocenas) +- **CloudWatch/Logs**: Prevents hidden data frame fields from displaying in tables. [#24580](https://github.com/grafana/grafana/pull/24580), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Results of stats queries are now grouped. [#24396](https://github.com/grafana/grafana/pull/24396), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch/Logs**: Usability improvements. [#24447](https://github.com/grafana/grafana/pull/24447), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard**: Allow editing provisioned dashboard JSON and add confirmation when JSON is copied to dashboard. [#24680](https://github.com/grafana/grafana/pull/24680), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Fix for strange "dashboard not found" errors when opening links in dashboard settings. [#24416](https://github.com/grafana/grafana/pull/24416), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix so default data source is selected when data source can't be found in panel editor. [#24526](https://github.com/grafana/grafana/pull/24526), [@mckn](https://github.com/mckn) +- **Dashboard**: Fixed issue changing a panel from transparent back to normal in panel editor. [#24483](https://github.com/grafana/grafana/pull/24483), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Make header names reflect the field name when exporting to CSV file from the panel inspector. [#24624](https://github.com/grafana/grafana/pull/24624), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Make sure side pane is displayed with tabs by default in panel editor. [#24636](https://github.com/grafana/grafana/pull/24636), [@dprokop](https://github.com/dprokop) +- **Data source**: Fix query/annotation help content formatting. [#24687](https://github.com/grafana/grafana/pull/24687), [@AgnesToulet](https://github.com/AgnesToulet) +- **Data source**: Fixes async mount errors. [#24579](https://github.com/grafana/grafana/pull/24579), [@Estrax](https://github.com/Estrax) +- **Data source**: Fixes saving a data source without failure when URL doesn't specify a protocol. [#24497](https://github.com/grafana/grafana/pull/24497), [@aknuds1](https://github.com/aknuds1) +- **Explore/Prometheus**: Show results of instant queries only in table. [#24508](https://github.com/grafana/grafana/pull/24508), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix rendering of react query editors. [#24593](https://github.com/grafana/grafana/pull/24593), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fixes loading more logs in logs context view. [#24135](https://github.com/grafana/grafana/pull/24135), [@Estrax](https://github.com/Estrax) +- **Graphite**: Fix schema and dedupe strategy in rollup indicators for Metrictank queries. [#24685](https://github.com/grafana/grafana/pull/24685), [@torkelo](https://github.com/torkelo) +- **Graphite**: Makes query annotations work again. [#24556](https://github.com/grafana/grafana/pull/24556), [@hugohaggmark](https://github.com/hugohaggmark) +- **Logs**: Clicking "Load more" from context overlay doesn't expand log row. [#24299](https://github.com/grafana/grafana/pull/24299), [@kaydelaney](https://github.com/kaydelaney) +- **Logs**: Fix total bytes process calculation. [#24691](https://github.com/grafana/grafana/pull/24691), [@davkal](https://github.com/davkal) +- **Org/user/team preferences**: Fixes so UI Theme can be set back to Default. [#24628](https://github.com/grafana/grafana/pull/24628), [@AgnesToulet](https://github.com/AgnesToulet) +- **Plugins**: Fix manifest validation. [#24573](https://github.com/grafana/grafana/pull/24573), [@aknuds1](https://github.com/aknuds1) +- **Provisioning**: Use proxy as default access mode in provisioning. [#24669](https://github.com/grafana/grafana/pull/24669), [@bergquist](https://github.com/bergquist) +- **Search**: Fix select item when pressing enter and Grafana is served using a sub path. [#24634](https://github.com/grafana/grafana/pull/24634), [@tskarhed](https://github.com/tskarhed) +- **Search**: Save folder expanded state. [#24496](https://github.com/grafana/grafana/pull/24496), [@Clarity-89](https://github.com/Clarity-89) +- **Security**: Tag value sanitization fix in OpenTSDB data source. [#24539](https://github.com/grafana/grafana/pull/24539), [@rotemreiss](https://github.com/rotemreiss) +- **Table**: Do not include angular options in options when switching from angular panel. [#24684](https://github.com/grafana/grafana/pull/24684), [@torkelo](https://github.com/torkelo) +- **Table**: Fixed persisting column resize for time series fields. [#24505](https://github.com/grafana/grafana/pull/24505), [@torkelo](https://github.com/torkelo) +- **Table**: Fixes Cannot read property subRows of null. [#24578](https://github.com/grafana/grafana/pull/24578), [@hugohaggmark](https://github.com/hugohaggmark) +- **Time picker**: Fixed so you can enter a relative range in the time picker without being converted to absolute range. [#24534](https://github.com/grafana/grafana/pull/24534), [@mckn](https://github.com/mckn) +- **Transformations**: Make transform dropdowns not cropped. [#24615](https://github.com/grafana/grafana/pull/24615), [@dprokop](https://github.com/dprokop) +- **Transformations**: Sort order should be preserved as entered by user when using the reduce transformation. [#24494](https://github.com/grafana/grafana/pull/24494), [@hugohaggmark](https://github.com/hugohaggmark) +- **Units**: Adds scale symbol for currencies with suffixed symbol. [#24678](https://github.com/grafana/grafana/pull/24678), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables**: Fixes filtering options with more than 1000 entries. [#24614](https://github.com/grafana/grafana/pull/24614), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables**: Fixes so Textbox variables read value from url. [#24623](https://github.com/grafana/grafana/pull/24623), [@hugohaggmark](https://github.com/hugohaggmark) +- **Zipkin**: Fix error when span contains remoteEndpoint. [#24524](https://github.com/grafana/grafana/pull/24524), [@aocenas](https://github.com/aocenas) +- **SAML**: Switch from email to login for user login attribute mapping (Enterprise) + +# 7.0.0-beta3 (2020-05-08) + +### Features / Enhancements + +- **Docker**: Upgrade to Alpine 3.11. [#24056](https://github.com/grafana/grafana/pull/24056), [@aknuds1](https://github.com/aknuds1) +- **Forms**: Remove Forms namespace [BREAKING]. Will cause all `Forms` imports to stop working. See migration guide in [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md)[#24378](https://github.com/grafana/grafana/pull/24378), [@tskarhed](https://github.com/tskarhed) + +### Bug Fixes + +- **CloudWatch**: Fix error with expression only query. [#24362](https://github.com/grafana/grafana/pull/24362), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Fix building of raw document queries resulting in error Unknown BaseAggregationBuilder error. [#24403](https://github.com/grafana/grafana/pull/24403), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fix for prometheus legend formats for instant time series queries. [#24407](https://github.com/grafana/grafana/pull/24407), [@torkelo](https://github.com/torkelo) + +# 7.0.0-beta2 (2020-05-07) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. + +**Deprecation warnings** + +- Scripted dashboards are now deprecated. The feature is not removed but will be in a future release. We hope to address the underlying requirement of dynamic dashboards in a different way. [#24059](https://github.com/grafana/grafana/issues/24059) + +### Features / Enhancements + +- **CloudWatch**: Adds more examples to CloudWatch Logs cheatsheet. [#24288](https://github.com/grafana/grafana/pull/24288), [@kaydelaney](https://github.com/kaydelaney) +- **Elasticsearch**: Changes terms min_doc_count default from 1 to 0. [#24204](https://github.com/grafana/grafana/pull/24204), [@Estrax](https://github.com/Estrax) +- **Login Page**: New design. [#23892](https://github.com/grafana/grafana/pull/23892), [@torkelo](https://github.com/torkelo) +- **Logs**: Add log level Fatal. [#24185](https://github.com/grafana/grafana/pull/24185), [@davkal](https://github.com/davkal) +- **Loki**: Show loki datasource stats in panel inspector. [#24190](https://github.com/grafana/grafana/pull/24190), [@davkal](https://github.com/davkal) +- **Migration**: Dashboard links. [#23553](https://github.com/grafana/grafana/pull/23553), [@peterholmberg](https://github.com/peterholmberg) +- **Plugins**: Require signing of external back-end plugins. [#24075](https://github.com/grafana/grafana/pull/24075), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Add off switch for metric/label name lookup. [#24034](https://github.com/grafana/grafana/pull/24034), [@s-h-a-d-o-w](https://github.com/s-h-a-d-o-w) +- **Search**: Bring back open search by clicking dashboard name. [#24151](https://github.com/grafana/grafana/pull/24151), [@torkelo](https://github.com/torkelo) +- **Tracing**: Header updates. [#24153](https://github.com/grafana/grafana/pull/24153), [@aocenas](https://github.com/aocenas) +- **Transformations**: Improve time series support. [#23978](https://github.com/grafana/grafana/pull/23978), [@ryantxu](https://github.com/ryantxu) + +### Bug Fixes + +- **CloudWatch logs**: Fix default region interpolation and reset log groups on region change. [#24346](https://github.com/grafana/grafana/pull/24346), [@aocenas](https://github.com/aocenas) +- **Dashboard**: Fix for folder picker menu not being visible outside modal when saving dashboard. [#24296](https://github.com/grafana/grafana/pull/24296), [@tskarhed](https://github.com/tskarhed) +- **Dashboard**: Go to explore now works even after discarding dashboard changes. [#24149](https://github.com/grafana/grafana/pull/24149), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Only show cache timeout option in panel edit if enabled in data source plugin json. [#24095](https://github.com/grafana/grafana/pull/24095), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Propagate unhandled errors when saving dashboard. [#24081](https://github.com/grafana/grafana/pull/24081), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Variable without a current value in json model causes crash on load. [#24261](https://github.com/grafana/grafana/pull/24261), [@torkelo](https://github.com/torkelo) +- **DashboardManager**: Disable editing if there are no folder permissions. [#24237](https://github.com/grafana/grafana/pull/24237), [@tskarhed](https://github.com/tskarhed) +- **DataLinks**: Do not add empty links. [#24088](https://github.com/grafana/grafana/pull/24088), [@dprokop](https://github.com/dprokop) +- **Explore/Loki**: Removes old query syntax support for regex filter. [#24281](https://github.com/grafana/grafana/pull/24281), [@Estrax](https://github.com/Estrax) +- **Explore**: Fix showing of results of queries in table. [#24018](https://github.com/grafana/grafana/pull/24018), [@ivanahuckova](https://github.com/ivanahuckova) +- **Field options**: show field name when title option config is empty. [#24335](https://github.com/grafana/grafana/pull/24335), [@dprokop](https://github.com/dprokop) +- **Graph**: Fixed graph tooltip getting stuck / not being cleared when leaving dashboard. [#24162](https://github.com/grafana/grafana/pull/24162), [@torkelo](https://github.com/torkelo) +- **Graph**: Fixed issue with x-axis labels showing "MM/DD" after viewing dashboard with pie chart. [#24341](https://github.com/grafana/grafana/pull/24341), [@mckn](https://github.com/mckn) +- **Jaeger**: Fix how label is created in cascader. [#24164](https://github.com/grafana/grafana/pull/24164), [@aocenas](https://github.com/aocenas) +- **Loki**: Fix label matcher for log metrics queries. [#24238](https://github.com/grafana/grafana/pull/24238), [@ivanahuckova](https://github.com/ivanahuckova) +- **Panel inspect**: hides Query tab for plugins without Query ability. [#24216](https://github.com/grafana/grafana/pull/24216), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Refresh query field metrics on data source change. [#24116](https://github.com/grafana/grafana/pull/24116), [@s-h-a-d-o-w](https://github.com/s-h-a-d-o-w) +- **Select**: Fixes so component loses focus on selecting value or pressing outside of input. [#24008](https://github.com/grafana/grafana/pull/24008), [@mckn](https://github.com/mckn) +- **Stat/Gauge/BarGauge**: Shows default cursor when missing links. [#24284](https://github.com/grafana/grafana/pull/24284), [@hugohaggmark](https://github.com/hugohaggmark) +- **Tracing**: Fix view bounds after trace change. [#23994](https://github.com/grafana/grafana/pull/23994), [@aocenas](https://github.com/aocenas) +- **Variables**: Migrates old tags format for consistency. [#24276](https://github.com/grafana/grafana/pull/24276), [@hugohaggmark](https://github.com/hugohaggmark) +- **Reporting**: Update report schedule as soon as a report is updated (Enterprise) +- **White-labeling**: Makes login title and subtitle configurable (Enterprise) + +# 7.0.0-beta1 (2020-04-28) + +## Breaking changes + +- **Removed PhantomJS**: PhantomJS was deprecated in [Grafana v6.4](https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-4/#phantomjs-deprecation) and starting from Grafana v7.0.0, all PhantomJS support has been removed. This means that Grafana no longer ships with a built-in image renderer, and we advise you to install the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). +- **Docker**: Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/). +- **Dashboard**: A global minimum dashboard refresh interval is now enforced and defaults to 5 seconds. +- **@grafana/ui**: Forms migration notice, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **@grafana/ui**: Select API change for creating custom values, see [@grafana/ui changelog](https://github.com/grafana/grafana/blob/master/packages/grafana-ui/CHANGELOG.md) +- **Interval calculation**: There is now a new option `Max data points` that controls the auto interval `$__interval` calculation. Interval was previously calculated by dividing the panel width by the time range. With the new max data points option it is now easy to set `$__interval` to a dynamic value that is time range agnostic. For example if you set `Max data points` to 10 Grafana will dynamically set `$__interval` by dividing the current time range by 10. +- **Datasource/Loki**: Support for [deprecated Loki endpoints](https://github.com/grafana/loki/blob/master/docs/api.md#lokis-http-api) has been removed. + +### Features / Enhancements + +- **@grafana/ui**: Create Icon component and replace icons. [#23402](https://github.com/grafana/grafana/pull/23402), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Create slider component. [#22275](https://github.com/grafana/grafana/pull/22275), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Remove ColorPalette component. [#23592](https://github.com/grafana/grafana/pull/23592), [@ivanahuckova](https://github.com/ivanahuckova) +- **AWS IAM**: Support for AWS EKS ServiceAccount roles for CloudWatch and S3 image upload. [#21594](https://github.com/grafana/grafana/pull/21594), [@patstrom](https://github.com/patstrom) +- **Alerting**: Adds support for basic auth in Alertmanager notifier. [#23231](https://github.com/grafana/grafana/pull/23231), [@melchiormoulin](https://github.com/melchiormoulin) +- **Alerting**: Enable Alert rule tags to override PagerDuty Severity setting. [#22736](https://github.com/grafana/grafana/pull/22736), [@AndrewBurian](https://github.com/AndrewBurian) +- **Alerting**: Handle image renderer unavailable when edit notifiers. [#23711](https://github.com/grafana/grafana/pull/23711), [@marefr](https://github.com/marefr) +- **Alerting**: Upload error image when image renderer unavailable. [#23713](https://github.com/grafana/grafana/pull/23713), [@marefr](https://github.com/marefr) +- **Alerting**: support alerting on data.Frame (that can be time series). [#22812](https://github.com/grafana/grafana/pull/22812), [@kylebrandt](https://github.com/kylebrandt) +- **Azure Monitor**: Add alerting support - Port Azure log analytics to the backend. [#23839](https://github.com/grafana/grafana/pull/23839), [@daniellee](https://github.com/daniellee) +- **Backend plugins**: Support alerting in external data source plugins. [#6841](https://github.com/grafana/grafana/issues/6841) +- **Build**: Bundle plugins. [#23787](https://github.com/grafana/grafana/pull/23787), [@aknuds1](https://github.com/aknuds1) +- **Build**: Remove usage of Go vendoring. [#23796](https://github.com/grafana/grafana/pull/23796), [@kylebrandt](https://github.com/kylebrandt) +- **Build**: Upgrade to Go 1.14. [#23371](https://github.com/grafana/grafana/pull/23371), [@aknuds1](https://github.com/aknuds1) +- **CloudWatch**: Added AWS Chatbot metrics and dimensions. [#23516](https://github.com/grafana/grafana/pull/23516), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Added Cassandra namespace. [#23299](https://github.com/grafana/grafana/pull/23299), [@vikkyomkar](https://github.com/vikkyomkar) +- **CloudWatch**: Added missing Cassandra metrics. [#23467](https://github.com/grafana/grafana/pull/23467), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch**: Adds support for Cloudwatch Logs. [#23566](https://github.com/grafana/grafana/pull/23566), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Prefer webIdentity over EC2 role. [#23452](https://github.com/grafana/grafana/pull/23452), [@dnascimento](https://github.com/dnascimento) +- **CloudWatch**: Prefer webIdentity over EC2 role also when assuming a role. [#23807](https://github.com/grafana/grafana/pull/23807), [@bruecktech](https://github.com/bruecktech) +- **Components**: IconButton. [#23510](https://github.com/grafana/grafana/pull/23510), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Add failsafe for slug generation. [#23709](https://github.com/grafana/grafana/pull/23709), [@sakjur](https://github.com/sakjur) +- **Dashboard**: Enforce minimum dashboard refresh interval to 5 seconds per default. [#23929](https://github.com/grafana/grafana/pull/23929), [@marefr](https://github.com/marefr) +- **Dashboard**: Handle no renderer available in panel share dialog. [#23856](https://github.com/grafana/grafana/pull/23856), [@marefr](https://github.com/marefr) +- **Dashboard**: Support additional variable format options (singlequote, doublequote, sqlstring). [#21622](https://github.com/grafana/grafana/pull/21622), [@xiaobeiyang](https://github.com/xiaobeiyang) +- **Dashboard**: Support data links via field overrides. [#23590](https://github.com/grafana/grafana/pull/23590), [@dprokop](https://github.com/dprokop) +- **Data source**: Max data points now used in interval calculation for all data sources. [#23915](https://github.com/grafana/grafana/pull/23915), [@torkelo](https://github.com/torkelo) +- **Database**: Order results in UserSearch by username/email. [#23328](https://github.com/grafana/grafana/pull/23328), [@aknuds1](https://github.com/aknuds1) +- **Database**: Update the xorm dependency to v0.8.1. [#22376](https://github.com/grafana/grafana/pull/22376), [@novalagung](https://github.com/novalagung) +- **Docker**: Upgrade to Ubuntu 20.04 in Dockerfiles. [#23852](https://github.com/grafana/grafana/pull/23852), [@aknuds1](https://github.com/aknuds1) +- **Docs**: Adding API reference documentation support for the packages libraries. [#21931](https://github.com/grafana/grafana/pull/21931), [@mckn](https://github.com/mckn) +- **Tracing**: Add trace UI to show traces from tracing datasources and Jaeger datasource. [#23047](https://github.com/grafana/grafana/pull/23047), [@aocenas](https://github.com/aocenas) +- **Frontend**: Adding support to select preferred timezone for presentation of date and time values. [#23586](https://github.com/grafana/grafana/pull/23586), [@mckn](https://github.com/mckn) +- **Grafana Toolkit**: Adds template for backend data source. [#23864](https://github.com/grafana/grafana/pull/23864), [@bergquist](https://github.com/bergquist) +- **Graphite**: Rollup indicator and custom meta data inspector. [#22738](https://github.com/grafana/grafana/pull/22738), [@torkelo](https://github.com/torkelo) +- **HTTP API**: Allow assigning a specific organization when creating a new user. [#21775](https://github.com/grafana/grafana/pull/21775), [@Sytten](https://github.com/Sytten) +- **Image Rendering**: New setting to control render request concurrency. [#23950](https://github.com/grafana/grafana/pull/23950), [@marefr](https://github.com/marefr) +- **Image Rendering**: Remove PhantomJS support. [#23460](https://github.com/grafana/grafana/pull/23460), [@marefr](https://github.com/marefr) +- **Logs**: Derived fields link design. [#23695](https://github.com/grafana/grafana/pull/23695), [@aocenas](https://github.com/aocenas) +- **Metrics**: Add image rendering metrics. [#23827](https://github.com/grafana/grafana/pull/23827), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Metrics**: Instrument backend plugin requests. [#23346](https://github.com/grafana/grafana/pull/23346), [@bergquist](https://github.com/bergquist) +- **Migration**: Add old Input to legacy namespace. [#23286](https://github.com/grafana/grafana/pull/23286), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Admin org edit page. [#23866](https://github.com/grafana/grafana/pull/23866), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Alerting - notifications list. [#22548](https://github.com/grafana/grafana/pull/22548), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Change password. [#23623](https://github.com/grafana/grafana/pull/23623), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Create org. [#22542](https://github.com/grafana/grafana/pull/22542), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Data/Panel link editor. [#23778](https://github.com/grafana/grafana/pull/23778), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Final components to LegacyForms. [#23707](https://github.com/grafana/grafana/pull/23707), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Layout Selector. [#23790](https://github.com/grafana/grafana/pull/23790), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Migrate admin/users. [#22759](https://github.com/grafana/grafana/pull/22759), [@mckn](https://github.com/mckn) +- **Migration**: Migrates ad hoc variable type to react/redux. [#22784](https://github.com/grafana/grafana/pull/22784), [@mckn](https://github.com/mckn) +- **Migration**: Move Switch from Forms namespace. [#23386](https://github.com/grafana/grafana/pull/23386), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move last components from Forms namespace. [#23556](https://github.com/grafana/grafana/pull/23556), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move old Switch to legacy namespace. [#23357](https://github.com/grafana/grafana/pull/23357), [@tskarhed](https://github.com/tskarhed) +- **Migration**: New datasource. [#23221](https://github.com/grafana/grafana/pull/23221), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Org users page. [#23372](https://github.com/grafana/grafana/pull/23372), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Orgs list. [#23821](https://github.com/grafana/grafana/pull/23821), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Remove Button from Forms namespace. [#23105](https://github.com/grafana/grafana/pull/23105), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Teams and alert list. [#23810](https://github.com/grafana/grafana/pull/23810), [@tskarhed](https://github.com/tskarhed) +- **Migration**: TextArea from Forms namespace. [#23436](https://github.com/grafana/grafana/pull/23436), [@tskarhed](https://github.com/tskarhed) +- **Migration**: User edit. [#23110](https://github.com/grafana/grafana/pull/23110), [@tskarhed](https://github.com/tskarhed) +- **OAuth**: Adds Okta provider. [#22972](https://github.com/grafana/grafana/pull/22972), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **OAuth**: Introduce new setting for configuring max age of OAuth state cookie. [#23195](https://github.com/grafana/grafana/pull/23195), [@rtrompier](https://github.com/rtrompier) +- **Plugins**: Add deprecation notice to setEditor method in PanelPlugin. [#23895](https://github.com/grafana/grafana/pull/23895), [@dprokop](https://github.com/dprokop) +- **Plugins**: Adds support for URL params in plugin routes. [#23503](https://github.com/grafana/grafana/pull/23503), [@daniellee](https://github.com/daniellee) +- **Plugins**: Fluent API for custom field config and panel options creation for PanelPlugin. [#23070](https://github.com/grafana/grafana/pull/23070), [@dprokop](https://github.com/dprokop) +- **Plugins**: Hide plugins page from viewers, and limit /api/plugins to only core plugins when called by viewer role. [#21901](https://github.com/grafana/grafana/pull/21901), [@dprokop](https://github.com/dprokop) +- **Postgres**: Add SSL support for datasource. [#21341](https://github.com/grafana/grafana/pull/21341), [@ryankurte](https://github.com/ryankurte) +- **Prometheus**: Render missing labels in legend formats as an empty string. [#22355](https://github.com/grafana/grafana/pull/22355), [@Hixon10](https://github.com/Hixon10) +- **Provisioning**: Allows specifying uid for datasource and use that in derived fields. [#23585](https://github.com/grafana/grafana/pull/23585), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Validate that dashboard providers have unique names. [#22898](https://github.com/grafana/grafana/pull/22898), [@youshy](https://github.com/youshy) +- **Search**: Replace search implementation. [#23855](https://github.com/grafana/grafana/pull/23855), [@sakjur](https://github.com/sakjur) +- **Search**: migrate dashboard search to react. [#23274](https://github.com/grafana/grafana/pull/23274), [@Clarity-89](https://github.com/Clarity-89) +- **Server**: Don't include trailing slash in cookie path when hosting Grafana in a sub path. [#22265](https://github.com/grafana/grafana/pull/22265), [@consideRatio](https://github.com/consideRatio) +- **Stackdriver**: Support for SLO queries. [#22917](https://github.com/grafana/grafana/pull/22917), [@sunker](https://github.com/sunker) +- **Table**: Add support for organizing fields/columns. [#23135](https://github.com/grafana/grafana/pull/23135), [@mckn](https://github.com/mckn) +- **Table**: Improvements to column resizing, style and alignment. [#23663](https://github.com/grafana/grafana/pull/23663), [@torkelo](https://github.com/torkelo) +- **Table**: upgrades react-table to 7.0.0 and typings. [#23247](https://github.com/grafana/grafana/pull/23247), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Handle column overflow and horizontal scrolling in table panel. [#4157](https://github.com/grafana/grafana/issues/4157) +- **Tracing**: Dark theme styling for TraceView. [#23406](https://github.com/grafana/grafana/pull/23406), [@aocenas](https://github.com/aocenas) +- **Tracing**: Zipkin datasource. [#23829](https://github.com/grafana/grafana/pull/23829), [@aocenas](https://github.com/aocenas) +- **Transformations**: Adds labels as fields transformer. [#23703](https://github.com/grafana/grafana/pull/23703), [@hugohaggmark](https://github.com/hugohaggmark) +- **Transformations**: Improve UI and add some love to filter by name. [#23751](https://github.com/grafana/grafana/pull/23751), [@dprokop](https://github.com/dprokop) +- **Transformations**: calculate a new field based on the row values. [#23675](https://github.com/grafana/grafana/pull/23675), [@ryantxu](https://github.com/ryantxu) +- **Units**: add (IEC) and (Metric) to bits and bytes. [#23175](https://github.com/grafana/grafana/pull/23175), [@flopp999](https://github.com/flopp999) +- **Usagestats**: Add usage stats about what type of data source is used in alerting. [#23125](https://github.com/grafana/grafana/pull/23125), [@bergquist](https://github.com/bergquist) +- **delete old dashboard versions in multiple batches**. [#23348](https://github.com/grafana/grafana/pull/23348), [@DanCech](https://github.com/DanCech) +- **grafana/data**: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING]. [#22754](https://github.com/grafana/grafana/pull/22754), [@dprokop](https://github.com/dprokop) +- **grafana/ui**: Add basic horizontal and vertical layout components. [#22303](https://github.com/grafana/grafana/pull/22303), [@dprokop](https://github.com/dprokop) +- **Auth** SAML Role and Team Sync (Enterprise) +- **Presence Indicators**: Display the avatars of active users on dashboards (Enterprise) +- **Reporting**: Makes it possible to disable the scheduler (Enterprise) +- **Dashboard**: Dashboard usage view (Enterprise) +- **Reporting** Makes it possible to trigger report emails without scheduler (Enterprise) +- **Search**: Sorting based on dashboard views and errors (Enterprise) +- **Reporting**: Improved landscape mode and panel image quality (Enterprise) +- **Reporting**: Adds config setting for image_scale_factor of panel images (Enterprise) + +### Bug Fixes + +- **@grafana/ui**: Fix time range when only partial datetime is provided. [#23122](https://github.com/grafana/grafana/pull/23122), [@ivanahuckova](https://github.com/ivanahuckova) +- **Alerting**: Only include image in notifier when enabled. [#23194](https://github.com/grafana/grafana/pull/23194), [@marefr](https://github.com/marefr) +- **Alerting**: Basic auth should not be required in the Alertmanager notifier. [#23691](https://github.com/grafana/grafana/pull/23691), [@bergquist](https://github.com/bergquist) +- **Alerting**: Translate notification IDs to UIDs when extracting alert rules. [#19882](https://github.com/grafana/grafana/pull/19882), [@aSapien](https://github.com/aSapien) +- **Azure Monitor**: Fix for application insights Azure China plugin route. [#23877](https://github.com/grafana/grafana/pull/23877), [@daniellee](https://github.com/daniellee) +- **CloudWatch**: Add ServerlessDatabaseCapacity to AWS/RDS metrics. [#23635](https://github.com/grafana/grafana/pull/23635), [@jackstevenson](https://github.com/jackstevenson) +- **Dashboard**: Fix global variable "\_\_org.id". [#23362](https://github.com/grafana/grafana/pull/23362), [@vikkyomkar](https://github.com/vikkyomkar) +- **Dashboard**: Handle min refresh interval when importing dashboard. [#23959](https://github.com/grafana/grafana/pull/23959), [@marefr](https://github.com/marefr) +- **DataSourceProxy**: Handle URL parsing error. [#23731](https://github.com/grafana/grafana/pull/23731), [@aknuds1](https://github.com/aknuds1) +- **Frontend**: Fix sorting of organization popup in alphabetical order. [#22259](https://github.com/grafana/grafana/pull/22259), [@vikkyomkar](https://github.com/vikkyomkar) +- **Image Rendering**: Make it work using serve_from_sub_path configured. [#23706](https://github.com/grafana/grafana/pull/23706), [@marefr](https://github.com/marefr) +- **Image rendering**: Fix missing icon on plugins list. [#23958](https://github.com/grafana/grafana/pull/23958), [@marefr](https://github.com/marefr) +- **Logs**: Fix error when non-string log level supplied. [#23654](https://github.com/grafana/grafana/pull/23654), [@ivanahuckova](https://github.com/ivanahuckova) +- **Rich history**: Fix create url and run query for various datasources. [#23627](https://github.com/grafana/grafana/pull/23627), [@ivanahuckova](https://github.com/ivanahuckova) +- **Security**: Fix XSS vulnerability in table panel. [#23816](https://github.com/grafana/grafana/pull/23816), [@torkelo](https://github.com/torkelo) + + + +# 6.7.5 (2020-12-17) + +### Security + +- **SAML**: Fixes encoding/xml SAML vulnerability in Grafana Enterprise [#29875](https://github.com/grafana/grafana/issues/29875), [@bergquist](https://github.com/bergquist) + + + + + +# 6.7.4 (2020-06-03) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2020/06/03/grafana-6.7.4-and-7.0.2-released-with-important-security-fix/) + +# 6.7.3 (2020-04-23) + +### Bug Fixes + +- **Admin**: Fix Synced via LDAP message for non-LDAP external users. [#23477](https://github.com/grafana/grafana/pull/23477), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Alerting**: Fixes notifications for alerts with empty message in Google Hangouts notifier. [#23559](https://github.com/grafana/grafana/pull/23559), [@hugohaggmark](https://github.com/hugohaggmark) +- **AuthProxy**: Fixes bug where long username could not be cached.. [#22926](https://github.com/grafana/grafana/pull/22926), [@jcmcken](https://github.com/jcmcken) +- **Dashboard**: Fix saving dashboard when editing raw dashboard JSON model. [#23314](https://github.com/grafana/grafana/pull/23314), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard**: Try to parse 8 and 15 digit numbers as timestamps if parsing of time range as date fails. [#21694](https://github.com/grafana/grafana/pull/21694), [@jessetan](https://github.com/jessetan) +- **DashboardListPanel**: Fixed problem with empty panel after going into edit mode (General folder filter being automatically added) . [#23426](https://github.com/grafana/grafana/pull/23426), [@torkelo](https://github.com/torkelo) +- **Data source**: Handle datasource withCredentials option properly. [#23380](https://github.com/grafana/grafana/pull/23380), [@hvtuananh](https://github.com/hvtuananh) +- **Security**: Fix annotation popup XSS vulnerability [#23813](https://github.com/grafana/grafana/pull/23813), [@torkelo](https://github.com/torkelo). Big thanks to Juha Laaksonen for reporting this issue. +- **Security**: Fix XSS vulnerability in table panel [#23816](https://github.com/grafana/grafana/pull/23816), [@torkelo](https://github.com/torkelo). Big thanks to Rotem Reiss for reporting this issue. +- **Server**: Exit Grafana with status code 0 if no error. [#23312](https://github.com/grafana/grafana/pull/23312), [@aknuds1](https://github.com/aknuds1) +- **TablePanel**: Fix XSS issue in header column rename (backport). [#23814](https://github.com/grafana/grafana/pull/23814), [@torkelo](https://github.com/torkelo) +- **Variables**: Fixes error when setting adhoc variable values. [#23580](https://github.com/grafana/grafana/pull/23580), [@hugohaggmark](https://github.com/hugohaggmark) + +# 6.7.2 (2020-04-02) + +### Bug Fixes + +- **BackendSrv**: Adds config to response to fix issue for external plugins that used this property . [#23032](https://github.com/grafana/grafana/pull/23032), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixed issue with saving new dashboard after changing title . [#23104](https://github.com/grafana/grafana/pull/23104), [@dprokop](https://github.com/dprokop) +- **DataLinks**: make sure we use the correct datapoint when dataset contains null value.. [#22981](https://github.com/grafana/grafana/pull/22981), [@mckn](https://github.com/mckn) +- **Plugins**: Fixed issue for plugins that imported dateMath util . [#23069](https://github.com/grafana/grafana/pull/23069), [@mckn](https://github.com/mckn) +- **Security**: Fix for dashboard snapshot original dashboard link could contain XSS vulnerability in url. [#23254](https://github.com/grafana/grafana/pull/23254), [@torkelo](https://github.com/torkelo). Big thanks to Ahmed A. Sherif for reporting this issue. +- **Variables**: Fixes issue with too many queries being issued for nested template variables after value change. [#23220](https://github.com/grafana/grafana/pull/23220), [@torkelo](https://github.com/torkelo) +- **Plugins**: Expose promiseToDigest. [#23249](https://github.com/grafana/grafana/pull/23249), [@torkelo](https://github.com/torkelo) +- **Reporting**: Fixes issue updating a report created by someone else (Enterprise) + +# 6.7.1 (2020-03-20) + +### Bug Fixes + +- **Azure**: Fixed dropdowns not showing current value. [#22914](https://github.com/grafana/grafana/pull/22914), [@torkelo](https://github.com/torkelo) +- **BackendSrv**: only add content-type on POST, PUT requests. [#22910](https://github.com/grafana/grafana/pull/22910), [@hugohaggmark](https://github.com/hugohaggmark) +- **Panels**: Fixed size issue with panel internal size when exiting panel edit mode. [#22912](https://github.com/grafana/grafana/pull/22912), [@torkelo](https://github.com/torkelo) +- **Reporting**: fixes migrations compatibility with mysql (Enterprise) +- **Reporting**: Reduce default concurrency limit to 4 (Enterprise) + +# 6.7.0 (2020-03-19) + +### Features / Enhancements + +- **AzureMonitor**: support workspaces function for template variables. [#22882](https://github.com/grafana/grafana/pull/22882), [@daniellee](https://github.com/daniellee) +- **SQLStore**: Add migration for adding index on annotation.alert_id. [#22876](https://github.com/grafana/grafana/pull/22876), [@aknuds1](https://github.com/aknuds1) +- **TablePanel**: Enable new units picker . [#22833](https://github.com/grafana/grafana/pull/22833), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **AngularPanels**: Fixed inner height calculation for angular panels . [#22796](https://github.com/grafana/grafana/pull/22796), [@torkelo](https://github.com/torkelo) +- **BackendSrv**: makes sure provided headers are correctly recognized and set. [#22778](https://github.com/grafana/grafana/pull/22778), [@hugohaggmark](https://github.com/hugohaggmark) +- **Forms**: Fix input suffix position (caret-down in Select) . [#22780](https://github.com/grafana/grafana/pull/22780), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixed issue with query editor and next select metric now showing after selecting metric node . [#22856](https://github.com/grafana/grafana/pull/22856), [@torkelo](https://github.com/torkelo) +- **Rich History**: UX adjustments and fixes. [#22729](https://github.com/grafana/grafana/pull/22729), [@ivanahuckova](https://github.com/ivanahuckova) + +# 6.7.0-beta1 (2020-03-12) + +## Breaking changes + +- **Slack**: Removed _Mention_ setting and instead introduce _Mention Users_, _Mention Groups_, and _Mention Channel_. The first two settings require user and group IDs, respectively. This change was necessary because the way of mentioning via the Slack API [changed](https://api.slack.com/changelog/2017-09-the-one-about-usernames) and mentions in Slack notifications no longer worked. +- **Alerting**: Reverts the behavior of `diff` and `percent_diff` to not always be absolute. Something we introduced by mistake in [6.1.0](https://github.com/grafana/grafana/commit/28eaac3a9c7082e8c496005c1cb66b4b70a4f82f). Alerting now support `diff()`, `diff_abs()`, `percent_diff()` and `percent_diff_abs()`. [#21338](https://github.com/grafana/grafana/pull/21338) + +### Notice about changes in backendSrv for plugin authors + +In our mission to migrate away from AngularJS to React we have removed all AngularJS dependencies in the core data retrieval service `backendSrv`. + +Removing the AngularJS dependencies in `backendSrv` has the unfortunate side effect of AngularJS digest no longer being triggered for any request made with `backendSrv`. Because of this, external plugins using `backendSrv` directly may suffer from strange behaviour in the UI. + +To remedy this issue, as a plugin author you need to trigger the digest after a direct call to `backendSrv`. + +Example: + +```js +backendSrv.get(‘http://your.url/api’).then(result => { + this.result = result; + this.$scope.$digest(); +}); +``` + +Another unfortunate outcome from this work in `backendSrv` is that the response format for `.headers()` changed from a function to an object. + +To make your plugin work on 6.7.x as well as on previous versions you should add something like the following: + +```typescript +let responseHeaders = response.headers; +if (!responseHeaders) { + return null; +} + +// Support pre 6.7 angular HTTP rather than fetch +if (typeof responseHeaders === 'function') { + responseHeaders = responseHeaders(); +} +``` + +You can test your plugin with the `master` branch version of Grafana. + +### Features / Enhancements + +- **API**: Include IP address when logging request error. [#21596](https://github.com/grafana/grafana/pull/21596), [@thedeveloperr](https://github.com/thedeveloperr) +- **Alerting**: Support passing tags to Pagerduty and allow notification on specific event categories . [#21335](https://github.com/grafana/grafana/pull/21335), [@johntdyer](https://github.com/johntdyer) +- **Chore**: Remove angular dependency from backendSrv. [#20999](https://github.com/grafana/grafana/pull/20999), [@kaydelaney](https://github.com/kaydelaney) +- **CloudWatch**: Surround dimension names with double quotes. [#22222](https://github.com/grafana/grafana/pull/22222), [@jeet-parekh](https://github.com/jeet-parekh) +- **CloudWatch**: updated metrics and dimensions for Athena, DocDB, and Route53Resolver. [#22604](https://github.com/grafana/grafana/pull/22604), [@jeet-parekh](https://github.com/jeet-parekh) +- **Cloudwatch**: add Usage Metrics. [#22179](https://github.com/grafana/grafana/pull/22179), [@passing](https://github.com/passing) +- **Dashboard**: Adds support for a global minimum dashboard refresh interval. [#19416](https://github.com/grafana/grafana/pull/19416), [@lfroment0](https://github.com/lfroment0) +- **DatasourceEditor**: Add UI to edit custom HTTP headers. [#17846](https://github.com/grafana/grafana/pull/17846), [@adrien-f](https://github.com/adrien-f) +- **Elastic**: To get fields, start with today's index and go backwards. [#22318](https://github.com/grafana/grafana/pull/22318), [@ChadiEM](https://github.com/ChadiEM) +- **Explore**: Rich history. [#22570](https://github.com/grafana/grafana/pull/22570), [@ivanahuckova](https://github.com/ivanahuckova) +- **Graph**: canvas's Stroke is executed after loop. [#22610](https://github.com/grafana/grafana/pull/22610), [@merturl](https://github.com/merturl) +- **Graphite**: Don't issue empty "select metric" queries. [#22699](https://github.com/grafana/grafana/pull/22699), [@papagian](https://github.com/papagian) +- **Image Rendering**: Store render key in remote cache to enable renderer to callback to public/load balancer URL when running in HA mode. [#22031](https://github.com/grafana/grafana/pull/22031), [@marefr](https://github.com/marefr) +- **LDAP**: Add fallback to search_base_dns if group_search_base_dns is undefined.. [#21263](https://github.com/grafana/grafana/pull/21263), [@bb-Ricardo](https://github.com/bb-Ricardo) +- **OAuth**: Implement Azure AD provide. [#20030](https://github.com/grafana/grafana/pull/20030), [@twendt](https://github.com/twendt) +- **Prometheus**: Implement region annotation. [#22225](https://github.com/grafana/grafana/pull/22225), [@secustor](https://github.com/secustor) +- **Prometheus**: make \$\_\_range more precise. [#21722](https://github.com/grafana/grafana/pull/21722), [@bmerry](https://github.com/bmerry) +- **Prometheus**: Do not show rate hint when increase function is used in query. [#21955](https://github.com/grafana/grafana/pull/21955), [@fredwangwang](https://github.com/fredwangwang) +- **Stackdriver**: Project selector. [#22447](https://github.com/grafana/grafana/pull/22447), [@sunker](https://github.com/sunker) +- **TablePanel**: display multi-line text. [#20210](https://github.com/grafana/grafana/pull/20210), [@michael-az](https://github.com/michael-az) +- **Templating**: Add new global built-in variables. [#21790](https://github.com/grafana/grafana/pull/21790), [@dcastanier](https://github.com/dcastanier) +- **Reporting**: add concurrent render limit to settings (Enterprise) +- **Reporting**: Add rendering timeout in settings (Enterprise) + +### Bug Fixes + +- **API**: Fix redirect issues. [#22285](https://github.com/grafana/grafana/pull/22285), [@papagian](https://github.com/papagian) +- **Alerting**: Don't include image_url field with Slack message if empty. [#22372](https://github.com/grafana/grafana/pull/22372), [@aknuds1](https://github.com/aknuds1) +- **Alerting**: Fixed bad background color for default notifications in alert tab . [#22660](https://github.com/grafana/grafana/pull/22660), [@krvajal](https://github.com/krvajal) +- **Annotations**: In table panel when setting transform to annotation, they will now show up right away without a manual refresh. [#22323](https://github.com/grafana/grafana/pull/22323), [@krvajal](https://github.com/krvajal) +- **Azure Monitor**: Fix app insights source to allow for new **timeFrom and **timeTo. [#21879](https://github.com/grafana/grafana/pull/21879), [@ChadNedzlek](https://github.com/ChadNedzlek) +- **BackendSrv**: Fixes POST body for form data. [#21714](https://github.com/grafana/grafana/pull/21714), [@hugohaggmark](https://github.com/hugohaggmark) +- **CloudWatch**: Credentials cache invalidation fix. [#22473](https://github.com/grafana/grafana/pull/22473), [@sunker](https://github.com/sunker) +- **CloudWatch**: Expand alias variables when query yields no result. [#22695](https://github.com/grafana/grafana/pull/22695), [@sunker](https://github.com/sunker) +- **Dashboard**: Fix bug with NaN in alerting. [#22053](https://github.com/grafana/grafana/pull/22053), [@a-melnyk](https://github.com/a-melnyk) +- **Explore**: Fix display of multiline logs in log panel and explore. [#22057](https://github.com/grafana/grafana/pull/22057), [@thomasdraebing](https://github.com/thomasdraebing) +- **Heatmap**: Legend color range is incorrect when using custom min/max. [#21748](https://github.com/grafana/grafana/pull/21748), [@sv5d](https://github.com/sv5d) +- **Security**: Fixed XSS issue in dashboard history diff . [#22680](https://github.com/grafana/grafana/pull/22680), [@torkelo](https://github.com/torkelo) +- **StatPanel**: Fixes base color is being used for null values . + [#22646](https://github.com/grafana/grafana/pull/22646), [@torkelo](https://github.com/torkelo) + +# 6.6.2 (2020-02-20) + +### Features / Enhancements + +- **Data proxy**: Log proxy errors using Grafana logger. [#22174](https://github.com/grafana/grafana/pull/22174), [@bergquist](https://github.com/bergquist) +- **Metrics**: Add gauge for requests currently in flight. [#22168](https://github.com/grafana/grafana/pull/22168), [@bergquist](https://github.com/bergquist) + +### Bug Fixes + +- **@grafana/ui**: Fix displaying of bars in React Graph. [#21968](https://github.com/grafana/grafana/pull/21968), [@ivanahuckova](https://github.com/ivanahuckova) +- **API**: Fix redirect issue when configured to use a subpath. [#21652](https://github.com/grafana/grafana/pull/21652), [@briangann](https://github.com/briangann) +- **API**: Improve recovery middleware when response already been written. [#22256](https://github.com/grafana/grafana/pull/22256), [@marefr](https://github.com/marefr) +- **Auth**: Don't rotate auth token when requests are cancelled by client. [#22106](https://github.com/grafana/grafana/pull/22106), [@bergquist](https://github.com/bergquist) +- **Docker**: Downgrade to 18.04 LTS base image. [#22313](https://github.com/grafana/grafana/pull/22313), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Fix auto interval for date histogram in explore logs mode. [#21937](https://github.com/grafana/grafana/pull/21937), [@ivanahuckova](https://github.com/ivanahuckova) +- **Image Rendering**: Fix PhantomJS compatibility with es2016 node dependencies. [#21677](https://github.com/grafana/grafana/pull/21677), [@dprokop](https://github.com/dprokop) +- **Links**: Assure base url when single stat, panel and data links are built. [#21956](https://github.com/grafana/grafana/pull/21956), [@dprokop](https://github.com/dprokop) +- **Loki, Prometheus**: Fix PromQL and LogQL syntax highlighting. [#21944](https://github.com/grafana/grafana/pull/21944), [@ivanahuckova](https://github.com/ivanahuckova) +- **OAuth**: Enforce auto_assign_org_id setting when role mapping enabled using Generic OAuth. [#22268](https://github.com/grafana/grafana/pull/22268), [@aknuds1](https://github.com/aknuds1) +- **Prometheus**: Updates explore query editor to prevent it from throwing error on edit. [#21605](https://github.com/grafana/grafana/pull/21605), [@Estrax](https://github.com/Estrax) +- **Server**: Reorder cipher suites for better security. [#22101](https://github.com/grafana/grafana/pull/22101), [@tofu-rocketry](https://github.com/tofu-rocketry) +- **TimePicker**: fixing weird behavior with calendar when switching between months/years . [#22253](https://github.com/grafana/grafana/pull/22253), [@mckn](https://github.com/mckn) + +# 6.6.1 (2020-02-06) + +### Bug Fixes + +- **Annotations**: Change indices and rewrites annotation find query to improve database query performance. [#21915](https://github.com/grafana/grafana/pull/21915), [@papagian](https://github.com/papagian), [@marefr](https://github.com/marefr), [@kylebrandt](https://github.com/kylebrandt) +- **Azure Monitor**: Fix Application Insights API key field to allow input. [#21738](https://github.com/grafana/grafana/pull/21738), [@shavonn](https://github.com/shavonn) +- **BarGauge**: Fix so we properly display the "no result" value when query returns empty result. [#21791](https://github.com/grafana/grafana/pull/21791), [@mckn](https://github.com/mckn) +- **Datasource**: Show access (Browser/Server) select on the Prometheus datasource. [#21833](https://github.com/grafana/grafana/pull/21833), [@jorgelbg](https://github.com/jorgelbg) +- **DatasourceSettings**: Fixed issue navigating away from data source settings page. [#21841](https://github.com/grafana/grafana/pull/21841), [@torkelo](https://github.com/torkelo) +- **Graph Panel**: Fix typo in thresholds form. [#21903](https://github.com/grafana/grafana/pull/21903), [@orendain](https://github.com/orendain) +- **Graphite**: Fixed issue with functions with multiple required params and no defaults caused params that could not be edited (groupByNodes groupByTags). [#21814](https://github.com/grafana/grafana/pull/21814), [@torkelo](https://github.com/torkelo) +- **Image Rendering**: Fix render of graph panel legend aligned to the right using Grafana image renderer plugin/service. [#21854](https://github.com/grafana/grafana/pull/21854), [@marefr](https://github.com/marefr) +- **Metrics**: Adds back missing summary quantiles. [#21858](https://github.com/grafana/grafana/pull/21858), [@kogent](https://github.com/kogent) +- **OpenTSDB**: Adds back missing ngInject to make it work again. [#21796](https://github.com/grafana/grafana/pull/21796), [@marefr](https://github.com/marefr) +- **Plugins**: Fix routing in app plugin pages. [#21847](https://github.com/grafana/grafana/pull/21847), [@dprokop](https://github.com/dprokop) +- **Prometheus**: Fixes default step value for annotation query. [#21934](https://github.com/grafana/grafana/pull/21934), [@hugohaggmark](https://github.com/hugohaggmark) +- **Quota**: Makes LDAP + Quota work for the first login of a new user. [#21949](https://github.com/grafana/grafana/pull/21949), [@xlson](https://github.com/xlson) +- **StatPanels**: Fixed change from singlestat to Gauge / BarGauge / Stat where default min & max (0, 100) was copied . [#21820](https://github.com/grafana/grafana/pull/21820), [@torkelo](https://github.com/torkelo) +- **TimePicker**: Should display in kiosk mode. [#21816](https://github.com/grafana/grafana/pull/21816), [@evgbibko](https://github.com/evgbibko) +- **grafana/toolkit**: Fix failing linter when there were lint issues. [#21849](https://github.com/grafana/grafana/pull/21849), [@dprokop](https://github.com/dprokop) + +# 6.6.0 (2020-01-27) + +### Features / Enhancements + +- **CloudWatch**: Add DynamoDB Accelerator (DAX) metrics & dimensions. [#21644](https://github.com/grafana/grafana/pull/21644), [@kenju](https://github.com/kenju) +- **CloudWatch**: Auto period snap to next higher period. [#21659](https://github.com/grafana/grafana/pull/21659), [@sunker](https://github.com/sunker) +- **Template variables**: Add error for failed query variable on time range update. [#21731](https://github.com/grafana/grafana/pull/21731), [@tskarhed](https://github.com/tskarhed) +- **XSS**: Sanitize column link. [#21735](https://github.com/grafana/grafana/pull/21735), [@tskarhed](https://github.com/tskarhed) + +### Bug Fixes + +- **Elasticsearch**: Fix adhoc variable filtering for logs query. [#21346](https://github.com/grafana/grafana/pull/21346), [@ceh](https://github.com/ceh) +- **Explore**: Fix colors for log level when level value is capitalised. [#21646](https://github.com/grafana/grafana/pull/21646), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix context view in logs, where some rows may have been filtered out.. [#21729](https://github.com/grafana/grafana/pull/21729), [@aocenas](https://github.com/aocenas) +- **Loki**: Fix Loki with repeated panels and interpolation for Explore. [#21685](https://github.com/grafana/grafana/pull/21685), [@ivanahuckova](https://github.com/ivanahuckova) +- **SQLStore**: Fix PostgreSQL failure to create organisation for first time. [#21648](https://github.com/grafana/grafana/pull/21648), [@papagian](https://github.com/papagian) + +# 6.6.0-beta1 (2020-01-20) + +## Breaking changes + +- **PagerDuty**: Change `payload.custom_details` field in PagerDuty notification to be a JSON object instead of a string. +- **Security**: The `[security]` setting `cookie_samesite` configured to `none` now renders cookies with `SameSite=None` attribute compared to before where no `SameSite` attribute was added to cookies. To get the old behavior, use value `disabled` instead of `none`. Refer to [Upgrade Grafana](https://grafana.com/docs/grafana/latest/installation/upgrading/#upgrading-to-v6-6) for more information. + +### Features / Enhancements + +- **Graphite**: Add Metrictank dashboard to Graphite datasource +- **Admin**: Show name of user in users table view. [#18108](https://github.com/grafana/grafana/pull/18108), [@eleijonmarck](https://github.com/eleijonmarck) +- **Alerting**: Add configurable severity support for PagerDuty notifier. [#19425](https://github.com/grafana/grafana/pull/19425), [@yemble](https://github.com/yemble) +- **Alerting**: Add more information to webhook notifications. [#20420](https://github.com/grafana/grafana/pull/20420), [@michael-az](https://github.com/michael-az) +- **Alerting**: Add support for sending tags in OpsGenie notifier. [#20810](https://github.com/grafana/grafana/pull/20810), [@aSapien](https://github.com/aSapien) +- **Alerting**: Added fallbackText to Google Chat notifier. [#21464](https://github.com/grafana/grafana/pull/21464), [@alvarolmedo](https://github.com/alvarolmedo) +- **Alerting**: Adds support for sending a single email to all recipients in email notifier. [#21091](https://github.com/grafana/grafana/pull/21091), [@marefr](https://github.com/marefr) +- **Alerting**: Enable setting of OpsGenie priority via a tag. [#21298](https://github.com/grafana/grafana/pull/21298), [@zabullet](https://github.com/zabullet) +- **Alerting**: Use fully qualified status emoji in Threema notifier. [#21305](https://github.com/grafana/grafana/pull/21305), [@dbrgn](https://github.com/dbrgn) +- **Alerting**: new min_interval_seconds option to enforce a minimum evaluation frequency . [#21188](https://github.com/grafana/grafana/pull/21188), [@papagian](https://github.com/papagian) +- **CloudWatch**: Calculate period based on time range. [#21471](https://github.com/grafana/grafana/pull/21471), [@sunker](https://github.com/sunker) +- **CloudWatch**: Display partial result in graph when max DP/call limit is reached . [#21533](https://github.com/grafana/grafana/pull/21533), [@sunker](https://github.com/sunker) +- **CloudWatch**: ECS/ContainerInsights metrics support. [#21125](https://github.com/grafana/grafana/pull/21125), [@briancurt](https://github.com/briancurt) +- **CloudWatch**: Upgrade aws-sdk-go. [#20510](https://github.com/grafana/grafana/pull/20510), [@mtanda](https://github.com/mtanda) +- **DataLinks**: allow using values from other fields in the same row (cells). [#21478](https://github.com/grafana/grafana/pull/21478), [@ryantxu](https://github.com/ryantxu) +- **Editor**: Ignore closing brace when it was added by editor. [#21172](https://github.com/grafana/grafana/pull/21172), [@davkal](https://github.com/davkal) +- **Explore**: Context tooltip to copy labels and values from graph. [#21405](https://github.com/grafana/grafana/pull/21405), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Log message line wrapping options for logs. [#20360](https://github.com/grafana/grafana/pull/20360), [@ivanahuckova](https://github.com/ivanahuckova) +- **Forms**: introduce RadioButtonGroup. [#20828](https://github.com/grafana/grafana/pull/20828), [@dprokop](https://github.com/dprokop) +- **Frontend**: Changes in Redux location should not strip subpath from location url. [#20161](https://github.com/grafana/grafana/pull/20161), [@wybczu](https://github.com/wybczu) +- **Graph**: Add fill gradient option to series override line fill. [#20941](https://github.com/grafana/grafana/pull/20941), [@hendrikvh](https://github.com/hendrikvh) +- **Graphite**: Add metrictank dashboard to Graphite datasource. [#20776](https://github.com/grafana/grafana/pull/20776), [@Dieterbe](https://github.com/Dieterbe) +- **Graphite**: Do not change query when opening the query editor and there is no data. [#21588](https://github.com/grafana/grafana/pull/21588), [@daniellee](https://github.com/daniellee) +- **Gravatar**: Use HTTPS by default. [#20964](https://github.com/grafana/grafana/pull/20964), [@jiajunhuang](https://github.com/jiajunhuang) +- **Loki**: Support for template variable queries. [#20697](https://github.com/grafana/grafana/pull/20697), [@ivanahuckova](https://github.com/ivanahuckova) +- **NewsPanel**: Add news as a builtin panel. [#21128](https://github.com/grafana/grafana/pull/21128), [@ryantxu](https://github.com/ryantxu) +- **OAuth**: Removes send_client_credentials_via_post setting . [#20044](https://github.com/grafana/grafana/pull/20044), [@LK4D4](https://github.com/LK4D4) +- **OpenTSDB**: Adding lookup limit to OpenTSDB datasource settings. [#20647](https://github.com/grafana/grafana/pull/20647), [@itamarst](https://github.com/itamarst) +- **Postgres/MySQL/MSSQL**: Adds support for region annotations. [#20752](https://github.com/grafana/grafana/pull/20752), [@Bercon](https://github.com/Bercon) +- **Prometheus**: Field to specify step in Explore. [#20195](https://github.com/grafana/grafana/pull/20195), [@Estrax](https://github.com/Estrax) +- **Prometheus**: User metrics metadata to inform query hints. [#21304](https://github.com/grafana/grafana/pull/21304), [@davkal](https://github.com/davkal) +- **Renderer**: Add user-agent to remote rendering service requests. [#20956](https://github.com/grafana/grafana/pull/20956), [@kfdm](https://github.com/kfdm) +- **Security**: Add disabled option for cookie samesite attribute. [#21472](https://github.com/grafana/grafana/pull/21472), [@marefr](https://github.com/marefr) +- **Stackdriver**: Support meta labels. [#21373](https://github.com/grafana/grafana/pull/21373), [@sunker](https://github.com/sunker) +- **TablePanel, GraphPanel**: Exclude hidden columns from CSV. [#19925](https://github.com/grafana/grafana/pull/19925), [@literalplus](https://github.com/literalplus) +- **Templating**: Update variables on location changed. [#21480](https://github.com/grafana/grafana/pull/21480), [@ryantxu](https://github.com/ryantxu) +- **Tracing**: Support configuring Jaeger client from environment. [#21103](https://github.com/grafana/grafana/pull/21103), [@hairyhenderson](https://github.com/hairyhenderson) +- **Units**: Add currency and energy units. [#20428](https://github.com/grafana/grafana/pull/20428), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +- **Units**: Support dynamic count and currency units. [#21279](https://github.com/grafana/grafana/pull/21279), [@ryantxu](https://github.com/ryantxu) +- **grafana/toolkit**: Add option to override webpack config. [#20872](https://github.com/grafana/grafana/pull/20872), [@sebimarkgraf](https://github.com/sebimarkgraf) +- **grafana/ui**: ConfirmModal component. [#20965](https://github.com/grafana/grafana/pull/20965), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **grafana/ui**: Create Tabs component. [#21328](https://github.com/grafana/grafana/pull/21328), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New table component. [#20991](https://github.com/grafana/grafana/pull/20991), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New updated time picker. [#20931](https://github.com/grafana/grafana/pull/20931), [@mckn](https://github.com/mckn) +- **White-labeling**: Makes it possible to customize the footer and login background (Enterprise) + +### Bug Fixes + +- **API**: Optionally list expired API keys. [#20468](https://github.com/grafana/grafana/pull/20468), [@papagian](https://github.com/papagian) +- **Alerting**: Fix custom_details to be a JSON object instead of a string in PagerDuty notifier. [#21150](https://github.com/grafana/grafana/pull/21150), [@tehGoti](https://github.com/tehGoti) +- **Alerting**: Fix image rendering and uploading timeout preventing to send alert notifications. [#21536](https://github.com/grafana/grafana/pull/21536), [@marefr](https://github.com/marefr) +- **Alerting**: Fix panic in dingding notifier . [#20378](https://github.com/grafana/grafana/pull/20378), [@csyangchen](https://github.com/csyangchen) +- **Alerting**: Fix template query validation logic. [#20721](https://github.com/grafana/grafana/pull/20721), [@okhowang](https://github.com/okhowang) +- **Alerting**: If no permission to clear history, keep the historical data. [#19007](https://github.com/grafana/grafana/pull/19007), [@lzdw](https://github.com/lzdw) +- **Alerting**: Unpausing a non-paused alert rule should not change status to Unknown. [#21375](https://github.com/grafana/grafana/pull/21375), [@vikkyomkar](https://github.com/vikkyomkar) +- **Api**: Fix returned message when enabling, disabling and deleting a non-existing user. [#21391](https://github.com/grafana/grafana/pull/21391), [@dpavlos](https://github.com/dpavlos) +- **Auth**: Rotate auth tokens at the end of requests. [#21347](https://github.com/grafana/grafana/pull/21347), [@woodsaj](https://github.com/woodsaj) +- **Azure Monitor**: Fixes error using azure monitor credentials with log analytics and non-default cloud. [#21032](https://github.com/grafana/grafana/pull/21032), [@shavonn](https://github.com/shavonn) +- **CLI**: Return error and aborts when plugin file extraction fails. [#20849](https://github.com/grafana/grafana/pull/20849), [@marefr](https://github.com/marefr) +- **CloudWatch**: Multi-valued template variable dimension alias fix. [#21541](https://github.com/grafana/grafana/pull/21541), [@sunker](https://github.com/sunker) +- **Dashboard**: Disable draggable panels on small devices. [#20629](https://github.com/grafana/grafana/pull/20629), [@peterholmberg](https://github.com/peterholmberg) +- **DataLinks**: Links with \${\_\_value.time} do not work when clicking on first result . [#20019](https://github.com/grafana/grafana/pull/20019), [@dweineha](https://github.com/dweineha) +- **Explore**: Fix showing of results in selected timezone (UTC/local). [#20812](https://github.com/grafana/grafana/pull/20812), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Fix timepicker when browsing back after switching datasource. [#21454](https://github.com/grafana/grafana/pull/21454), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Sync timepicker and logs after live-tailing stops. [#20979](https://github.com/grafana/grafana/pull/20979), [@ivanahuckova](https://github.com/ivanahuckova) +- **Graph**: Fix when clicking a plot on a touch device we won't display the annotation menu. [#21479](https://github.com/grafana/grafana/pull/21479), [@mckn](https://github.com/mckn) +- **OAuth**: Fix role mapping from id token. [#20300](https://github.com/grafana/grafana/pull/20300), [@seanson](https://github.com/seanson) +- **Plugins**: Add appSubUrl string to config pages. [#21414](https://github.com/grafana/grafana/pull/21414), [@Maddin-619](https://github.com/Maddin-619) +- **Provisioning**: Start provision dashboards after Grafana server have started. [#21564](https://github.com/grafana/grafana/pull/21564), [@marefr](https://github.com/marefr) +- **Render**: Use https as protocol when rendering if HTTP2 enabled. [#21600](https://github.com/grafana/grafana/pull/21600), [@marefr](https://github.com/marefr) +- **Security**: Use same cookie settings for all cookies. [#19787](https://github.com/grafana/grafana/pull/19787), [@jeffdesc](https://github.com/jeffdesc) +- **Singlestat**: Support empty value map texts. [#20952](https://github.com/grafana/grafana/pull/20952), [@hendrikvh](https://github.com/hendrikvh) +- **Units**: Custom suffix and prefix units can now be specified, for example custom currency & SI & time formats. [#20763](https://github.com/grafana/grafana/pull/20763), [@ryantxu](https://github.com/ryantxu) +- **grafana/ui**: Do not build grafana/ui in strict mode as it depends on non-strict libs. [#21319](https://github.com/grafana/grafana/pull/21319), [@dprokop](https://github.com/dprokop) + +# 6.5.3 (2020-01-15) + +### Features / Enhancements + +- **API**: Validate redirect_to cookie has valid (Grafana) url . [#21057](https://github.com/grafana/grafana/pull/21057), [@papagian](https://github.com/papagian), Thanks Habi S Ravi for reporting this issue. + +### Bug Fixes + +- **AdHocFilter**: Shows SubMenu when filtering directly from table. [#21017](https://github.com/grafana/grafana/pull/21017), [@hugohaggmark](https://github.com/hugohaggmark) +- **Cloudwatch**: Fixed crash when switching from cloudwatch data source. [#21376](https://github.com/grafana/grafana/pull/21376), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Sanitize data/panel link URLs. [#21140](https://github.com/grafana/grafana/pull/21140), [@dprokop](https://github.com/dprokop) +- **Elastic**: Fix multiselect variable interpolation for logs. [#20894](https://github.com/grafana/grafana/pull/20894), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus**: Fixes so user can change HTTP Method in config settings. [#21055](https://github.com/grafana/grafana/pull/21055), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Prevents validation of inputs when clicking in them without changing the value. [#21059](https://github.com/grafana/grafana/pull/21059), [@hugohaggmark](https://github.com/hugohaggmark) +- **Rendering**: Fix panel PNG rendering when using sub url & serve_from_sub_path = true. [#21306](https://github.com/grafana/grafana/pull/21306), [@bgranvea](https://github.com/bgranvea) +- **Table**: Matches column names with unescaped regex characters. [#21164](https://github.com/grafana/grafana/pull/21164), [@hugohaggmark](https://github.com/hugohaggmark) + +# 6.5.2 (2019-12-11) + +### Bug Fixes + +- **Alerting**: Improve alert threshold handle dragging behavior. [#20922](https://github.com/grafana/grafana/pull/20922), [@torkelo](https://github.com/torkelo) +- **AngularPanels**: Fixed loading spinner being stuck in some rare cases. [#20878](https://github.com/grafana/grafana/pull/20878), [@torkelo](https://github.com/torkelo) +- **CloudWatch**: Fix query editor does not render in Explore. [#20909](https://github.com/grafana/grafana/pull/20909), [@davkal](https://github.com/davkal) +- **CloudWatch**: Remove illegal character escaping in inferred expressions. [#20915](https://github.com/grafana/grafana/pull/20915), [@sunker](https://github.com/sunker) +- **CloudWatch**: Remove template variable error message. [#20864](https://github.com/grafana/grafana/pull/20864), [@sunker](https://github.com/sunker) +- **CloudWatch**: Use datasource template variable in curated dashboards. [#20917](https://github.com/grafana/grafana/pull/20917), [@sunker](https://github.com/sunker) +- **Elasticsearch**: Set default port to 9200 in ConfigEditor. [#20948](https://github.com/grafana/grafana/pull/20948), [@papagian](https://github.com/papagian) +- **Gauge/BarGauge**: Added support for value mapping of "no data"-state to text/value. [#20842](https://github.com/grafana/grafana/pull/20842), [@mckn](https://github.com/mckn) +- **Graph**: Prevent tooltip from being displayed outside of window. [#20874](https://github.com/grafana/grafana/pull/20874), [@mckn](https://github.com/mckn) +- **Graphite**: Fixes error with annotation metric queries . [#20857](https://github.com/grafana/grafana/pull/20857), [@dprokop](https://github.com/dprokop) +- **Login**: Fix fatal error when navigating from reset password page. [#20747](https://github.com/grafana/grafana/pull/20747), [@peterholmberg](https://github.com/peterholmberg) +- **MixedDatasources**: Do not filter out all mixed data sources in add mixed query dropdown. [#20990](https://github.com/grafana/grafana/pull/20990), [@torkelo](https://github.com/torkelo) +- **Prometheus**: Fix caching for default labels request. [#20718](https://github.com/grafana/grafana/pull/20718), [@aocenas](https://github.com/aocenas) +- **Prometheus**: Run default labels query only once. [#20898](https://github.com/grafana/grafana/pull/20898), [@aocenas](https://github.com/aocenas) +- **Security**: Fix invite link still accessible after completion or revocation. [#20863](https://github.com/grafana/grafana/pull/20863), [@aknuds1](https://github.com/aknuds1) +- **Server**: Fail when unable to create log directory. [#20804](https://github.com/grafana/grafana/pull/20804), [@aknuds1](https://github.com/aknuds1) +- **TeamPicker**: Increase size limit from 10 to 100. [#20882](https://github.com/grafana/grafana/pull/20882), [@hendrikvh](https://github.com/hendrikvh) +- **Units**: Remove SI prefix symbol from new milli/microSievert(/h) units. [#20650](https://github.com/grafana/grafana/pull/20650), [@zegelin](https://github.com/zegelin) + +# 6.5.1 (2019-11-28) + +### Bug Fixes + +- **CloudWatch**: Region template query fix. [#20661](https://github.com/grafana/grafana/pull/20661), [@sunker](https://github.com/sunker) +- **CloudWatch**: Fix annotations query editor loading. [#20687](https://github.com/grafana/grafana/pull/20687), [@sunker](https://github.com/sunker) +- **Panel**: Fixes undefined services/dependencies in plugins without `/**@ngInject*/`. [#20696](https://github.com/grafana/grafana/pull/20696), [@hugohaggmark](https://github.com/hugohaggmark) +- **Server**: Fix failure to start with "bind: address already in use" when using socket as protocol. [#20679](https://github.com/grafana/grafana/pull/20679), [@aknuds1](https://github.com/aknuds1) +- **Stats**: Fix active admins/editors/viewers stats are counted more than once if the user is part of more than one org. [#20711](https://github.com/grafana/grafana/pull/20711), [@papagian](https://github.com/papagian) + +# 6.5.0 (2019-11-25) + +### Features / Enhancements + +- **CloudWatch**: Add curated dashboards for most popular amazon services. [#20486](https://github.com/grafana/grafana/pull/20486), [@sunker](https://github.com/sunker) +- **CloudWatch**: Enable Min time interval. [#20260](https://github.com/grafana/grafana/pull/20260), [@mtanda](https://github.com/mtanda) +- **Explore**: UI improvements for log details. [#20485](https://github.com/grafana/grafana/pull/20485), [@ivanahuckova](https://github.com/ivanahuckova) +- **Server**: Improve grafana-server diagnostics configuration for profiling and tracing. [#20593](https://github.com/grafana/grafana/pull/20593), [@papagian](https://github.com/papagian) + +### Bug Fixes + +- **BarGauge/Gauge**: Add back missing title option field display options. [#20616](https://github.com/grafana/grafana/pull/20616), [@torkelo](https://github.com/torkelo) +- **CloudWatch**: Fix high CPU load. [#20579](https://github.com/grafana/grafana/pull/20579), [@marefr](https://github.com/marefr) +- **CloudWatch**: Fix high resolution mode without expression. [#20459](https://github.com/grafana/grafana/pull/20459), [@mtanda](https://github.com/mtanda) +- **CloudWatch**: Make sure period variable is being interpreted correctly. [#20447](https://github.com/grafana/grafana/pull/20447), [@sunker](https://github.com/sunker) +- **CloudWatch**: Remove HighResolution toggle since it's not being used. [#20440](https://github.com/grafana/grafana/pull/20440), [@sunker](https://github.com/sunker) +- **Cloudwatch**: Fix LaunchTime attribute tag bug. [#20237](https://github.com/grafana/grafana/pull/20237), [@sunker](https://github.com/sunker) +- **Data links**: Fix URL field turns read-only for graph panels. [#20381](https://github.com/grafana/grafana/pull/20381), [@dprokop](https://github.com/dprokop) +- **Explore**: Keep logQL filters when selecting labels in log row details. [#20570](https://github.com/grafana/grafana/pull/20570), [@ivanahuckova](https://github.com/ivanahuckova) +- **MySQL**: Fix TLS auth settings in config page. [#20501](https://github.com/grafana/grafana/pull/20501), [@peterholmberg](https://github.com/peterholmberg) +- **Provisioning**: Fix unmarshaling nested jsonData values. [#20399](https://github.com/grafana/grafana/pull/20399), [@aocenas](https://github.com/aocenas) +- **Server**: Should fail when server is unable to bind port. [#20409](https://github.com/grafana/grafana/pull/20409), [@aknuds1](https://github.com/aknuds1) +- **Templating**: Prevents crash when \$\_\_searchFilter is not a string. [#20526](https://github.com/grafana/grafana/pull/20526), [@hugohaggmark](https://github.com/hugohaggmark) +- **TextPanel**: Fixes issue with template variable value not properly html escaped [#20588](https://github.com/grafana/grafana/pull/20588), [@torkelo](https://github.com/torkelo) +- **TimePicker**: Should update after location change. [#20466](https://github.com/grafana/grafana/pull/20466), [@torkelo](https://github.com/torkelo) + +## Breaking changes + +- **CloudWatch**: Pre Grafana 6.5.0, the CloudWatch datasource used the GetMetricStatistics API for all queries that did not have an ´id´ and did not have an ´expression´ defined in the query editor. The GetMetricStatistics API has a limit of 400 transactions per second. In this release, all queries use the GetMetricData API. The GetMetricData API has a limit of 50 transactions per second and 100 metrics per transaction. For API pricing information, please refer to the CloudWatch pricing page (https://aws.amazon.com/cloudwatch/pricing/). + +- **CloudWatch**: The GetMetricData API does not return metric unit, so unit auto detection in panels is no longer supported. + +- **CloudWatch**: The `HighRes` switch has been removed from the query editor. Read more about this in [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5). + +- **CloudWatch**: In previous versions of Grafana, there was partial support for using multi-valued template variables as dimension values. When a multi-valued template variable is being used for dimension values in Grafana 6.5, a [search expression](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-search-expressions.html) will be generated. In the GetMetricData API, expressions are limited to 1024 characters, so you might reach this limit if you are using a large number of values. Read our [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5) guide to see how you can use the `*` wildcard for this use case. + +# 6.5.0-beta1 (2019-11-14) + +### Features / Enhancements + +- **API**: Add `createdAt` and `updatedAt` to api/users/lookup. [#19496](https://github.com/grafana/grafana/pull/19496), [@gotjosh](https://github.com/gotjosh) +- **API**: Add createdAt field to /api/users/:id. [#19475](https://github.com/grafana/grafana/pull/19475), [@cored](https://github.com/cored) +- **Admin**: Adds setting to disable creating initial admin user. [#19505](https://github.com/grafana/grafana/pull/19505), [@shavonn](https://github.com/shavonn) +- **Alerting**: Include alert_state in Kafka notifier payload. [#20099](https://github.com/grafana/grafana/pull/20099), [@arnaudlemaignen](https://github.com/arnaudlemaignen) +- **AuthProxy**: Can now login with auth proxy and get a login token. [#20175](https://github.com/grafana/grafana/pull/20175), [@torkelo](https://github.com/torkelo) +- **AuthProxy**: replaces setting ldap_sync_ttl with sync_ttl. [#20191](https://github.com/grafana/grafana/pull/20191), [@jongyllen](https://github.com/jongyllen) +- **AzureMonitor**: Alerting for Azure Application Insights. [#19381](https://github.com/grafana/grafana/pull/19381), [@ChadNedzlek](https://github.com/ChadNedzlek) +- **Build**: Upgrade to Go 1.13. [#19502](https://github.com/grafana/grafana/pull/19502), [@aknuds1](https://github.com/aknuds1) +- **CLI**: Reduce memory usage for plugin installation. [#19639](https://github.com/grafana/grafana/pull/19639), [@olivierlemasle](https://github.com/olivierlemasle) +- **CloudWatch**: Add ap-east-1 to hard-coded region lists. [#19523](https://github.com/grafana/grafana/pull/19523), [@Nessworthy](https://github.com/Nessworthy) +- **CloudWatch**: ContainerInsights metrics support. [#18971](https://github.com/grafana/grafana/pull/18971), [@francopeapea](https://github.com/francopeapea) +- **CloudWatch**: Support dynamic queries using dimension wildcards [#20058](https://github.com/grafana/grafana/issues/20058), [@sunker](https://github.com/sunker) +- **CloudWatch**: Stop using GetMetricStatistics and use GetMetricData for all time series requests [#20057](https://github.com/grafana/grafana/issues/20057), [@sunker](https://github.com/sunker) +- **CloudWatch**: Convert query editor from Angular to React [#19880](https://github.com/grafana/grafana/issues/19880), [@sunker](https://github.com/sunker) +- **CloudWatch**: Convert config editor from Angular to React [#19881](https://github.com/grafana/grafana/issues/19881), [@shavonn](https://github.com/shavonn) +- **CloudWatch**: Improved error handling when throttling occurs [#20348](https://github.com/grafana/grafana/issues/20348), [@sunker](https://github.com/sunker) +- **CloudWatch**: Deep linking from Grafana panel to CloudWatch console [#20279](https://github.com/grafana/grafana/issues/20279), [@sunker](https://github.com/sunker) +- **CloudWatch**: Add Grafana user agent to GMD calls [#20277](https://github.com/grafana/grafana/issues/20277), [@sunker](https://github.com/sunker) +- **Dashboard**: Allows the d-solo route to be used without slug. [#19640](https://github.com/grafana/grafana/pull/19640), [@97amarnathk](https://github.com/97amarnathk) +- **Docker**: Build and publish an additional Ubuntu based docker image. [#20196](https://github.com/grafana/grafana/pull/20196), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Adds support for region annotations. [#17602](https://github.com/grafana/grafana/pull/17602), [@fangel](https://github.com/fangel) +- **Explore**: Add custom DataLinks on datasource level (like tracing links). [#20060](https://github.com/grafana/grafana/pull/20060), [@aocenas](https://github.com/aocenas) +- **Explore**: Add functionality to show/hide query row results. [#19794](https://github.com/grafana/grafana/pull/19794), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Synchronise time ranges in split mode. [#19274](https://github.com/grafana/grafana/pull/19274), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: UI change for log row details . [#20034](https://github.com/grafana/grafana/pull/20034), [@ivanahuckova](https://github.com/ivanahuckova) +- **Frontend**: Migrate DataSource HTTP Settings to React. [#19452](https://github.com/grafana/grafana/pull/19452), [@dprokop](https://github.com/dprokop) +- **Frontend**: Show browser not supported notification. [#19904](https://github.com/grafana/grafana/pull/19904), [@peterholmberg](https://github.com/peterholmberg) +- **Graph**: Added series override option to have hidden series be persisted on save. [#20124](https://github.com/grafana/grafana/pull/20124), [@Gauravshah](https://github.com/Gauravshah) +- **Graphite**: Add Metrictank option to settings to view Metrictank request processing info in new inspect feature. [#20138](https://github.com/grafana/grafana/pull/20138), [@ryantxu](https://github.com/ryantxu) +- **LDAP**: Enable single user sync. [#19446](https://github.com/grafana/grafana/pull/19446), [@gotjosh](https://github.com/gotjosh) +- **LDAP**: Last org admin can login but wont be removed. [#20326](https://github.com/grafana/grafana/pull/20326), [@xlson](https://github.com/xlson) +- **LDAP**: Support env variable expressions in ldap.toml file. [#20173](https://github.com/grafana/grafana/pull/20173), [@torkelo](https://github.com/torkelo) +- **OAuth**: Generic OAuth role mapping support. [#17149](https://github.com/grafana/grafana/pull/17149), [@hypery2k](https://github.com/hypery2k) +- **Prometheus**: Custom query parameters string for Thanos downsampling. [#19121](https://github.com/grafana/grafana/pull/19121), [@seuf](https://github.com/seuf) +- **Provisioning**: Allow saving of provisioned dashboards. [#19820](https://github.com/grafana/grafana/pull/19820), [@jongyllen](https://github.com/jongyllen) +- **Security**: Minor XSS issue resolved by angularjs upgrade from 1.6.6 -> 1.6.9. [#19849](https://github.com/grafana/grafana/pull/19849), [@peterholmberg](https://github.com/peterholmberg) +- **TablePanel**: Prevents crash when data contains mixed data formats. [#20202](https://github.com/grafana/grafana/pull/20202), [@hugohaggmark](https://github.com/hugohaggmark) +- **Templating**: Introduces \$\_\_searchFilter to Query Variables. [#19858](https://github.com/grafana/grafana/pull/19858), [@hugohaggmark](https://github.com/hugohaggmark) +- **Templating**: Made default template variable query editor field a textarea with automatic height. [#20288](https://github.com/grafana/grafana/pull/20288), [@torkelo](https://github.com/torkelo) +- **Units**: Add milli/microSievert, milli/microSievert/h and pixels. [#20144](https://github.com/grafana/grafana/pull/20144), [@ryantxu](https://github.com/ryantxu) +- **Units**: Added mega ampere and watt-hour per kg. [#19922](https://github.com/grafana/grafana/pull/19922), [@Karan96Kaushik](https://github.com/Karan96Kaushik) +- **Enterprise**: Enterprise without a license behaves like OSS (Enterprise) + +### Bug Fixes + +- **API**: Added dashboardId and slug in response to dashboard import api. [#19692](https://github.com/grafana/grafana/pull/19692), [@jongyllen](https://github.com/jongyllen) +- **API**: Fix logging of dynamic listening port. [#19644](https://github.com/grafana/grafana/pull/19644), [@oleggator](https://github.com/oleggator) +- **BarGauge**: Fix so that default thresholds not keeps resetting. [#20190](https://github.com/grafana/grafana/pull/20190), [@lzdw](https://github.com/lzdw) +- **CloudWatch**: Fix incorrect casing of Redshift dimension entry for service class and stage. [#19897](https://github.com/grafana/grafana/pull/19897), [@nlsdfnbch](https://github.com/nlsdfnbch) +- **CloudWatch**: Fixing AWS Kafka dimension names. [#19986](https://github.com/grafana/grafana/pull/19986), [@skuxy](https://github.com/skuxy) +- **CloudWatch**: Metric math broken when using multi template variables [#18337](https://github.com/grafana/grafana/issues/18337), [@sunker](https://github.com/sunker) +- **CloudWatch**: Graphs with multiple multi-value dimension variables don't work [#17949](https://github.com/grafana/grafana/issues/17949), [@sunker](https://github.com/sunker) +- **CloudWatch**: Variables' values surrounded with braces in request sent to AWS [#14451](https://github.com/grafana/grafana/issues/14451), [@sunker](https://github.com/sunker) +- **CloudWatch**: Cloudwatch Query for a list of instances for which data is available in the selected time interval [#12784](https://github.com/grafana/grafana/issues/12784), [@sunker](https://github.com/sunker) +- **CloudWatch**: Dimension's positioning/order should be stored in the json dashboard [#11062](https://github.com/grafana/grafana/issues/11062), [@sunker](https://github.com/sunker) +- **CloudWatch**: Batch CloudWatch API call support in backend [#7991](https://github.com/grafana/grafana/issues/7991), [@sunker](https://github.com/sunker) +- **ColorPicker**: Fixes issue with ColorPicker disappearing too quickly . [#20289](https://github.com/grafana/grafana/pull/20289), [@dprokop](https://github.com/dprokop) +- **Datasource**: Add custom headers on alerting queries. [#19508](https://github.com/grafana/grafana/pull/19508), [@weeco](https://github.com/weeco) +- **Docker**: Add additional glibc dependencies to support certain backend plugins in alpine. [#20214](https://github.com/grafana/grafana/pull/20214), [@briangann](https://github.com/briangann) +- **Docker**: Build and use musl-based binaries in alpine images to resolve glibc incompatibility issues. [#19798](https://github.com/grafana/grafana/pull/19798), [@aknuds1](https://github.com/aknuds1) +- **Elasticsearch**: Fix template variables interpolation when redirecting to Explore. [#20314](https://github.com/grafana/grafana/pull/20314), [@ivanahuckova](https://github.com/ivanahuckova) +- **Elasticsearch**: Support rendering in logs panel. [#20229](https://github.com/grafana/grafana/pull/20229), [@davkal](https://github.com/davkal) +- **Explore**: Expand template variables when redirecting from dashboard panel. [#19582](https://github.com/grafana/grafana/pull/19582), [@ivanahuckova](https://github.com/ivanahuckova) +- **OAuth**: Make the login button display name of custom OAuth provider. [#20209](https://github.com/grafana/grafana/pull/20209), [@dprokop](https://github.com/dprokop) +- **ReactPanels**: Adds Explore menu item. [#20236](https://github.com/grafana/grafana/pull/20236), [@hugohaggmark](https://github.com/hugohaggmark) +- **Team Sync**: Fix URL encode Group IDs for external team sync. [#20280](https://github.com/grafana/grafana/pull/20280), [@gotjosh](https://github.com/gotjosh) + +## Breaking changes + +- **CloudWatch**: Pre Grafana 6.5.0, the CloudWatch datasource used the GetMetricStatistics API for all queries that did not have an ´id´ and did not have an ´expression´ defined in the query editor. The GetMetricStatistics API has a limit of 400 transactions per second. In this release, all queries use the GetMetricData API. The GetMetricData API has a limit of 50 transactions per second and 100 metrics per transaction. For API pricing information, please refer to the CloudWatch pricing page (https://aws.amazon.com/cloudwatch/pricing/). + +- **CloudWatch**: The GetMetricData API does not return metric unit, so unit auto detection in panels is no longer supported. + +- **CloudWatch**: The `HighRes` switch has been removed from the query editor. Read more about this in [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5). + +- **CloudWatch**: In previous versions of Grafana, there was partial support for using multi-valued template variables as dimension values. When a multi-valued template variable is being used for dimension values in Grafana 6.5, a [search expression](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-search-expressions.html) will be generated. In the GetMetricData API, expressions are limited to 1024 characters, so you might reach this limit if you are using a large number of values. Read our [upgrading to 6.5](https://grafana.com/docs/installation/upgrading/#upgrading-to-v6-5) guide to see how you can use the `*` wildcard for this use case. + +# 6.4.5 (2019-11-25) + +### Bug Fixes + +- **CloudWatch**: Fix high CPU load [#20579](https://github.com/grafana/grafana/pull/20579) + +# 6.4.4 (2019-11-06) + +### Bug Fixes + +- **MySQL**: Fix encoding in connection string [#20192](https://github.com/grafana/grafana/pull/20192) +- **DataLinks**: Fix blur issues. [#19883](https://github.com/grafana/grafana/pull/19883), [@aocenas](https://github.com/aocenas) +- **Docker**: Makes it possible to parse timezones in the docker image. [#20081](https://github.com/grafana/grafana/pull/20081), [@xlson](https://github.com/xlson) +- **LDAP**: All LDAP servers should be tried even if one of them returns a connection error. [#20077](https://github.com/grafana/grafana/pull/20077), [@jongyllen](https://github.com/jongyllen) +- **LDAP**: No longer shows incorrectly matching groups based on role in debug page. [#20018](https://github.com/grafana/grafana/pull/20018), [@xlson](https://github.com/xlson) +- **Singlestat**: Fix no data / null value mapping . [#19951](https://github.com/grafana/grafana/pull/19951), [@ryantxu](https://github.com/ryantxu) + +#### Security vulnerability + +The MySQL data source connection string fix, [#20192](https://github.com/grafana/grafana/pull/20192), that was part of this release +also fixed a security vulnerability. Thanks Yuriy Dyachenko for discovering and notifying us about this. + +# 6.4.3 (2019-10-16) + +### Bug Fixes + +- **Alerting**: All notification channels should send even if one fails to send. [#19807](https://github.com/grafana/grafana/pull/19807), [@jan25](https://github.com/jan25) +- **AzureMonitor**: Fix slate interference with dropdowns. [#19799](https://github.com/grafana/grafana/pull/19799), [@aocenas](https://github.com/aocenas) +- **ContextMenu**: make ContextMenu positioning aware of the viewport width. [#19699](https://github.com/grafana/grafana/pull/19699), [@krvajal](https://github.com/krvajal) +- **DataLinks**: Fix context menu not showing in singlestat-ish visualisations. [#19809](https://github.com/grafana/grafana/pull/19809), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Fix url field not releasing focus. [#19804](https://github.com/grafana/grafana/pull/19804), [@aocenas](https://github.com/aocenas) +- **Datasource**: Fixes clicking outside of some query editors required 2 clicks. [#19822](https://github.com/grafana/grafana/pull/19822), [@aocenas](https://github.com/aocenas) +- **Panels**: Fixes default tab for visualizations without Queries Tab. [#19803](https://github.com/grafana/grafana/pull/19803), [@hugohaggmark](https://github.com/hugohaggmark) +- **Singlestat**: Fixed issue with mapping null to text. [#19689](https://github.com/grafana/grafana/pull/19689), [@torkelo](https://github.com/torkelo) +- **@grafana/toolkit**: Don't fail plugin creation when git user.name config is not set. [#19821](https://github.com/grafana/grafana/pull/19821), [@dprokop](https://github.com/dprokop) +- **@grafana/toolkit**: TSLint line number off by 1. [#19782](https://github.com/grafana/grafana/pull/19782), [@fredwangwang](https://github.com/fredwangwang) + +# 6.4.2 (2019-10-08) + +### Bug Fixes + +- **CloudWatch**: Changes incorrect dimension wmlid to wlmid . [#19679](https://github.com/grafana/grafana/pull/19679), [@ATTron](https://github.com/ATTron) +- **Grafana Image Renderer**: Fixes plugin page. [#19664](https://github.com/grafana/grafana/pull/19664), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graph**: Fixes auto decimals logic for y axis ticks that results in too many decimals for high values. [#19618](https://github.com/grafana/grafana/pull/19618), [@torkelo](https://github.com/torkelo) +- **Graph**: Switching to series mode should re-render graph. [#19623](https://github.com/grafana/grafana/pull/19623), [@torkelo](https://github.com/torkelo) +- **Loki**: Fix autocomplete on label values. [#19579](https://github.com/grafana/grafana/pull/19579), [@aocenas](https://github.com/aocenas) +- **Loki**: Removes live option for logs panel. [#19533](https://github.com/grafana/grafana/pull/19533), [@davkal](https://github.com/davkal) +- **Profile**: Fix issue with user profile not showing more than sessions sessions in some cases. [#19578](https://github.com/grafana/grafana/pull/19578), [@huynhsamha](https://github.com/huynhsamha) +- **Prometheus**: Fixes so results in Panel always are sorted by query order. [#19597](https://github.com/grafana/grafana/pull/19597), [@hugohaggmark](https://github.com/hugohaggmark) +- **ShareQuery**: Fixed issue when using -- Dashboard -- datasource (to share query result) when dashboard had rows. [#19610](https://github.com/grafana/grafana/pull/19610), [@torkelo](https://github.com/torkelo) +- **Show SAML login button if SAML is enabled**. [#19591](https://github.com/grafana/grafana/pull/19591), [@papagian](https://github.com/papagian) +- **SingleStat**: Fixes \$\_\_name postfix/prefix usage. [#19687](https://github.com/grafana/grafana/pull/19687), [@hugohaggmark](https://github.com/hugohaggmark) +- **Table**: Proper handling of json data with dataframes. [#19596](https://github.com/grafana/grafana/pull/19596), [@marefr](https://github.com/marefr) +- **Units**: Fixed wrong id for Terabits/sec. [#19611](https://github.com/grafana/grafana/pull/19611), [@andreaslangnevyjel](https://github.com/andreaslangnevyjel) + +# 6.4.1 (2019-10-02) + +### Bug Fixes + +- **Provisioning**: Fixed issue where empty nested keys in YAML provisioning caused server crash, [#19547](https://github.com/grafana/grafana/pull/19547) +- **ImageRendering**: Fixed issue with image rendering in enterprise build (Enterprise) +- **Reporting**: Fixed issue with reporting service when STMP disabled (Enterprise). + +# 6.4.0 (2019-10-01) + +### Features / Enhancements + +- **Build**: Upgrade go to 1.12.10. [#19499](https://github.com/grafana/grafana/pull/19499), [@marefr](https://github.com/marefr) +- **DataLinks**: Suggestions menu improvements. [#19396](https://github.com/grafana/grafana/pull/19396), [@dprokop](https://github.com/dprokop) +- **Explore**: Take root_url setting into account when redirecting from dashboard to explore. [#19447](https://github.com/grafana/grafana/pull/19447), [@ivanahuckova](https://github.com/ivanahuckova) +- **Explore**: Update broken link to logql docs. [#19510](https://github.com/grafana/grafana/pull/19510), [@ivanahuckova](https://github.com/ivanahuckova) +- **Logs**: Adds Logs Panel as a visualization. [#19504](https://github.com/grafana/grafana/pull/19504), [@davkal](https://github.com/davkal) +- **Reporting**: Generate and email PDF reports based on Dashboards (Enterprise) + +### Bug Fixes + +- **CLI**: Fix version selection for plugin install. [#19498](https://github.com/grafana/grafana/pull/19498), [@aocenas](https://github.com/aocenas) +- **Graph**: Fixes minor issue with series override color picker and custom color . [#19516](https://github.com/grafana/grafana/pull/19516), [@torkelo](https://github.com/torkelo) + +## Plugins that need updating when upgrading from 6.3 to 6.4 + +- [Splunk](https://grafana.com/grafana/plugins/grafana-splunk-datasource) + +# 6.4.0-beta2 (2019-09-25) + +### Features / Enhancements + +- **Azure Monitor**: Remove support for cross resource queries (#19115)". [#19346](https://github.com/grafana/grafana/pull/19346), [@sunker](https://github.com/sunker) +- **Docker**: Upgrade packages to resolve reported vulnerabilities. [#19188](https://github.com/grafana/grafana/pull/19188), [@marefr](https://github.com/marefr) +- **Graphite**: Time range expansion reduced from 1 minute to 1 second. [#19246](https://github.com/grafana/grafana/pull/19246), [@torkelo](https://github.com/torkelo) +- **grafana/toolkit**: Add plugin creation task. [#19207](https://github.com/grafana/grafana/pull/19207), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **Alerting**: Prevents creating alerts from unsupported queries. [#19250](https://github.com/grafana/grafana/pull/19250), [@hugohaggmark](https://github.com/hugohaggmark) +- **Alerting**: Truncate PagerDuty summary when greater than 1024 characters. [#18730](https://github.com/grafana/grafana/pull/18730), [@nvllsvm](https://github.com/nvllsvm) +- **Cloudwatch**: Fix autocomplete for Gamelift dimensions. [#19146](https://github.com/grafana/grafana/pull/19146), [@kevinpz](https://github.com/kevinpz) +- **Dashboard**: Fix export for sharing when panels use default data source. [#19315](https://github.com/grafana/grafana/pull/19315), [@torkelo](https://github.com/torkelo) +- **Database**: Rewrite system statistics query to perform better. [#19178](https://github.com/grafana/grafana/pull/19178), [@papagian](https://github.com/papagian) +- **Gauge/BarGauge**: Fix issue with [object Object] in titles . [#19217](https://github.com/grafana/grafana/pull/19217), [@ryantxu](https://github.com/ryantxu) +- **MSSQL**: Revert usage of new connectionstring format introduced by #18384. [#19203](https://github.com/grafana/grafana/pull/19203), [@marefr](https://github.com/marefr) +- **Multi-LDAP**: Do not fail-fast on invalid credentials. [#19261](https://github.com/grafana/grafana/pull/19261), [@gotjosh](https://github.com/gotjosh) +- **MySQL, Postgres, MSSQL**: Fix validating query with template variables in alert . [#19237](https://github.com/grafana/grafana/pull/19237), [@marefr](https://github.com/marefr) +- **MySQL, Postgres**: Update raw sql when query builder updates. [#19209](https://github.com/grafana/grafana/pull/19209), [@marefr](https://github.com/marefr) +- **MySQL**: Limit datasource error details returned from the backend. [#19373](https://github.com/grafana/grafana/pull/19373), [@marefr](https://github.com/marefr) + +# 6.4.0-beta1 (2019-09-17) + +### Features / Enhancements + +- **Reporting**: Created scheduled PDF reports for any dashboard (Enterprise). +- **API**: Readonly datasources should not be created via the API. [#19006](https://github.com/grafana/grafana/pull/19006), [@papagian](https://github.com/papagian) +- **Alerting**: Include configured AlertRuleTags in Webhooks notifier. [#18233](https://github.com/grafana/grafana/pull/18233), [@dominic-miglar](https://github.com/dominic-miglar) +- **Annotations**: Add annotations support to Loki. [#18949](https://github.com/grafana/grafana/pull/18949), [@aocenas](https://github.com/aocenas) +- **Annotations**: Use a single row to represent a region. [#17673](https://github.com/grafana/grafana/pull/17673), [@ryantxu](https://github.com/ryantxu) +- **Auth**: Allow inviting existing users when login form is disabled. [#19048](https://github.com/grafana/grafana/pull/19048), [@548017](https://github.com/548017) +- **Azure Monitor**: Add support for cross resource queries. [#19115](https://github.com/grafana/grafana/pull/19115), [@sunker](https://github.com/sunker) +- **CLI**: Allow installing custom binary plugins. [#17551](https://github.com/grafana/grafana/pull/17551), [@aocenas](https://github.com/aocenas) +- **Dashboard**: Adds Logs Panel (alpha) as visualization option for Dashboards. [#18641](https://github.com/grafana/grafana/pull/18641), [@hugohaggmark](https://github.com/hugohaggmark) +- **Dashboard**: Reuse query results between panels . [#16660](https://github.com/grafana/grafana/pull/16660), [@ryantxu](https://github.com/ryantxu) +- **Dashboard**: Set time to to 23:59:59 when setting To time using calendar. [#18595](https://github.com/grafana/grafana/pull/18595), [@simPod](https://github.com/simPod) +- **DataLinks**: Add DataLinks support to Gauge, BarGauge and stat panel. [#18605](https://github.com/grafana/grafana/pull/18605), [@ryantxu](https://github.com/ryantxu) +- **DataLinks**: Enable access to labels & field names. [#18918](https://github.com/grafana/grafana/pull/18918), [@torkelo](https://github.com/torkelo) +- **DataLinks**: Enable multiple data links per panel. [#18434](https://github.com/grafana/grafana/pull/18434), [@dprokop](https://github.com/dprokop) +- **Docker**: switch docker image to alpine base with phantomjs support. [#18468](https://github.com/grafana/grafana/pull/18468), [@DanCech](https://github.com/DanCech) +- **Elasticsearch**: allow templating queries to order by doc_count. [#18870](https://github.com/grafana/grafana/pull/18870), [@hackery](https://github.com/hackery) +- **Explore**: Add throttling when doing live queries. [#19085](https://github.com/grafana/grafana/pull/19085), [@aocenas](https://github.com/aocenas) +- **Explore**: Adds ability to go back to dashboard, optionally with query changes. [#17982](https://github.com/grafana/grafana/pull/17982), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Reduce default time range to last hour. [#18212](https://github.com/grafana/grafana/pull/18212), [@davkal](https://github.com/davkal) +- **Gauge/BarGauge**: Support decimals for min/max. [#18368](https://github.com/grafana/grafana/pull/18368), [@ryantxu](https://github.com/ryantxu) +- **Graph**: New series override transform constant that renders a single point as a line across the whole graph. [#19102](https://github.com/grafana/grafana/pull/19102), [@davkal](https://github.com/davkal) +- **Image rendering**: Add deprecation warning when PhantomJS is used for rendering images. [#18933](https://github.com/grafana/grafana/pull/18933), [@papagian](https://github.com/papagian) +- **InfluxDB**: Enable interpolation within ad-hoc filter values. [#18077](https://github.com/grafana/grafana/pull/18077), [@kvc-code](https://github.com/kvc-code) +- **LDAP**: Allow an user to be synchronized against LDAP. [#18976](https://github.com/grafana/grafana/pull/18976), [@gotjosh](https://github.com/gotjosh) +- **Ldap**: Add ldap debug page. [#18759](https://github.com/grafana/grafana/pull/18759), [@peterholmberg](https://github.com/peterholmberg) +- **Loki**: Remove prefetching of default label values. [#18213](https://github.com/grafana/grafana/pull/18213), [@davkal](https://github.com/davkal) +- **Metrics**: Add failed alert notifications metric. [#18089](https://github.com/grafana/grafana/pull/18089), [@koorgoo](https://github.com/koorgoo) +- **OAuth**: Support JMES path lookup when retrieving user email. [#14683](https://github.com/grafana/grafana/pull/14683), [@bobmshannon](https://github.com/bobmshannon) +- **OAuth**: return GitLab groups as a part of user info (enable team sync). [#18388](https://github.com/grafana/grafana/pull/18388), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Panels**: Add unit for electrical charge - ampere-hour. [#18950](https://github.com/grafana/grafana/pull/18950), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +- **Plugin**: AzureMonitor - Reapply MetricNamespace support. [#17282](https://github.com/grafana/grafana/pull/17282), [@raphaelquati](https://github.com/raphaelquati) +- **Plugins**: better warning when plugins fail to load. [#18671](https://github.com/grafana/grafana/pull/18671), [@ryantxu](https://github.com/ryantxu) +- **Postgres**: Add support for scram sha 256 authentication. [#18397](https://github.com/grafana/grafana/pull/18397), [@nonamef](https://github.com/nonamef) +- **RemoteCache**: Support SSL with Redis. [#18511](https://github.com/grafana/grafana/pull/18511), [@kylebrandt](https://github.com/kylebrandt) +- **SingleStat**: The gauge option in now disabled/hidden (unless it's an old panel with it already enabled) . [#18610](https://github.com/grafana/grafana/pull/18610), [@ryantxu](https://github.com/ryantxu) +- **Stackdriver**: Add extra alignment period options. [#18909](https://github.com/grafana/grafana/pull/18909), [@sunker](https://github.com/sunker) +- **Units**: Add South African Rand (ZAR) to currencies. [#18893](https://github.com/grafana/grafana/pull/18893), [@jeteon](https://github.com/jeteon) +- **Units**: Adding T,P,E,Z,and Y bytes. [#18706](https://github.com/grafana/grafana/pull/18706), [@chiqomar](https://github.com/chiqomar) + +### Bug Fixes + +- **Alerting**: Notification is sent when state changes from no_data to ok. [#18920](https://github.com/grafana/grafana/pull/18920), [@papagian](https://github.com/papagian) +- **Alerting**: fix duplicate alert states when the alert fails to save to the database. [#18216](https://github.com/grafana/grafana/pull/18216), [@kylebrandt](https://github.com/kylebrandt) +- **Alerting**: fix response popover prompt when add notification channels. [#18967](https://github.com/grafana/grafana/pull/18967), [@lzdw](https://github.com/lzdw) +- **CloudWatch**: Fix alerting for queries with Id (using GetMetricData). [#17899](https://github.com/grafana/grafana/pull/17899), [@alex-berger](https://github.com/alex-berger) +- **Explore**: Fix auto completion on label values for Loki. [#18988](https://github.com/grafana/grafana/pull/18988), [@aocenas](https://github.com/aocenas) +- **Explore**: Fixes crash using back button with a zoomed in graph. [#19122](https://github.com/grafana/grafana/pull/19122), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fixes so queries in Explore are only run if Graph/Table is shown. [#19000](https://github.com/grafana/grafana/pull/19000), [@hugohaggmark](https://github.com/hugohaggmark) +- **MSSQL**: Change connectionstring to URL format to fix using passwords with semicolon. [#18384](https://github.com/grafana/grafana/pull/18384), [@Russiancold](https://github.com/Russiancold) +- **MSSQL**: Fix memory leak when debug enabled. [#19049](https://github.com/grafana/grafana/pull/19049), [@briangann](https://github.com/briangann) +- **Provisioning**: Allow escaping literal '$' with '$\$' in configs to avoid interpolation. [#18045](https://github.com/grafana/grafana/pull/18045), [@kylebrandt](https://github.com/kylebrandt) +- **TimePicker**: Fixes hiding time picker dropdown in FireFox. [#19154](https://github.com/grafana/grafana/pull/19154), [@hugohaggmark](https://github.com/hugohaggmark) + +## Breaking changes + +### Annotations + +There are some breaking changes in the annotations HTTP API for region annotations. Region annotations are now represented +using a single event instead of two separate events. Check breaking changes in HTTP API [below](#http-api) and [HTTP API documentation](https://grafana.com/docs/http_api/annotations/) for more details. + +### Docker + +Grafana is now using Alpine 3.10 as docker base image. + +### HTTP API + +- `GET /api/alert-notifications` now requires at least editor access. New `/api/alert-notifications/lookup` returns less information than `/api/alert-notifications` and can be access by any authenticated user. +- `GET /api/alert-notifiers` now requires at least editor access +- `GET /api/org/users` now requires org admin role. New `/api/org/users/lookup` returns less information than `/api/org/users` and can be access by users that are org admins, admin in any folder or admin of any team. +- `GET /api/annotations` no longer returns `regionId` property. +- `POST /api/annotations` no longer supports `isRegion` property. +- `PUT /api/annotations/:id` no longer supports `isRegion` property. +- `PATCH /api/annotations/:id` no longer supports `isRegion` property. +- `DELETE /api/annotations/region/:id` has been removed. + +## Deprecation notes + +### PhantomJS + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, is deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +# 6.3.7 (2019-11-22) + +### Bug Fixes + +- **CloudWatch**: Fix high CPU load [#20579](https://github.com/grafana/grafana/pull/20579) + +# 6.3.6 (2019-09-23) + +### Features / Enhancements + +- **Metrics**: Adds setting for turning off total stats metrics. [#19142](https://github.com/grafana/grafana/pull/19142), [@marefr](https://github.com/marefr) + +### Bug Fixes + +- **Database**: Rewrite system statistics query to perform better. [#19178](https://github.com/grafana/grafana/pull/19178), [@papagian](https://github.com/papagian) +- **Explore**: Fixes error when switching from prometheus to loki data sources. [#18599](https://github.com/grafana/grafana/pull/18599), [@kaydelaney](https://github.com/kaydelaney) + +# 6.3.5 (2019-09-02) + +### Upgrades + +- **Build**: Upgrade to go 1.12.9. [#18638](https://github.com/grafana/grafana/pull/18638), [@marcusolsson](https://github.com/marcusolsson) + +### Bug Fixes + +- **Dashboard**: Fixes dashboards init failed loading error for dashboards with panel links that had missing properties. [#18786](https://github.com/grafana/grafana/pull/18786), [@torkelo](https://github.com/torkelo) +- **Editor**: Fixes issue where only entire lines were being copied. [#18806](https://github.com/grafana/grafana/pull/18806), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Fixes query field layout in splitted view for Safari browsers. [#18654](https://github.com/grafana/grafana/pull/18654), [@hugohaggmark](https://github.com/hugohaggmark) +- **LDAP**: multildap + ldap integration. [#18588](https://github.com/grafana/grafana/pull/18588), [@markelog](https://github.com/markelog) +- **Profile/UserAdmin**: Fix for user agent parser crashes grafana-server on 32-bit builds. [#18788](https://github.com/grafana/grafana/pull/18788), [@marcusolsson](https://github.com/marcusolsson) +- **Prometheus**: Prevents panel editor crash when switching to Prometheus data source. [#18616](https://github.com/grafana/grafana/pull/18616), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: Changes brace-insertion behavior to be less annoying. [#18698](https://github.com/grafana/grafana/pull/18698), [@kaydelaney](https://github.com/kaydelaney) + +# 6.3.4 (2019-08-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/08/29/grafana-5.4.5-and-6.3.4-released-with-important-security-fix/) + +# 6.3.3 (2019-08-15) + +### Bug Fixes + +- **Annotations**: Fix failing annotation query when time series query is cancelled. [#18532](https://github.com/grafana/grafana/pull/18532), [@dprokop](https://github.com/dprokop) +- **Auth**: Do not set SameSite cookie attribute if cookie_samesite is none. [#18462](https://github.com/grafana/grafana/pull/18462), [@papagian](https://github.com/papagian) +- **DataLinks**: Apply scoped variables to data links correctly. [#18454](https://github.com/grafana/grafana/pull/18454), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Respect timezone when displaying datapoint's timestamp in graph context menu. [#18461](https://github.com/grafana/grafana/pull/18461), [@dprokop](https://github.com/dprokop) +- **DataLinks**: Use datapoint timestamp correctly when interpolating variables. [#18459](https://github.com/grafana/grafana/pull/18459), [@dprokop](https://github.com/dprokop) +- **Explore**: Fix loading error for empty queries. [#18488](https://github.com/grafana/grafana/pull/18488), [@davkal](https://github.com/davkal) +- **Graph**: Fixes legend issue clicking on series line icon and issue with horizontal scrollbar being visible on windows. [#18563](https://github.com/grafana/grafana/pull/18563), [@torkelo](https://github.com/torkelo) +- **Graphite**: Avoid glob of single-value array variables . [#18420](https://github.com/grafana/grafana/pull/18420), [@gotjosh](https://github.com/gotjosh) +- **Prometheus**: Fix queries with label_replace remove the \$1 match when loading query editor. [#18480](https://github.com/grafana/grafana/pull/18480), [@hugohaggmark](https://github.com/hugohaggmark) +- **Prometheus**: More consistently allows for multi-line queries in editor. [#18362](https://github.com/grafana/grafana/pull/18362), [@kaydelaney](https://github.com/kaydelaney) +- **TimeSeries**: Assume values are all numbers. [#18540](https://github.com/grafana/grafana/pull/18540), [@ryantxu](https://github.com/ryantxu) + +# 6.3.2 (2019-08-07) + +### Bug Fixes + +- **Gauge/BarGauge**: Fixes issue with lost thresholds and an issue loading Gauge with avg stat. [#18375](https://github.com/grafana/grafana/pull/18375) + +# 6.3.1 (2019-08-07) + +### Bug Fixes + +- **PanelLinks**: Fixes crash issue with Gauge & Bar Gauge panels with panel links (drill down links). [#18430](https://github.com/grafana/grafana/pull/18430) + +# 6.3.0 (2019-08-06) + +### Features / Enhancements + +- **OAuth**: Do not set SameSite OAuth cookie if cookie_samesite is None. [#18392](https://github.com/grafana/grafana/pull/18392), [@papagian](https://github.com/papagian) + +### Bug Fixes + +- **PanelLinks**: Fix render issue when there is no panel description. [#18408](https://github.com/grafana/grafana/pull/18408), [@dehrax](https://github.com/dehrax) + +# 6.3.0-beta4 (2019-08-02) + +### Features / Enhancements + +- **Auth Proxy**: Include additional headers as part of the cache key. [#18298](https://github.com/grafana/grafana/pull/18298), [@gotjosh](https://github.com/gotjosh) + +# 6.3.0-beta3 (2019-08-02) + +### Bug Fixes + +- **OAuth**: Fix "missing saved state" OAuth login failure due to SameSite cookie policy. [#18332](https://github.com/grafana/grafana/pull/18332), [@papagian](https://github.com/papagian) +- **cli**: fix for recognizing when in dev mode.. [#18334](https://github.com/grafana/grafana/pull/18334), [@xlson](https://github.com/xlson) + +# 6.3.0-beta2 (2019-07-26) + +### Features / Enhancements + +- **Build grafana images consistently**. [#18224](https://github.com/grafana/grafana/pull/18224), [@hassanfarid](https://github.com/hassanfarid) +- **Docs**: SAML. [#18069](https://github.com/grafana/grafana/pull/18069), [@gotjosh](https://github.com/gotjosh) +- **Permissions**: Show plugins in nav for non admin users but hide plugin configuration. [#18234](https://github.com/grafana/grafana/pull/18234), [@aocenas](https://github.com/aocenas) +- **TimePicker**: Increase max height of quick range dropdown. [#18247](https://github.com/grafana/grafana/pull/18247), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **DataLinks**: Fixes incorrect interpolation of \${\_\_series_name} . [#18251](https://github.com/grafana/grafana/pull/18251), [@torkelo](https://github.com/torkelo) +- **Loki**: Display live tailed logs in correct order in Explore. [#18031](https://github.com/grafana/grafana/pull/18031), [@kaydelaney](https://github.com/kaydelaney) +- **PhantomJS**: Fixes rendering on Debian Buster. [#18162](https://github.com/grafana/grafana/pull/18162), [@xlson](https://github.com/xlson) +- **TimePicker**: Fixed style issue for custom range popover. [#18244](https://github.com/grafana/grafana/pull/18244), [@torkelo](https://github.com/torkelo) +- **Timerange**: Fixes a bug where custom time ranges didn't respect UTC. [#18248](https://github.com/grafana/grafana/pull/18248), [@kaydelaney](https://github.com/kaydelaney) +- **remote_cache**: Fix redis connstr parsing. [#18204](https://github.com/grafana/grafana/pull/18204), [@mblaschke](https://github.com/mblaschke) + +# 6.3.0-beta1 (2019-07-10) + +### Features / Enhancements + +- **Alerting**: Add tags to alert rules. [#10989](https://github.com/grafana/grafana/pull/10989), [@Thib17](https://github.com/Thib17) +- **Alerting**: Attempt to send email notifications to all given email addresses. [#16881](https://github.com/grafana/grafana/pull/16881), [@zhulongcheng](https://github.com/zhulongcheng) +- **Alerting**: Improve alert rule testing. [#16286](https://github.com/grafana/grafana/pull/16286), [@marefr](https://github.com/marefr) +- **Alerting**: Support for configuring content field for Discord alert notifier. [#17017](https://github.com/grafana/grafana/pull/17017), [@jan25](https://github.com/jan25) +- **Alertmanager**: Replace illegal chars with underscore in label names. [#17002](https://github.com/grafana/grafana/pull/17002), [@bergquist](https://github.com/bergquist) +- **Auth**: Allow expiration of API keys. [#17678](https://github.com/grafana/grafana/pull/17678), [@papagian](https://github.com/papagian) +- **Auth**: Return device, os and browser when listing user auth tokens in HTTP API. [#17504](https://github.com/grafana/grafana/pull/17504), [@shavonn](https://github.com/shavonn) +- **Auth**: Support list and revoke of user auth tokens in UI. [#17434](https://github.com/grafana/grafana/pull/17434), [@shavonn](https://github.com/shavonn) +- **AzureMonitor**: change clashing built-in Grafana variables/macro names for Azure Logs. [#17140](https://github.com/grafana/grafana/pull/17140), [@shavonn](https://github.com/shavonn) +- **CloudWatch**: Made region visible for AWS Cloudwatch Expressions. [#17243](https://github.com/grafana/grafana/pull/17243), [@utkarshcmu](https://github.com/utkarshcmu) +- **Cloudwatch**: Add AWS DocDB metrics. [#17241](https://github.com/grafana/grafana/pull/17241), [@utkarshcmu](https://github.com/utkarshcmu) +- **Dashboard**: Use timezone dashboard setting when exporting to CSV. [#18002](https://github.com/grafana/grafana/pull/18002), [@dehrax](https://github.com/dehrax) +- **Data links**. [#17267](https://github.com/grafana/grafana/pull/17267), [@torkelo](https://github.com/torkelo) +- **Docker**: Switch base image to ubuntu:latest from debian:stretch to avoid security issues.. [#17066](https://github.com/grafana/grafana/pull/17066), [@bergquist](https://github.com/bergquist) +- **Elasticsearch**: Support for visualizing logs in Explore . [#17605](https://github.com/grafana/grafana/pull/17605), [@marefr](https://github.com/marefr) +- **Explore**: Adds Live option for supported data sources. [#17062](https://github.com/grafana/grafana/pull/17062), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Adds orgId to URL for sharing purposes. [#17895](https://github.com/grafana/grafana/pull/17895), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Adds support for new loki 'start' and 'end' params for labels endpoint. [#17512](https://github.com/grafana/grafana/pull/17512), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Adds support for toggling raw query mode in explore. [#17870](https://github.com/grafana/grafana/pull/17870), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Allow switching between metrics and logs . [#16959](https://github.com/grafana/grafana/pull/16959), [@marefr](https://github.com/marefr) +- **Explore**: Combines the timestamp and local time columns into one. [#17775](https://github.com/grafana/grafana/pull/17775), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Display log lines context . [#17097](https://github.com/grafana/grafana/pull/17097), [@dprokop](https://github.com/dprokop) +- **Explore**: Don't parse log levels if provided by field or label. [#17180](https://github.com/grafana/grafana/pull/17180), [@marefr](https://github.com/marefr) +- **Explore**: Improves performance of Logs element by limiting re-rendering. [#17685](https://github.com/grafana/grafana/pull/17685), [@kaydelaney](https://github.com/kaydelaney) +- **Explore**: Support for new LogQL filtering syntax. [#16674](https://github.com/grafana/grafana/pull/16674), [@davkal](https://github.com/davkal) +- **Explore**: Use new TimePicker from Grafana/UI. [#17793](https://github.com/grafana/grafana/pull/17793), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: handle newlines in LogRow Highlighter. [#17425](https://github.com/grafana/grafana/pull/17425), [@rrfeng](https://github.com/rrfeng) +- **Graph**: Added new fill gradient option. [#17528](https://github.com/grafana/grafana/pull/17528), [@torkelo](https://github.com/torkelo) +- **GraphPanel**: Don't sort series when legend table & sort column is not visible . [#17095](https://github.com/grafana/grafana/pull/17095), [@shavonn](https://github.com/shavonn) +- **InfluxDB**: Support for visualizing logs in Explore. [#17450](https://github.com/grafana/grafana/pull/17450), [@hugohaggmark](https://github.com/hugohaggmark) +- **Logging**: Login and Logout actions (#17760). [#17883](https://github.com/grafana/grafana/pull/17883), [@ATTron](https://github.com/ATTron) +- **Logging**: Move log package to pkg/infra. [#17023](https://github.com/grafana/grafana/pull/17023), [@zhulongcheng](https://github.com/zhulongcheng) +- **Metrics**: Expose stats about roles as metrics. [#17469](https://github.com/grafana/grafana/pull/17469), [@bergquist](https://github.com/bergquist) +- **MySQL/Postgres/MSSQL**: Add parsing for day, weeks and year intervals in macros. [#13086](https://github.com/grafana/grafana/pull/13086), [@bernardd](https://github.com/bernardd) +- **MySQL**: Add support for periodically reloading client certs. [#14892](https://github.com/grafana/grafana/pull/14892), [@tpetr](https://github.com/tpetr) +- **Plugins**: replace dataFormats list with skipDataQuery flag in plugin.json. [#16984](https://github.com/grafana/grafana/pull/16984), [@ryantxu](https://github.com/ryantxu) +- **Prometheus**: Take timezone into account for step alignment. [#17477](https://github.com/grafana/grafana/pull/17477), [@fxmiii](https://github.com/fxmiii) +- **Prometheus**: Use overridden panel range for \$\_\_range instead of dashboard range. [#17352](https://github.com/grafana/grafana/pull/17352), [@patrick246](https://github.com/patrick246) +- **Prometheus**: added time range filter to series labels query. [#16851](https://github.com/grafana/grafana/pull/16851), [@FUSAKLA](https://github.com/FUSAKLA) +- **Provisioning**: Support folder that doesn't exist yet in dashboard provisioning. [#17407](https://github.com/grafana/grafana/pull/17407), [@Nexucis](https://github.com/Nexucis) +- **Refresh picker**: Handle empty intervals. [#17585](https://github.com/grafana/grafana/pull/17585), [@dehrax](https://github.com/dehrax) +- **Singlestat**: Add y min/max config to singlestat sparklines. [#17527](https://github.com/grafana/grafana/pull/17527), [@pitr](https://github.com/pitr) +- **Snapshot**: use given key and deleteKey. [#16876](https://github.com/grafana/grafana/pull/16876), [@zhulongcheng](https://github.com/zhulongcheng) +- **Templating**: Correctly display \_\_text in multi-value variable after page reload. [#17840](https://github.com/grafana/grafana/pull/17840), [@EduardSergeev](https://github.com/EduardSergeev) +- **Templating**: Support selecting all filtered values of a multi-value variable. [#16873](https://github.com/grafana/grafana/pull/16873), [@r66ad](https://github.com/r66ad) +- **Tracing**: allow propagation with Zipkin headers. [#17009](https://github.com/grafana/grafana/pull/17009), [@jrockway](https://github.com/jrockway) +- **Users**: Disable users removed from LDAP. [#16820](https://github.com/grafana/grafana/pull/16820), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **SAML**: Add SAML as an authentication option (Enterprise) + +### Bug Fixes + +- **AddPanel**: Fix issue when removing moved add panel widget . [#17659](https://github.com/grafana/grafana/pull/17659), [@dehrax](https://github.com/dehrax) +- **CLI**: Fix encrypt-datasource-passwords fails with sql error. [#18014](https://github.com/grafana/grafana/pull/18014), [@marefr](https://github.com/marefr) +- **Elasticsearch**: Fix default max concurrent shard requests. [#17770](https://github.com/grafana/grafana/pull/17770), [@marefr](https://github.com/marefr) +- **Explore**: Fix browsing back to dashboard panel. [#17061](https://github.com/grafana/grafana/pull/17061), [@jschill](https://github.com/jschill) +- **Explore**: Fix filter by series level in logs graph. [#17798](https://github.com/grafana/grafana/pull/17798), [@marefr](https://github.com/marefr) +- **Explore**: Fix issues when loading and both graph/table are collapsed. [#17113](https://github.com/grafana/grafana/pull/17113), [@marefr](https://github.com/marefr) +- **Explore**: Fix selection/copy of log lines. [#17121](https://github.com/grafana/grafana/pull/17121), [@marefr](https://github.com/marefr) +- **Fix**: Wrap value of multi variable in array when coming from URL. [#16992](https://github.com/grafana/grafana/pull/16992), [@aocenas](https://github.com/aocenas) +- **Frontend**: Fix for Json tree component not working. [#17608](https://github.com/grafana/grafana/pull/17608), [@srid12](https://github.com/srid12) +- **Graphite**: Fix for issue with alias function being moved last. [#17791](https://github.com/grafana/grafana/pull/17791), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixes issue with seriesByTag & function with variable param. [#17795](https://github.com/grafana/grafana/pull/17795), [@torkelo](https://github.com/torkelo) +- **Graphite**: use POST for /metrics/find requests. [#17814](https://github.com/grafana/grafana/pull/17814), [@papagian](https://github.com/papagian) +- **HTTP Server**: Serve Grafana with a custom URL path prefix. [#17048](https://github.com/grafana/grafana/pull/17048), [@jan25](https://github.com/jan25) +- **InfluxDB**: Fixes single quotes are not escaped in label value filters. [#17398](https://github.com/grafana/grafana/pull/17398), [@Panzki](https://github.com/Panzki) +- **Prometheus**: Correctly escape '|' literals in interpolated PromQL variables. [#16932](https://github.com/grafana/grafana/pull/16932), [@Limess](https://github.com/Limess) +- **Prometheus**: Fix when adding label for metrics which contains colons in Explore. [#16760](https://github.com/grafana/grafana/pull/16760), [@tolwi](https://github.com/tolwi) +- **SinglestatPanel**: Remove background color when value turns null. [#17552](https://github.com/grafana/grafana/pull/17552), [@druggieri](https://github.com/druggieri) + +# 6.2.5 (2019-06-25) + +### Features / Enhancements + +- **Grafana-CLI**: Wrapper for `grafana-cli` within RPM/DEB packages and config/homepath are now global flags. [#17695](https://github.com/grafana/grafana/pull/17695), [@gotjosh](https://github.com/gotjosh) +- **Panel**: Fully escape html in drilldown links (was only sanitized before) . [#17731](https://github.com/grafana/grafana/pull/17731), [@dehrax](https://github.com/dehrax) + +### Bug Fixes + +- **Config**: Fix connectionstring for remote_cache in defaults.ini. [#17675](https://github.com/grafana/grafana/pull/17675), [@kylebrandt](https://github.com/kylebrandt) +- **Elasticsearch**: Fix empty query (via template variable) should be sent as wildcard. [#17488](https://github.com/grafana/grafana/pull/17488), [@davewat](https://github.com/davewat) +- **HTTP-Server**: Fix Strict-Transport-Security header. [#17644](https://github.com/grafana/grafana/pull/17644), [@kylebrandt](https://github.com/kylebrandt) +- **TablePanel**: fix annotations display. [#17646](https://github.com/grafana/grafana/pull/17646), [@ryantxu](https://github.com/ryantxu) + +# 6.2.4 (2019-06-18) + +### Bug Fixes + +- **Grafana-CLI**: Fix receiving flags via command line . [#17617](https://github.com/grafana/grafana/pull/17617), [@gotjosh](https://github.com/gotjosh) +- **HTTPServer**: Fix X-XSS-Protection header formatting. [#17620](https://github.com/grafana/grafana/pull/17620), [@yverry](https://github.com/yverry) + +# 6.2.3 (2019-06-17) + +### Known issues + +- **grafana-cli**: The argument `--pluginsDir` is not working. +- **docker**: Due to above problem with grafana-cli the docker run will fail to start the container if you're installing plugins using the `GF_INSTALL_PLUGINS` environment variable. We have removed 6.2.3 tag from docker hub and latest tag now points to 6.2.2. + +More details in bug report: https://github.com/grafana/grafana/issues/17613 + +### Features / Enhancements + +- **AuthProxy**: Optimistic lock pattern for remote cache Set. [#17485](https://github.com/grafana/grafana/pull/17485), [@papagian](https://github.com/papagian) +- **HTTPServer**: Options for returning new headers X-Content-Type-Options, X-XSS-Protection and Strict-Transport-Security. [#17522](https://github.com/grafana/grafana/pull/17522), [@kylebrandt](https://github.com/kylebrandt) + +### Bug Fixes + +- **Auth Proxy**: Fix non-negative cache TTL. [#17495](https://github.com/grafana/grafana/pull/17495), [@kylebrandt](https://github.com/kylebrandt) +- **Grafana-CLI**: Fix receiving configuration flags from the command line. [#17606](https://github.com/grafana/grafana/pull/17606), [@gotjosh](https://github.com/gotjosh) +- **OAuth**: Fix for wrong user token updated on OAuth refresh in DS proxy. [#17541](https://github.com/grafana/grafana/pull/17541), [@redbaron](https://github.com/redbaron) +- **remote_cache**: Fix redis. [#17483](https://github.com/grafana/grafana/pull/17483), [@kylebrandt](https://github.com/kylebrandt) + +# 6.2.2 (2019-06-05) + +### Features / Enhancements + +- **Security**: Prevent CSV formula injection attack when exporting data. [#17363](https://github.com/grafana/grafana/pull/17363), [@DanCech](https://github.com/DanCech) + +### Bug Fixes + +- **CloudWatch**: Fixes error when hiding/disabling queries . [#17283](https://github.com/grafana/grafana/pull/17283), [@jpiccari](https://github.com/jpiccari) +- **Database**: Fixed slow permission query in folder/dashboard search. [#17427](https://github.com/grafana/grafana/pull/17427), [@aocenas](https://github.com/aocenas) +- **Explore**: Fixed updating time range before running queries. [#17349](https://github.com/grafana/grafana/pull/17349), [@marefr](https://github.com/marefr) +- **Plugins**: Fixed plugin config page navigation when using subpath. [#17364](https://github.com/grafana/grafana/pull/17364), [@torkelo](https://github.com/torkelo) + +# 6.2.1 (2019-05-27) + +### Features / Enhancements + +- **CLI**: Add command to migrate all data sources to use encrypted password fields . [#17118](https://github.com/grafana/grafana/pull/17118), [@aocenas](https://github.com/aocenas) +- **Gauge/BarGauge**: Improvements to auto value font size . [#17292](https://github.com/grafana/grafana/pull/17292), [@torkelo](https://github.com/torkelo) + +### Bug Fixes + +- **Auth Proxy**: Resolve database is locked errors. [#17274](https://github.com/grafana/grafana/pull/17274), [@marefr](https://github.com/marefr) +- **Database**: Retry transaction if sqlite returns database is locked error. [#17276](https://github.com/grafana/grafana/pull/17276), [@marefr](https://github.com/marefr) +- **Explore**: Fixes so clicking in a Prometheus Table the query is filtered by clicked value. [#17083](https://github.com/grafana/grafana/pull/17083), [@hugohaggmark](https://github.com/hugohaggmark) +- **Singlestat**: Fixes issue with value placement and line wraps. [#17249](https://github.com/grafana/grafana/pull/17249), [@torkelo](https://github.com/torkelo) +- **Tech**: Update jQuery to 3.4.1 to fix issue on iOS 10 based browsers as well as Chrome 53.x . [#17290](https://github.com/grafana/grafana/pull/17290), [@timbutler](https://github.com/timbutler) + +# 6.2.0 (2019-05-22) + +### Bug Fixes + +- **BarGauge**: Fix for negative min values. [#17192](https://github.com/grafana/grafana/pull/17192), [@torkelo](https://github.com/torkelo) +- **Gauge/BarGauge**: Fix for issues editing min & max options. [#17174](https://github.com/grafana/grafana/pull/17174) +- **Search**: Make only folder name only open search with current folder filter. [#17226](https://github.com/grafana/grafana/pull/17226) +- **AzureMonitor**: Revert to clearing chained dropdowns. [#17212](https://github.com/grafana/grafana/pull/17212) + +### Breaking Changes + +- **Plugins**: Data source plugins that process hidden queries need to add a "hiddenQueries: true" attribute in plugin.json. [#17124](https://github.com/grafana/grafana/pull/17124), [@ryantxu](https://github.com/ryantxu) + +### Removal of old deprecated package repository + +5 months ago we deprecated our old package cloud repository and [replaced it](https://grafana.com/blog/2019/01/05/moving-to-packages.grafana.com/) with our own. We will remove the old depreciated +repo on July 1st. Make sure you have switched to the new repo by then. The new repository has all our old releases so you are not required to upgrade just to switch package repository. + +# 6.2.0-beta2 (2019-05-15) + +### Features / Enhancements + +- **Plugins**: Support templated urls in plugin routes. [#16599](https://github.com/grafana/grafana/pull/16599), [@briangann](https://github.com/briangann) +- **Packaging**: New MSI windows installer package\*\*. [#17073](https://github.com/grafana/grafana/pull/17073), [@briangann](https://github.com/briangann) + +### Bug Fixes + +- **Dashboard**: Fixes blank dashboard after window resize with panel without title. [#16942](https://github.com/grafana/grafana/pull/16942), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixes lazy loading & expanding collapsed rows on mobile. [#17055](https://github.com/grafana/grafana/pull/17055), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fixes scrolling issues for Edge browser. [#17033](https://github.com/grafana/grafana/pull/17033), [@jschill](https://github.com/jschill) +- **Dashboard**: Show refresh button in first kiosk(tv) mode. [#17032](https://github.com/grafana/grafana/pull/17032), [@torkelo](https://github.com/torkelo) +- **Explore**: Fix empty result from data source should render logs container. [#16999](https://github.com/grafana/grafana/pull/16999), [@marefr](https://github.com/marefr) +- **Explore**: Fixes so clicking in a Prometheus Table the query is filtered by clicked value. [#17083](https://github.com/grafana/grafana/pull/17083), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Makes it possible to zoom in Explore/Loki/Graph without exception. [#16991](https://github.com/grafana/grafana/pull/16991), [@hugohaggmark](https://github.com/hugohaggmark) +- **Gauge**: Fixes orientation issue after switching from BarGauge to Gauge. [#17064](https://github.com/grafana/grafana/pull/17064), [@torkelo](https://github.com/torkelo) +- **GettingStarted**: Fixes layout issues in getting started panel. [#16941](https://github.com/grafana/grafana/pull/16941), [@torkelo](https://github.com/torkelo) +- **InfluxDB**: Fix HTTP method should default to GET. [#16949](https://github.com/grafana/grafana/pull/16949), [@StephenSorriaux](https://github.com/StephenSorriaux) +- **Panels**: Fixed alert icon position in panel header. [#17070](https://github.com/grafana/grafana/pull/17070), [@torkelo](https://github.com/torkelo) +- **Panels**: Fixes panel error tooltip not showing. [#16993](https://github.com/grafana/grafana/pull/16993), [@torkelo](https://github.com/torkelo) +- **Plugins**: Fix how datemath utils are exposed to plugins. [#16976](https://github.com/grafana/grafana/pull/16976), [@marefr](https://github.com/marefr) +- **Singlestat**: fixed centering issue for very small panels. [#16944](https://github.com/grafana/grafana/pull/16944), [@torkelo](https://github.com/torkelo) +- **Search**: Scroll issue in dashboard search in latest Chrome. [#17054](https://github.com/grafana/grafana/pull/17054), [@jschill](https://github.com/jschill) +- **Docker**: Prevent a permission denied error when writing files to the default provisioning directory. [#16831](https://github.com/grafana/grafana/pull/16831), [@wmedlar](https://github.com/wmedlar) +- **Gauge**: Adds background shade to gauge track and improves height usage. [#17019](https://github.com/grafana/grafana/pull/17019), [@torkelo](https://github.com/torkelo) +- **RemoteCache**: Avoid race condition in Set causing error on insert. . [#17082](https://github.com/grafana/grafana/pull/17082), [@bergquist](https://github.com/bergquist) + +# 6.2.0-beta1 (2019-05-07) + +### Features / Enhancements + +- **Admin**: Add more stats about roles. [#16667](https://github.com/grafana/grafana/pull/16667), [@bergquist](https://github.com/bergquist) +- **Alert list panel**: Support variables in filters. [#16892](https://github.com/grafana/grafana/pull/16892), [@psschand](https://github.com/psschand) +- **Alerting**: Adjust label for send on all alerts to default . [#16554](https://github.com/grafana/grafana/pull/16554), [@simPod](https://github.com/simPod) +- **Alerting**: Makes timeouts and retries configurable. [#16259](https://github.com/grafana/grafana/pull/16259), [@kobehaha](https://github.com/kobehaha) +- **Alerting**: No notification when going from no data to pending. [#16905](https://github.com/grafana/grafana/pull/16905), [@bergquist](https://github.com/bergquist) +- **Alerting**: Pushover alert, support for different sound for OK. [#16525](https://github.com/grafana/grafana/pull/16525), [@Hofls](https://github.com/Hofls) +- **Auth**: Enable retries and transaction for some db calls for auth tokens . [#16785](https://github.com/grafana/grafana/pull/16785), [@bergquist](https://github.com/bergquist) +- **AzureMonitor**: Adds support for multiple subscriptions per data source. [#16922](https://github.com/grafana/grafana/pull/16922), [@daniellee](https://github.com/daniellee) +- **Bar Gauge**: New multi series enabled gauge like panel with horizontal and vertical layouts and 3 display modes. [#16918](https://github.com/grafana/grafana/pull/16918), [@torkelo](https://github.com/torkelo) +- **Build**: Upgrades to golang 1.12.4. [#16545](https://github.com/grafana/grafana/pull/16545), [@bergquist](https://github.com/bergquist) +- **CloudWatch**: Update AWS/IoT metric and dimensions. [#16337](https://github.com/grafana/grafana/pull/16337), [@nonamef](https://github.com/nonamef) +- **Config**: Show user-friendly error message instead of stack trace. [#16564](https://github.com/grafana/grafana/pull/16564), [@Hofls](https://github.com/Hofls) +- **Dashboard**: Enable filtering dashboards in search by current folder. [#16790](https://github.com/grafana/grafana/pull/16790), [@dprokop](https://github.com/dprokop) +- **Dashboard**: Lazy load out of view panels . [#15554](https://github.com/grafana/grafana/pull/15554), [@ryantxu](https://github.com/ryantxu) +- **DataProxy**: Restore Set-Cookie header after proxy request. [#16838](https://github.com/grafana/grafana/pull/16838), [@marefr](https://github.com/marefr) +- **Data Sources**: Add pattern validation for time input on data source config pages. [#16837](https://github.com/grafana/grafana/pull/16837), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Add 7.x version support. [#16646](https://github.com/grafana/grafana/pull/16646), [@alcidesv](https://github.com/alcidesv) +- **Explore**: Adds reconnect for failing data source. [#16226](https://github.com/grafana/grafana/pull/16226), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Support user timezone. [#16469](https://github.com/grafana/grafana/pull/16469), [@marefr](https://github.com/marefr) +- **InfluxDB**: Add support for POST HTTP verb. [#16690](https://github.com/grafana/grafana/pull/16690), [@StephenSorriaux](https://github.com/StephenSorriaux) +- **Loki**: Search is now case insensitive. [#15948](https://github.com/grafana/grafana/pull/15948), [@steven-sheehy](https://github.com/steven-sheehy) +- **OAuth**: Update jwt regexp to include `=`. [#16521](https://github.com/grafana/grafana/pull/16521), [@DanCech](https://github.com/DanCech) +- **Panels**: No title will no longer make panel header take up space. [#16884](https://github.com/grafana/grafana/pull/16884), [@torkelo](https://github.com/torkelo) +- **Prometheus**: Adds tracing headers for Prometheus datasource. [#16724](https://github.com/grafana/grafana/pull/16724), [@svagner](https://github.com/svagner) +- **Provisioning**: Add API endpoint to reload provisioning configs. [#16579](https://github.com/grafana/grafana/pull/16579), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Do not allow deletion of provisioned dashboards. [#16211](https://github.com/grafana/grafana/pull/16211), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Interpolate env vars in provisioning files. [#16499](https://github.com/grafana/grafana/pull/16499), [@aocenas](https://github.com/aocenas) +- **Provisioning**: Support FolderUid in Dashboard Provisioning Config. [#16559](https://github.com/grafana/grafana/pull/16559), [@swtch1](https://github.com/swtch1) +- **Security**: Add new setting allow_embedding. [#16853](https://github.com/grafana/grafana/pull/16853), [@marefr](https://github.com/marefr) +- **Security**: Store data source passwords encrypted in secureJsonData. [#16175](https://github.com/grafana/grafana/pull/16175), [@aocenas](https://github.com/aocenas) +- **UX**: Improve Grafana usage for smaller screens. [#16783](https://github.com/grafana/grafana/pull/16783), [@torkelo](https://github.com/torkelo) +- **Units**: Add angle units, Arc Minutes and Seconds. [#16271](https://github.com/grafana/grafana/pull/16271), [@Dripoul](https://github.com/Dripoul) + +### Bug Fixes + +- **Build**: Fix bug where grafana didn't start after mysql on rpm packages. [#16917](https://github.com/grafana/grafana/pull/16917), [@bergquist](https://github.com/bergquist) +- **CloudWatch**: Fixes query order not affecting series ordering & color. [#16408](https://github.com/grafana/grafana/pull/16408), [@mtanda](https://github.com/mtanda) +- **CloudWatch**: Use default alias if there is no alias for metrics. [#16732](https://github.com/grafana/grafana/pull/16732), [@utkarshcmu](https://github.com/utkarshcmu) +- **Config**: Fixes bug where timeouts for alerting was not parsed correctly. [#16784](https://github.com/grafana/grafana/pull/16784), [@aocenas](https://github.com/aocenas) +- **Elasticsearch**: Fix view percentiles metric in table without date histogram. [#15686](https://github.com/grafana/grafana/pull/15686), [@Igor-Ratsuk](https://github.com/Igor-Ratsuk) +- **Explore**: Prevents histogram loading from killing Prometheus instance. [#16768](https://github.com/grafana/grafana/pull/16768), [@hugohaggmark](https://github.com/hugohaggmark) +- **Graph**: Allow override decimals to fully override. [#16414](https://github.com/grafana/grafana/pull/16414), [@torkelo](https://github.com/torkelo) +- **Mixed Data Source**: Fix error when one query is disabled. [#16409](https://github.com/grafana/grafana/pull/16409), [@marefr](https://github.com/marefr) +- **Search**: Fixes search limits and adds a page parameter. [#16458](https://github.com/grafana/grafana/pull/16458), [@torkelo](https://github.com/torkelo) +- **Security**: Responses from backend should not be cached. [#16848](https://github.com/grafana/grafana/pull/16848), [@marefr](https://github.com/marefr) + +### Breaking changes + +- **Gauge Panel**: The suffix / prefix options have been removed from the new Gauge Panel (introduced in v6.0). [#16870](https://github.com/grafana/grafana/issues/16870). + +# 6.1.6 (2019-04-29) + +### Features / Enhancements + +- **Security**: Bump jQuery to 3.4.0 . [#16761](https://github.com/grafana/grafana/pull/16761), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **Playlist**: Fix loading dashboards by tag. [#16727](https://github.com/grafana/grafana/pull/16727), [@marefr](https://github.com/marefr) + +# 6.1.5 (2019-04-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/04/29/grafana-5.4.4-and-6.1.6-released-with-important-security-fix/) + +# 6.1.4 (2019-04-16) + +### Bug Fixes + +- **DataPanel**: Added missing built-in interval variables to scopedVars. [#16556](https://github.com/grafana/grafana/pull/16556), [@torkelo](https://github.com/torkelo) +- **Explore**: Adds maxDataPoints to data source query options . [#16513](https://github.com/grafana/grafana/pull/16513), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fixes so intervals are recalculated on run query. [#16510](https://github.com/grafana/grafana/pull/16510), [@hugohaggmark](https://github.com/hugohaggmark) +- **Heatmap**: Fix for empty graph when panel is too narrow (#16378). [#16460](https://github.com/grafana/grafana/pull/16460), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: Fixed auto decimals when bucket name is not number. [#16609](https://github.com/grafana/grafana/pull/16609), [@torkelo](https://github.com/torkelo) +- **QueryInspector**: Now shows error responses again. [#16514](https://github.com/grafana/grafana/pull/16514), [@torkelo](https://github.com/torkelo) + +# 6.1.3 (2019-04-09) + +### Bug Fixes + +- **Graph**: Fixed auto decimals in legend values for some units like `ms` and `s`. [#16455](https://github.com/grafana/grafana/pull/16455), [@torkelo](https://github.com/torkelo) +- **Graph**: Fixed png rendering with legend to the right. [#16463](https://github.com/grafana/grafana/pull/16463), [@torkelo](https://github.com/torkelo) +- **Singlestat**: Use decimals when manually specified. [#16451](https://github.com/grafana/grafana/pull/16451), [@torkelo](https://github.com/torkelo) +- **UI Switch**: Fix broken UI switches. Fixes Default Data Source switch, Explore Logs switches, Gauge option switches. [#16303](https://github.com/grafana/grafana/pull/16303), [@dprokop](https://github.com/dprokop) + +# 6.1.2 (2019-04-08) + +### Bug Fixes + +- **Graph**: Fixed series legend color for hidden series. [#16438](https://github.com/grafana/grafana/pull/16438), [@Ijin08](https://github.com/Ijin08) +- **Graph**: Fixed tooltip highlight on white theme. [#16429](https://github.com/grafana/grafana/pull/16429), [@torkelo](https://github.com/torkelo) +- **Styles**: Fixed menu hover highlight border. [#16431](https://github.com/grafana/grafana/pull/16431), [@torkelo](https://github.com/torkelo) +- **Singlestat Panel**: Correctly use the override decimals. [#16413](https://github.com/grafana/grafana/pull/16413), [@torkelo](https://github.com/torkelo) + +# 6.1.1 (2019-04-05) + +### Bug Fixes + +- **Alerting**: Notification channel http api fixes. [#16379](https://github.com/grafana/grafana/pull/16379), [@marefr](https://github.com/marefr) +- **Graphite**: Editing graphite query function now works again. [#16390](https://github.com/grafana/grafana/pull/16390), [@torkelo](https://github.com/torkelo) +- **Playlist**: Kiosk & auto fit panels modes are working normally again . [#16403](https://github.com/grafana/grafana/pull/16403), [@torkelo](https://github.com/torkelo) +- **QueryEditors**: Toggle edit mode now always work on slower computers. [#16394](https://github.com/grafana/grafana/pull/16394), [@seanlaff](https://github.com/seanlaff) + +# 6.1.0 (2019-04-03) + +### Bug Fixes + +- **CloudWatch**: Fix for dimension value list when changing dimension key. [#16356](https://github.com/grafana/grafana/pull/16356), [@mtanda](https://github.com/mtanda) +- **Graphite**: Editing function arguments now works again. [#16297](https://github.com/grafana/grafana/pull/16297), [@torkelo](https://github.com/torkelo) +- **InfluxDB**: Fix tag names with periods in alert evaluation. [#16255](https://github.com/grafana/grafana/pull/16255), [@floyd-may](https://github.com/floyd-may) +- **PngRendering**: Fix for panel height & title centering . [#16351](https://github.com/grafana/grafana/pull/16351), [@torkelo](https://github.com/torkelo) +- **Templating**: Fix for editing query variables. [#16299](https://github.com/grafana/grafana/pull/16299), [@torkelo](https://github.com/torkelo) + +# 6.1.0-beta1 (2019-03-27) + +### New Features + +- **Prometheus**: adhoc filter support [#8253](https://github.com/grafana/grafana/issues/8253), thx [@mtanda](https://github.com/mtanda) +- **Permissions**: Editors can become admin for dashboards, folders and teams they create. [#15977](https://github.com/grafana/grafana/pull/15977), [@xlson](https://github.com/xlson) + +### Minor + +- **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836) +- **Alerting**: DingDing notification channel now includes alert values. [#13825](https://github.com/grafana/grafana/pull/13825), [@athurg](https://github.com/athurg) +- **Alerting**: Notification channel http api enhancements. [#16219](https://github.com/grafana/grafana/pull/16219), [@marefr](https://github.com/marefr) +- **CloudWatch**: Update metrics/dimensions list. [#16137](https://github.com/grafana/grafana/pull/16137), [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow) +- **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816) +- **Dashboard**: New keyboard shortcut `d l` toggles all Graph legends in a dashboard. [#15770](https://github.com/grafana/grafana/pull/15770), [@jsferrei](https://github.com/jsferrei) +- **Data Source**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001) +- **DataProxy**: Add custom header (X-Grafana-User) to data source requests with the current username. [#15998](https://github.com/grafana/grafana/pull/15998), [@aocenas](https://github.com/aocenas) +- **DataProxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931) +- **DataProxy**: Adds oauth pass-through option for data sources. [#15205](https://github.com/grafana/grafana/pull/15205), [@seanlaff](https://github.com/seanlaff) +- **Explore**: Hide empty duplicates column in logs viewer. [#15982](https://github.com/grafana/grafana/pull/15982), [@steven-sheehy](https://github.com/steven-sheehy) +- **Explore**: Make it possible to close left pane of split view. [#16155](https://github.com/grafana/grafana/pull/16155), [@dprokop](https://github.com/dprokop) +- **Explore**: Move back / forward with browser buttons now works. [#16150](https://github.com/grafana/grafana/pull/16150), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Update Loki labels when label selector is opened. [#16131](https://github.com/grafana/grafana/pull/16131), [@dprokop](https://github.com/dprokop) +- **Graph Panel**: New options for X-axis Min & Max (for histograms). [#14877](https://github.com/grafana/grafana/pull/14877), [@papagian](https://github.com/papagian) +- **Heatmap**: You can now choose to hide buckets with zero value. [#15934](https://github.com/grafana/grafana/pull/15934), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683) +- **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683) +- **Prometheus**: Change alignment of range queries to end before now and not in future. [#16110](https://github.com/grafana/grafana/pull/16110), [@davkal](https://github.com/davkal) +- **Prometheus**: Dedup annotations events with same timestamp . [#16152](https://github.com/grafana/grafana/pull/16152), [@torkelo](https://github.com/torkelo) +- **SQL**: Use default min interval of 1m for all SQL data sources. [#15799](https://github.com/grafana/grafana/pull/15799), [@marefr](https://github.com/marefr) +- **TablePanel**: Column color style now works even after removing columns. [#16227](https://github.com/grafana/grafana/pull/16227), [@torkelo](https://github.com/torkelo) +- **Templating**: Custom variable value now escapes all backslashes properly. [#15980](https://github.com/grafana/grafana/pull/15980), [@srid12](https://github.com/srid12) +- **Templating**: Data source variable now supports multi-value for uses cases that involve repeating panels & rows. [#15914](https://github.com/grafana/grafana/pull/15914), [@torkelo](https://github.com/torkelo) +- **VictorOps**: Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng) + +### Bug Fixes + +- **Alerting**: Don't include non-existing image in MS Teams notifications. [#16116](https://github.com/grafana/grafana/pull/16116), [@SGI495](https://github.com/SGI495) +- **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506) +- **Annotations**: Fix for native annotations filtered by template variable with pipe. [#15515](https://github.com/grafana/grafana/pull/15515), [@marefr](https://github.com/marefr) +- **Dashboard**: Fix for time regions spanning across midnight. [#16201](https://github.com/grafana/grafana/pull/16201), [@marefr](https://github.com/marefr) +- **Data Source**: Handles nil jsondata field gracefully [#14239](https://github.com/grafana/grafana/issues/14239) +- **Data Source**: Empty user/password was not updated when updating data sources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619) +- **Elasticsearch**: Fixes using template variables in the alias field. [#16229](https://github.com/grafana/grafana/pull/16229), [@daniellee](https://github.com/daniellee) +- **Elasticsearch**: Fix incorrect index pattern padding in alerting queries. [#15892](https://github.com/grafana/grafana/pull/15892), [@sandlis](https://github.com/sandlis) +- **Explore**: Fix for Prometheus autocomplete not working in Firefox. [#16192](https://github.com/grafana/grafana/pull/16192), [@hugohaggmark](https://github.com/hugohaggmark) +- **Explore**: Fix for url does not keep query after browser refresh. [#16189](https://github.com/grafana/grafana/pull/16189), [@hugohaggmark](https://github.com/hugohaggmark) +- **Gauge**: Interpolate scoped variables in repeated gauges [#15739](https://github.com/grafana/grafana/issues/15739) +- **Graphite**: Fixed issue with using series ref and series by tag. [#16111](https://github.com/grafana/grafana/pull/16111), [@torkelo](https://github.com/torkelo) +- **Graphite**: Fixed variable quoting when variable value is numeric. [#16149](https://github.com/grafana/grafana/pull/16149), [@torkelo](https://github.com/torkelo) +- **Heatmap**: Fixes Y-axis tick labels being in wrong order for some Prometheus queries. [#15932](https://github.com/grafana/grafana/pull/15932), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: Negative values are now displayed correctly in graph & legend. [#15953](https://github.com/grafana/grafana/pull/15953), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019) +- **InfluxDB**: Always close request body even for error status codes. [#16207](https://github.com/grafana/grafana/pull/16207), [@ramongtx](https://github.com/ramongtx) +- **ManageDashboards**: Fix for checkboxes not appearing properly Firefox . [#15981](https://github.com/grafana/grafana/pull/15981), [@srid12](https://github.com/srid12) +- **Playlist**: Leaving playlist now always stops playlist . [#15791](https://github.com/grafana/grafana/pull/15791), [@peterholmberg](https://github.com/peterholmberg) +- **Prometheus**: fixes regex ad-hoc filters variables with wildcards. [#16234](https://github.com/grafana/grafana/pull/16234), [@daniellee](https://github.com/daniellee) +- **TablePanel**: Column color style now works even after removing columns. [#16227](https://github.com/grafana/grafana/pull/16227), [@torkelo](https://github.com/torkelo) +- **TablePanel**: Fix for white text on white background when value is null. [#16199](https://github.com/grafana/grafana/pull/16199), [@peterholmberg](https://github.com/peterholmberg) + +# 6.0.2 (2019-03-19) + +### Bug Fixes + +- **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo) +- **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr) +- **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr) +- **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg) +- **Data Source admin**: Fixed url of back button in data source edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop) + +# 6.0.1 (2019-03-06) + +### Bug Fixes + +- **Metrics**: Fixes broken usagestats metrics for /metrics [#15651](https://github.com/grafana/grafana/issues/15651) +- **Dashboard**: Fixes kiosk mode should have &kiosk appended to the url [#15765](https://github.com/grafana/grafana/issues/15765) +- **Dashboard**: Fixes kiosk=tv mode with autofitpanels should respect header [#15650](https://github.com/grafana/grafana/issues/15650) +- **Image rendering**: Fixed image rendering issue for dashboards with auto refresh, . [#15818](https://github.com/grafana/grafana/pull/15818), [@torkelo](https://github.com/torkelo) +- **Dashboard**: Fix only users that can edit a dashboard should be able to update panel json. [#15805](https://github.com/grafana/grafana/pull/15805), [@marefr](https://github.com/marefr) +- **LDAP**: fix allow anonymous initial bind for ldap search. [#15803](https://github.com/grafana/grafana/pull/15803), [@marefr](https://github.com/marefr) +- **UX**: Fixed scrollbar not visible initially (only after manual scroll). [#15798](https://github.com/grafana/grafana/pull/15798), [@torkelo](https://github.com/torkelo) +- **Data Source admin** TestData [#15793](https://github.com/grafana/grafana/pull/15793), [@hugohaggmark](https://github.com/hugohaggmark) +- **Dashboard**: Fixed scrolling issue that caused scroll to be locked to bottom. [#15792](https://github.com/grafana/grafana/pull/15792), [@torkelo](https://github.com/torkelo) +- **Explore**: Viewers with viewers_can_edit should be able to access /explore. [#15787](https://github.com/grafana/grafana/pull/15787), [@jschill](https://github.com/jschill) +- **Security** fix: limit access to org admin and alerting pages. [#15761](https://github.com/grafana/grafana/pull/15761), [@marefr](https://github.com/marefr) +- **Panel Edit** minInterval changes did not persist [#15757](https://github.com/grafana/grafana/pull/15757), [@hugohaggmark](https://github.com/hugohaggmark) +- **Teams**: Fixed bug when getting teams for user. [#15595](https://github.com/grafana/grafana/pull/15595), [@hugohaggmark](https://github.com/hugohaggmark) +- **Stackdriver**: fix for float64 bounds for distribution metrics [#14509](https://github.com/grafana/grafana/issues/14509) +- **Stackdriver**: no reducers available for distribution type [#15179](https://github.com/grafana/grafana/issues/15179) + +# 6.0.0 stable (2019-02-25) + +### Bug Fixes + +- **Dashboard**: fixes click after scroll in series override menu [#15621](https://github.com/grafana/grafana/issues/15621) +- **MySQL**: fix mysql query using \_interval_ms variable throws error [#14507](https://github.com/grafana/grafana/issues/14507) + +# 6.0.0-beta3 (2019-02-19) + +### Minor + +- **CLI**: Grafana CLI should preserve permissions for backend binaries for Linux and Darwin [#15500](https://github.com/grafana/grafana/issues/15500) +- **Alerting**: Allow image rendering 90 percent of alertTimeout [#15395](https://github.com/grafana/grafana/pull/15395) + +### Bug fixes + +- **Influxdb**: Add support for alerting on InfluxDB queries that use the non_negative_difference function [#15415](https://github.com/grafana/grafana/issues/15415), thx [@kiran3394](https://github.com/kiran3394) +- **Alerting**: Fix percent_diff calculation when points are nulls [#15443](https://github.com/grafana/grafana/issues/15443), thx [@max-neverov](https://github.com/max-neverov) +- **Alerting**: Fixed handling of alert urls with true flags [#15454](https://github.com/grafana/grafana/issues/15454) + +# 6.0.0-beta2 (2019-02-11) + +### New Features + +- **AzureMonitor**: Enable alerting by converting Azure Monitor API to Go [#14623](https://github.com/grafana/grafana/issues/14623) + +### Minor + +- **Alerting**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +- **Graphite/InfluxDB/OpenTSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges [#15284](https://github.com/grafana/grafana/issues/15284) +- **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) +- **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) +- **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) +- **Cloudwatch**: Add AWS/EC2/API metrics [#14233](https://github.com/grafana/grafana/issues/14233), thx [@tcpatterson](https://github.com/tcpatterson) +- **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) +- **MySQL**: Adds data source SSL CA/client certificates support [#8570](https://github.com/grafana/grafana/issues/8570), thx [@bugficks](https://github.com/bugficks) +- **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh) +- **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) +- **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) +- **Login**: Anonymous usage stats for token auth [#15288](https://github.com/grafana/grafana/issues/15288) +- **AzureMonitor**: improve autocomplete for Log Analytics and App Insights editor [#15131](https://github.com/grafana/grafana/issues/15131) +- **LDAP**: Fix IPA/FreeIPA v4.6.4 does not allow LDAP searches with empty attributes [#14432](https://github.com/grafana/grafana/issues/14432) +- **Provisioning**: Allow testing data sources that were added by config [#12164](https://github.com/grafana/grafana/issues/12164) +- **Security**: Fix CSRF Token validation for POSTs [#1441](https://github.com/grafana/grafana/issues/1441) + +### Breaking changes + +- **Internal Metrics** Edition has been added to the build_info metric. This will break any Graphite queries using this metric. Edition will be a new label for the Prometheus metric. [#15363](https://github.com/grafana/grafana/pull/15363) + +### Bug fixes + +- **Gauge**: Fix issue with gauge requests being cancelled [#15366](https://github.com/grafana/grafana/issues/15366) +- **Gauge**: Accept decimal inputs for thresholds [#15372](https://github.com/grafana/grafana/issues/15372) +- **UI**: Fix error caused by named colors that are not part of named colors palette [#15373](https://github.com/grafana/grafana/issues/15373) +- **Search**: Bug pressing special regexp chars in input fields [#12972](https://github.com/grafana/grafana/issues/12972) +- **Permissions**: No need to have edit permissions to be able to "Save as" [#13066](https://github.com/grafana/grafana/issues/13066) + +# 6.0.0-beta1 (2019-01-30) + +### New Features + +- **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) +- **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968) +- **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl) +- **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) +- **Provisioning**: Provisioning support for alert notifiers [#10487](https://github.com/grafana/grafana/issues/10487), thx [@pbakulev](https://github.com/pbakulev) +- **Explore**: A whole new way to do ad-hoc metric queries and exploration. Split view in half and compare metrics & logs and much much more. [Read more here](http://docs.grafana.org/features/explore/) +- **Auth**: Replace remember me cookie solution for Grafana's builtin, LDAP and OAuth authentication with a solution based on short-lived tokens [#15303](https://github.com/grafana/grafana/issues/15303) + +### Minor + +- **Templating**: Built in time range variables `$__from` and `$__to`, [#1909](https://github.com/grafana/grafana/issues/1909) +- **Alerting**: Use separate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813) +- **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) +- **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175) +- **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh) +- **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh) +- **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) +- **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551) +- **Postgres/MySQL/MSSQL**: Nanosecond timestamp support (`$__unixEpochNanoFilter`, `$__unixEpochNanoFrom`, `$__unixEpochNanoTo`) [#14711](https://github.com/grafana/grafana/pull/14711), thx [@ander26](https://github.com/ander26) +- **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) +- **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) +- **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) +- **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) +- **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) +- **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) +- **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard) +- **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud) +- **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia) +- **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK) +- **Templating**: Add percentencode formatting to variable interpolation to be used mainly for url escaping [#12764](https://github.com/grafana/grafana/issues/12764), thx [@cxcv](https://github.com/cxcv) +- **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik) +- **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo) +- **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484) +- **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) +- **Dataproxy**: Add global data source proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick) +- **Database**: Support specifying database host using IPV6 for backend database and sql data sources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad) +- **Database**: Support defining additional database connection string args when using `url` property in database settings [#14709](https://github.com/grafana/grafana/pull/14709), thx [@tpetr](https://github.com/tpetr) +- **Stackdriver**: crossSeriesAggregation not being sent with the query [#15129](https://github.com/grafana/grafana/issues/15129), thx [@Legogris](https://github.com/Legogris) + +### 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) +- **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy) +- **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062) +- **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189) +- **Alerting**: Fixes crash bug when alert notifier folders are missing [#15295](https://github.com/grafana/grafana/issues/15295) +- **Dashboard**: Fix save provisioned dashboard modal [#15219](https://github.com/grafana/grafana/pull/15219) +- **Dashboard**: Fix having a long query in prometheus dashboard query editor blocks 30% of the query field when on OSX and having native scrollbars [#15122](https://github.com/grafana/grafana/issues/15122) +- **Explore**: Fix issue with wrapping on long queries [#15222](https://github.com/grafana/grafana/issues/15222) +- **Explore**: Fix cut & paste adds newline before and after selection [#15223](https://github.com/grafana/grafana/issues/15223) +- **Dataproxy**: Fix global data source proxy timeout not added to correct http client [#15258](https://github.com/grafana/grafana/issues/15258) [#5699](https://github.com/grafana/grafana/issues/5699) + +### Breaking changes + +- **Text Panel**: The text panel does no longer by default allow unsanitized HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`. +- **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991) + +For older release notes, refer to the [CHANGELOG_ARCHIVE.md](https://github.com/grafana/grafana/blob/master/CHANGELOG_ARCHIVE.md) diff --git a/CHANGELOG_ARCHIVE.md b/CHANGELOG_ARCHIVE.md new file mode 100644 index 0000000..68d0efd --- /dev/null +++ b/CHANGELOG_ARCHIVE.md @@ -0,0 +1,2257 @@ + +# 5.4.5 (2019-08-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/08/29/grafana-5.4.5-and-6.3.4-released-with-important-security-fix/) + +# 5.4.4 (2019-04-29) + +- **Security**: Urgent security patch release. Please read more in our [blog](https://grafana.com/blog/2019/04/29/grafana-5.4.4-and-6.1.6-released-with-important-security-fix/) + +# 5.4.3 (2019-01-14) + +### Tech + +- **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach) +- **Backend**: Upgrade to golang 1.11.4 [#14580](https://github.com/grafana/grafana/issues/14580) +- **MySQL** only update session in mysql database when required [#14540](https://github.com/grafana/grafana/pull/14540) + +### Bug fixes + +- **Alerting** Invalid frequency causes division by zero in alert scheduler [#14810](https://github.com/grafana/grafana/issues/14810) +- **Dashboard** Dashboard links do not update when time range changes [#14493](https://github.com/grafana/grafana/issues/14493) +- **Limits** Support more than 1000 data sources per org [#13883](https://github.com/grafana/grafana/issues/13883) +- **Backend** fix signed in user for orgId=0 result should return active org id [#14574](https://github.com/grafana/grafana/pull/14574) +- **Provisioning** Adds orgId to user dto for provisioned dashboards [#14678](https://github.com/grafana/grafana/pull/14678) + +# 5.4.2 (2018-12-13) + +- **Data Source admin**: Fix for issue creating new data source when same name exists [#14467](https://github.com/grafana/grafana/issues/14467) +- **OAuth**: Fix for oauth auto login setting, can now be set using env variable [#14435](https://github.com/grafana/grafana/issues/14435) +- **Dashboard search**: Fix for searching tags in tags filter dropdown. + +# 5.4.1 (2018-12-10) + +- **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262) +- **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999). +- **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319) +- TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti) +- **Plugins**: Panel plugins should no receive the panel-initialized event again as usual. +- **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284) +- **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313](https://github.com/grafana/grafana/pull/14313) +- **Quotas**: Fixed for updating org & user quotas. [#14347](https://github.com/grafana/grafana/pull/14347), thx [#moznion](https://github.com/moznion) +- **Cloudwatch**: Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate to auto complete list. [#14401](https://github.com/grafana/grafana/pull/14401), thx [@sglajchEG](https://github.com/sglajchEG) +- **Dashboard Search**: Fixed filtering by tag issues. +- **Graph**: Fixed time region issues, [#14425](https://github.com/grafana/grafana/issues/14425), [#14280](https://github.com/grafana/grafana/issues/14280) +- **Graph**: Fixed issue with series color picker popover being placed outside window. + +# 5.4.0 (2018-12-03) + +- **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150) +- **Cloudwatch**: AWS/CodeBuild metrics and dimensions [#14167](https://github.com/grafana/grafana/issues/14167), thx [@mmcoltman](https://github.com/mmcoltman) +- **MySQL**: Fix `$__timeFrom()` and `$__timeTo()` should respect local time zone [#14228](https://github.com/grafana/grafana/issues/14228) + +### 5.4.0-beta1 fixes + +- **Graph**: Fix legend always visible even if configured to be hidden [#14144](https://github.com/grafana/grafana/issues/14144) +- **Elasticsearch**: Fix regression when using data source version 6.0+ and alerting [#14175](https://github.com/grafana/grafana/pull/14175) + +# 5.4.0-beta1 (2018-11-20) + +### New Features + +- **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202) +- **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) +- **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) +- **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm) +- **MySQL**: Support connecting thru Unix socket for MySQL data source [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +- **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) +- **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) +- **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550) +- **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930) +- **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller) +- **Stackdriver**: Template query editor [#13561](https://github.com/grafana/grafana/issues/13561) + +### Minor + +- **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) +- **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) +- **Cloudwatch**: CloudHSM metrics and dimensions [#14129](https://github.com/grafana/grafana/pull/14129), thx [@daktari](https://github.com/daktari) +- **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) +- **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367) +- **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977) +- **Graph**: Render dots when no connecting line can be made [#13605](https://github.com/grafana/grafana/issues/13605), thx [@jsferrei](https://github.com/jsferrei) +- **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme) +- **Singlestat**: Fix XSS in prefix/postfix [#13946](https://github.com/grafana/grafana/issues/13946), thx [@cinaglia](https://github.com/cinaglia) +- **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) +- **Alerting**: Increase default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945) +- **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov) +- **Alerting**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +- **Alerting**: Increase Telegram captions length limit [#13876](https://github.com/grafana/grafana/pull/13876), thx [@skgsergio](https://github.com/skgsergio) +- **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876) +- **Data Source Proxy**: Keep trailing slash for data source proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +- **OAuth**: Fix Google OAuth relies on email, not google account id [#13924](https://github.com/grafana/grafana/issues/13924), thx [@vinicyusmacedo](https://github.com/vinicyusmacedo) +- **Dashboard**: Toggle legend using keyboard shortcut [#13655](https://github.com/grafana/grafana/issues/13655), thx [@davewat](https://github.com/davewat) +- **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry) +- **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425) +- **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru) +- **HTTP API**: Support retrieving teams by user [#14120](https://github.com/grafana/grafana/pull/14120), thx [@supercharlesliu](https://github.com/supercharlesliu) +- **Metrics**: Add basic authentication to metrics endpoint [#13577](https://github.com/grafana/grafana/issues/13577), thx [@bobmshannon](https://github.com/bobmshannon) + +### Breaking changes + +- Postgres/MySQL/MSSQL data sources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited). + +# 5.3.4 (2018-11-13) + +- **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322) +- **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769) +- **Dashboard**: Fix data source selection in panel by enter key [#13932](https://github.com/grafana/grafana/issues/13932) +- **Graph**: Fix table legend height when positioned below graph and using Internet Explorer 11 [#13903](https://github.com/grafana/grafana/issues/13903) +- **Dataproxy**: Drop origin and referer http headers [#13328](https://github.com/grafana/grafana/issues/13328) [#13949](https://github.com/grafana/grafana/issues/13949), thx [@roidelapluie](https://github.com/roidelapluie) + +# 5.3.3 (2018-11-13) + +### File Exfiltration vulnerability Security fix + +See [security announcement](https://community.grafana.com/t/grafana-5-3-3-and-4-6-5-security-update/11961) for details. + +# 5.3.2 (2018-10-24) + +- **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres**: Fix template variables error [#13692](https://github.com/grafana/grafana/issues/13692), thx [@svenklemm](https://github.com/svenklemm) +- **Cloudwatch**: Fix service panic because of race conditions [#13674](https://github.com/grafana/grafana/issues/13674), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Fix check for invalid percentile statistics [#13633](https://github.com/grafana/grafana/issues/13633), thx [@apalaniuk](https://github.com/apalaniuk) +- **Stackdriver/Cloudwatch**: Allow user to change unit in graph panel if cloudwatch/stackdriver data source response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda) +- **Stackdriver**: stackdriver user-metrics duplicated response when multiple resource types [#13691](https://github.com/grafana/grafana/issues/13691) +- **Variables**: Fix text box template variable doesn't work properly without a default value [#13666](https://github.com/grafana/grafana/issues/13666) +- **Variables**: Fix variable dependency check when using `${var}` format [#13600](https://github.com/grafana/grafana/issues/13600) +- **Dashboard**: Fix kiosk=1 url parameter should put dashboard in kiosk mode [#13764](https://github.com/grafana/grafana/pull/13764) +- **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f) +- **Provisioning**: Fix deleting provisioned dashboard folder should cleanup provisioning meta data [#13280](https://github.com/grafana/grafana/issues/13280) + +### Minor + +- **Docker**: adds curl back into the docker image for utility. [#13794](https://github.com/grafana/grafana/pull/13794) + +# 5.3.1 (2018-10-16) + +- **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616) +- **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607) +- **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +- **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553) +- **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628) +- **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky) +- **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650) +- **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech) +- **HTTP API**: Fix /api/org/users so that query and limit querystrings works + +# 5.3.0 (2018-10-10) + +- **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495) +- **Stackdriver**: Support the distribution metric type for heatmaps [#13559](https://github.com/grafana/grafana/issues/13559) +- **Cloudwatch**: Automatically set graph yaxis unit [#13575](https://github.com/grafana/grafana/issues/13575), thx [@mtanda](https://github.com/mtanda) + +# 5.3.0-beta3 (2018-10-03) + +- **Stackdriver**: Fix for missing ngInject [#13511](https://github.com/grafana/grafana/pull/13511) +- **Permissions**: Fix for broken permissions selector [#13507](https://github.com/grafana/grafana/issues/13507) +- **Alerting**: Alert reminders deduping not working as expected when running multiple Grafana instances [#13492](https://github.com/grafana/grafana/issues/13492) + +# 5.3.0-beta2 (2018-10-01) + +### New Features + +- **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735) +- **Stackdriver**: Support for Google Stackdriver data source [#13289](https://github.com/grafana/grafana/pull/13289) + +### Minor + +- **Provisioning**: Dashboard Provisioning now support symlinks that changes target [#12534](https://github.com/grafana/grafana/issues/12534), thx [@auhlig](https://github.com/auhlig) +- **OAuth**: Allow oauth email attribute name to be configurable [#12986](https://github.com/grafana/grafana/issues/12986), thx [@bobmshannon](https://github.com/bobmshannon) +- **Tags**: Default sort order for GetDashboardTags [#11681](https://github.com/grafana/grafana/pull/11681), thx [@Jonnymcc](https://github.com/Jonnymcc) +- **Prometheus**: Label completion queries respect dashboard time range [#12251](https://github.com/grafana/grafana/pull/12251), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Allow to display annotations based on Prometheus series value [#10159](https://github.com/grafana/grafana/issues/10159), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Adhoc-filtering for Prometheus dashboards [#13212](https://github.com/grafana/grafana/issues/13212) +- **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon) +- **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030) +- **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173) +- **Alerting**: Link to view full size image in Microsoft Teams alert notifier [#13121](https://github.com/grafana/grafana/issues/13121), thx [@holiiveira](https://github.com/holiiveira) +- **Alerting**: Fixes a bug where all alerts would send reminders after upgrade & restart [#13402](https://github.com/grafana/grafana/pull/13402) +- **Alerting**: Concurrent render limit for graphs used in notifications [#13401](https://github.com/grafana/grafana/pull/13401) +- **Postgres/MySQL/MSSQL**: Add support for replacing $__interval and $\_\_interval_ms in alert queries [#11555](https://github.com/grafana/grafana/issues/11555), thx [@svenklemm](https://github.com/svenklemm) + +# 5.3.0-beta1 (2018-09-06) + +### New Major Features + +- **Alerting**: Notification reminders [#7330](https://github.com/grafana/grafana/issues/7330), thx [@jbaublitz](https://github.com/jbaublitz) +- **Dashboard**: TV & Kiosk mode changes, new cycle view mode button in dashboard toolbar [#13025](https://github.com/grafana/grafana/pull/13025) +- **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht) +- **Postgres**: Graphical query builder [#10095](https://github.com/grafana/grafana/issues/10095), thx [svenklemm](https://github.com/svenklemm) + +### New Features + +- **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622) +- **LDAP**: Client certificates support [#12805](https://github.com/grafana/grafana/issues/12805), thx [@nyxi](https://github.com/nyxi) +- **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476) +- **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos) +- **Dataproxy**: Pass configured/auth headers to a data source [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano) +- **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm) +- **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon) + +### Minor + +- **Alerting**: Its now possible to configure the default value for how to handle errors and no data in alerting. [#10424](https://github.com/grafana/grafana/issues/10424) +- **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane) +- **Alerting**: Fix rendering timeout which could cause notifications to not be sent due to rendering timing out [#12151](https://github.com/grafana/grafana/issues/12151) +- **Docker**: Make it possible to set a specific plugin url [#12861](https://github.com/grafana/grafana/pull/12861), thx [ClementGautier](https://github.com/ClementGautier) +- **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) +- **Provisioning**: Should allow one default data source per organization [#12229](https://github.com/grafana/grafana/issues/12229) +- **GitHub OAuth**: Allow changes of user info at GitHub to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber) +- **OAuth**: Fix overriding tls_skip_verify_insecure using environment variable [#12747](https://github.com/grafana/grafana/issues/12747), thx [@jangaraj](https://github.com/jangaraj) +- **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +- **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484) +- **Prometheus**: Add $__interval, $**interval_ms, \$**range, $__range_s & $\_\_range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597) [#12882](https://github.com/grafana/grafana/issues/12882), thx [@roidelapluie](https://github.com/roidelapluie) +- **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731) +- **Graphite**: Fix for quoting of int function parameters (when using variables) [#11927](https://github.com/grafana/grafana/pull/11927) +- **InfluxDB**: Support timeFilter in query templating for InfluxDB [#12598](https://github.com/grafana/grafana/pull/12598), thx [kichristensen](https://github.com/kichristensen) +- **Postgres/MySQL/MSSQL**: New $__unixEpochGroup and $\_\_unixEpochGroupAlias macros [#12892](https://github.com/grafana/grafana/issues/12892), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Add previous fill mode to \$\_\_timeGroup macro which will fill in previously seen value when point is missing [#12756](https://github.com/grafana/grafana/issues/12756), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Use floor rounding in \$\_\_timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Use metric column as prefix when returning multiple value columns [#12727](https://github.com/grafana/grafana/issues/12727), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: New $__timeGroupAlias macro. Postgres $\_\_timeGroup no longer automatically adds time column alias [#12749](https://github.com/grafana/grafana/issues/12749), thx [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Escape single quotes in variables [#12785](https://github.com/grafana/grafana/issues/12785), thx [@eMerzh](https://github.com/eMerzh) +- **Postgres/MySQL/MSSQL**: Min time interval support [#13157](https://github.com/grafana/grafana/issues/13157), thx [@svenklemm](https://github.com/svenklemm) +- **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $**timeFrom and \$**timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +- **Postgres**: Escape ssl mode parameter in connectionstring [#12644](https://github.com/grafana/grafana/issues/12644), thx [@yogyrahmawan](https://github.com/yogyrahmawan) +- **Cloudwatch**: Improved error handling [#12489](https://github.com/grafana/grafana/issues/12489), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: AppSync metrics and dimensions [#12300](https://github.com/grafana/grafana/issues/12300), thx [@franciscocpg](https://github.com/franciscocpg) +- **Cloudwatch**: Direct Connect metrics and dimensions [#12762](https://github.com/grafana/grafana/pulls/12762), thx [@mindriot88](https://github.com/mindriot88) +- **Cloudwatch**: Added BurstBalance metric to list of AWS RDS metrics [#12561](https://github.com/grafana/grafana/pulls/12561), thx [@activeshadow](https://github.com/activeshadow) +- **Cloudwatch**: Add new Redshift metrics and dimensions [#12063](https://github.com/grafana/grafana/pulls/12063), thx [@A21z](https://github.com/A21z) +- **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248) +- **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705) +- **Graph**: Option to hide series from tooltip [#3341](https://github.com/grafana/grafana/issues/3341), thx [@mtanda](https://github.com/mtanda) +- **Singlestat**: Make colorization of prefix and postfix optional in singlestat [#11892](https://github.com/grafana/grafana/pull/11892), thx [@ApsOps](https://github.com/ApsOps) +- **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668) +- **Table**: Fix link color when using light theme and thresholds in use [#12766](https://github.com/grafana/grafana/issues/12766) +- **Table**: Fix for useless horizontal scrollbar for table panel [#9964](https://github.com/grafana/grafana/issues/9964) +- **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2) +- **Heatmap**: Fix broken tooltip and crosshair on Firefox [#12486](https://github.com/grafana/grafana/issues/12486) +- **Data Source**: Fix UI issue with secret fields after updating data source [#11270](https://github.com/grafana/grafana/issues/11270) +- **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda) +- **Variables**: Limit amount of queries executed when updating variable that other variable(s) are dependent on [#11890](https://github.com/grafana/grafana/issues/11890) +- **Variables**: Support query variable refresh when another variable referenced in `Regex` field change its value [#12952](https://github.com/grafana/grafana/issues/12952), thx [@franciscocpg](https://github.com/franciscocpg) +- **Variables**: Support variables in query variable `Custom all value` field [#12965](https://github.com/grafana/grafana/issues/12965), thx [@franciscocpg](https://github.com/franciscocpg) +- **Units**: Change units to include characters for power of 2 and 3 [#12744](https://github.com/grafana/grafana/pull/12744), thx [@Worty](https://github.com/Worty) +- **Units**: Polish złoty currency [#12691](https://github.com/grafana/grafana/pull/12691), thx [@mwegrzynek](https://github.com/mwegrzynek) +- **Units**: Adds bitcoin axes unit. [#13125](https://github.com/grafana/grafana/pull/13125) +- **Api**: Delete nonexistent data source should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley) +- **Logging**: Reopen log files after receiving a SIGHUP signal [#13112](https://github.com/grafana/grafana/pull/13112), thx [@filewalkwithme](https://github.com/filewalkwithme) +- **Login**: Show loading animation while waiting for authentication response on login [#12865](https://github.com/grafana/grafana/issues/12865) +- **UI**: Fix iOS home screen "app" icon and Windows 10 app experience [#12752](https://github.com/grafana/grafana/issues/12752), thx [@andig](https://github.com/andig) +- **Plugins**: Convert URL-like text to links in plugins readme [#12843](https://github.com/grafana/grafana/pull/12843), thx [pgiraud](https://github.com/pgiraud) + +### Breaking changes + +- Postgres data source no longer automatically adds time column alias when using the \$\_\_timeGroup alias. However, there's code in place which should make this change backward compatible and shouldn't create any issues. +- Kiosk mode now also hides submenu (variables) +- ?inactive url parameter no longer supported, replaced with kiosk=tv url parameter + +### New experimental features + +These are new features that's still being worked on and are in an experimental phase. We encourage users to try these out and provide any feedback in related issue. + +- **Dashboard**: Auto fit dashboard panels to optimize space used for current TV / Monitor [#12768](https://github.com/grafana/grafana/issues/12768) + +### Tech + +- **Frontend**: Convert all Frontend Karma tests to Jest tests [#12224](https://github.com/grafana/grafana/issues/12224) +- **Backend**: Upgrade to golang 1.11 [#13030](https://github.com/grafana/grafana/issues/13030) + +# 5.2.4 (2018-09-07) + +- **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) + +# 5.2.3 (2018-08-29) + +### Important fix for LDAP & OAuth login vulnerability + +See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details. + +# 5.2.2 (2018-07-25) + +### Minor + +- **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) +- **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506) +- **Postgres/MySQL/MSSQL**: Fix connection leak [#12636](https://github.com/grafana/grafana/issues/12636) [#9827](https://github.com/grafana/grafana/issues/9827) +- **Plugins**: Fix loading of external plugins [#12551](https://github.com/grafana/grafana/issues/12551) +- **Dashboard**: Remove unwanted scrollbars in embedded panels [#12589](https://github.com/grafana/grafana/issues/12589) +- **Prometheus**: Prevent error using \$\_\_interval_ms in query [#12533](https://github.com/grafana/grafana/pull/12533), thx [@mtanda](https://github.com/mtanda) + +# 5.2.1 (2018-06-29) + +### Minor + +- **Auth Proxy**: Important security fix for whitelist of IP address feature [#12444](https://github.com/grafana/grafana/pull/12444) +- **UI**: Fix - Grafana footer overlapping page [#12430](https://github.com/grafana/grafana/issues/12430) +- **Logging**: Errors should be reported before crashing [#12438](https://github.com/grafana/grafana/issues/12438) + +# 5.2.0-stable (2018-06-27) + +### Minor + +- **Plugins**: Handle errors correctly when loading data source plugin [#12383](https://github.com/grafana/grafana/pull/12383) thx [@rozetko](https://github.com/rozetko) +- **Render**: Enhance error message if phantomjs executable is not found [#11868](https://github.com/grafana/grafana/issues/11868) +- **Dashboard**: Set correct text in drop down when variable is present in url [#11968](https://github.com/grafana/grafana/issues/11968) + +### 5.2.0-beta3 fixes + +- **LDAP**: Handle "dn" ldap attribute more gracefully [#12385](https://github.com/grafana/grafana/pull/12385), reverts [#10970](https://github.com/grafana/grafana/pull/10970) + +# 5.2.0-beta3 (2018-06-21) + +### Minor + +- **Build**: All rpm packages should be signed [#12359](https://github.com/grafana/grafana/issues/12359) + +# 5.2.0-beta2 (2018-06-20) + +### New Features + +- **Dashboard**: Import dashboard to folder [#10796](https://github.com/grafana/grafana/issues/10796) + +### Minor + +- **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) +- **Dashboard**: Fix so panel titles doesn't wrap [#11074](https://github.com/grafana/grafana/issues/11074) +- **Dashboard**: Prevent double-click when saving dashboard [#11963](https://github.com/grafana/grafana/issues/11963) +- **Dashboard**: AutoFocus the add-panel search filter [#12189](https://github.com/grafana/grafana/pull/12189) thx [@ryantxu](https://github.com/ryantxu) +- **Units**: W/m2 (energy), l/h (flow) and kPa (pressure) [#11233](https://github.com/grafana/grafana/pull/11233), thx [@flopp999](https://github.com/flopp999) +- **Units**: Liter/min (flow) and milliLiter/min (flow) [#12282](https://github.com/grafana/grafana/pull/12282), thx [@flopp999](https://github.com/flopp999) +- **Alerting**: Fix mobile notifications for Microsoft Teams alert notifier [#11484](https://github.com/grafana/grafana/pull/11484), thx [@manacker](https://github.com/manacker) +- **Influxdb**: Add support for mode function [#12286](https://github.com/grafana/grafana/issues/12286) +- **Cloudwatch**: Fixes panic caused by bad timerange settings [#12199](https://github.com/grafana/grafana/issues/12199) +- **Auth Proxy**: Whitelist proxy IP address instead of client IP address [#10707](https://github.com/grafana/grafana/issues/10707) +- **User Management**: Make sure that a user always has a current org assigned [#11076](https://github.com/grafana/grafana/issues/11076) +- **Snapshots**: Fix: annotations not properly extracted leading to incorrect rendering of annotations [#12278](https://github.com/grafana/grafana/issues/12278) +- **LDAP**: Allow use of DN in group_search_filter_user_attribute and member_of [#3132](https://github.com/grafana/grafana/issues/3132), thx [@mmolnar](https://github.com/mmolnar) +- **Graph**: Fix legend decimals precision calculation [#11792](https://github.com/grafana/grafana/issues/11792) +- **Dashboard**: Make sure to process panels in collapsed rows when exporting dashboard [#12256](https://github.com/grafana/grafana/issues/12256) + +### 5.2.0-beta1 fixes + +- **Dashboard**: Dashboard link doesn't work when "As dropdown" option is checked [#12315](https://github.com/grafana/grafana/issues/12315) +- **Dashboard**: Fix regressions after save modal changes, including adhoc template issues [#12240](https://github.com/grafana/grafana/issues/12240) +- **Docker**: Config keys ending with \_FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) + +# 5.2.0-beta1 (2018-06-05) + +### New Features + +- **Elasticsearch**: Alerting support [#5893](https://github.com/grafana/grafana/issues/5893), thx [@WPH95](https://github.com/WPH95) +- **Build**: Crosscompile and packages Grafana on arm, windows, linux and darwin [#11920](https://github.com/grafana/grafana/pull/11920), thx [@fg2it](https://github.com/fg2it) +- **Login**: Change admin password after first login [#11882](https://github.com/grafana/grafana/issues/11882) +- **Alert list panel**: Updated to support filtering alerts by name, dashboard title, folder, tags [#11500](https://github.com/grafana/grafana/issues/11500), [#8168](https://github.com/grafana/grafana/issues/8168), [#6541](https://github.com/grafana/grafana/issues/6541) + +### Minor + +- **Dashboard**: Modified time range and variables are now not saved by default [#10748](https://github.com/grafana/grafana/issues/10748), [#8805](https://github.com/grafana/grafana/issues/8805) +- **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498) +- **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625) +- **Prometheus**: Query dates are now step-aligned [#10434](https://github.com/grafana/grafana/pull/10434) +- **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda) +- **Variables**: Fix variable interpolation when using multiple formatting types [#11800](https://github.com/grafana/grafana/issues/11800), thx [@svenklemm](https://github.com/svenklemm) +- **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616) +- **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel), +- **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013) +- **InfluxDB**: Support count distinct aggregation [#11645](https://github.com/grafana/grafana/issues/11645), thx [@kichristensen](https://github.com/kichristensen) +- **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel) +- **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813) +- **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338) +- **Prometheus**: Add support for passing timeout parameter to Prometheus [#11788](https://github.com/grafana/grafana/pull/11788), thx [@mtanda](https://github.com/mtanda) +- **Login**: Add optional option sign out url for generic oauth [#9847](https://github.com/grafana/grafana/issues/9847), thx [@roidelapluie](https://github.com/roidelapluie) +- **Login**: Use proxy server from environment variable if available [#9703](https://github.com/grafana/grafana/issues/9703), thx [@iyeonok](https://github.com/iyeonok) +- **Invite users**: Friendlier error message when smtp is not configured [#12087](https://github.com/grafana/grafana/issues/12087), thx [@thurt](https://github.com/thurt) +- **Graphite**: Don't send distributed tracing headers when using direct/browser access mode [#11494](https://github.com/grafana/grafana/issues/11494) +- **Sidenav**: Show create dashboard link for viewers if at least editor in one folder [#11858](https://github.com/grafana/grafana/issues/11858) +- **SQL**: Second epochs are now correctly converted to ms. [#12085](https://github.com/grafana/grafana/pull/12085) +- **Singlestat**: Fix singlestat threshold tooltip [#11971](https://github.com/grafana/grafana/issues/11971) +- **Dashboard**: Hide grid controls in fullscreen/low-activity views [#11771](https://github.com/grafana/grafana/issues/11771) +- **Dashboard**: Validate uid when importing dashboards [#11515](https://github.com/grafana/grafana/issues/11515) +- **Docker**: Support for env variables ending with \_FILE [grafana-docker #166](https://github.com/grafana/grafana-docker/pull/166), thx [@efrecon](https://github.com/efrecon) +- **Alert list panel**: Show alerts for user with viewer role [#11167](https://github.com/grafana/grafana/issues/11167) +- **Provisioning**: Verify checksum of dashboards before updating to reduce load on database [#11670](https://github.com/grafana/grafana/issues/11670) +- **Provisioning**: Support symlinked files in dashboard provisioning config files [#11958](https://github.com/grafana/grafana/issues/11958) +- **Dashboard list panel**: Search dashboards by folder [#11525](https://github.com/grafana/grafana/issues/11525) +- **Sidenav**: Always show server admin link in sidenav if grafana admin [#11657](https://github.com/grafana/grafana/issues/11657) + +# 5.1.5 (2018-06-27) + +- **Docker**: Config keys ending with \_FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170) + +# 5.1.4 (2018-06-19) + +- **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343) + +# 5.1.3 (2018-05-16) + +- **Scroll**: Graph panel / legend texts shifts on the left each time we move scrollbar on firefox [#11830](https://github.com/grafana/grafana/issues/11830) + +# 5.1.2 (2018-05-09) + +- **Database**: Fix MySql migration issue [#11862](https://github.com/grafana/grafana/issues/11862) +- **Google Analytics**: Enable Google Analytics anonymizeIP setting for GDPR [#11656](https://github.com/grafana/grafana/pull/11656) + +# 5.1.1 (2018-05-07) + +- **LDAP**: LDAP login with MariaDB/MySQL database and dn>100 chars not possible [#11754](https://github.com/grafana/grafana/issues/11754) +- **Build**: AppVeyor Windows build missing version and commit info [#11758](https://github.com/grafana/grafana/issues/11758) +- **Scroll**: Scroll can't start in graphs on Chrome mobile [#11710](https://github.com/grafana/grafana/issues/11710) +- **Units**: Revert renaming of unit key ppm [#11743](https://github.com/grafana/grafana/issues/11743) + +# 5.1.0 (2018-04-26) + +- **Folders**: Default permissions on folder are not shown as inherited in its dashboards [#11668](https://github.com/grafana/grafana/issues/11668) +- **Templating**: Allow more than 20 previews when creating a variable [#11508](https://github.com/grafana/grafana/issues/11508) +- **Dashboard**: Row edit icon not shown [#11466](https://github.com/grafana/grafana/issues/11466) +- **SQL**: Unsupported data types for value column using time series query [#11703](https://github.com/grafana/grafana/issues/11703) +- **Prometheus**: Prometheus query inspector expands to be very large on autocomplete queries [#11673](https://github.com/grafana/grafana/issues/11673) + +# 5.1.0-beta1 (2018-04-20) + +- **MSSQL**: New Microsoft SQL Server data source [#10093](https://github.com/grafana/grafana/pull/10093), [#11298](https://github.com/grafana/grafana/pull/11298), thx [@linuxchips](https://github.com/linuxchips) +- **Prometheus**: The heatmap panel now support Prometheus histograms [#10009](https://github.com/grafana/grafana/issues/10009) +- **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm) +- **Postgres/MySQL/MSSQL**: Fix precision for the time column in table mode [#11306](https://github.com/grafana/grafana/issues/11306) +- **Graph**: Align left and right Y-axes to one level [#1271](https://github.com/grafana/grafana/issues/1271) & [#2740](https://github.com/grafana/grafana/issues/2740) thx [@ilgizar](https://github.com/ilgizar) +- **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar) +- **Graph**: Support multiple series stacking in histogram mode [#8151](https://github.com/grafana/grafana/issues/8151), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942) +- **Alerting**: Support Pagerduty notification channel using Pagerduty V2 API [#10531](https://github.com/grafana/grafana/issues/10531), thx [@jbaublitz](https://github.com/jbaublitz) +- **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Show template variable candidate in query editor [#9210](https://github.com/grafana/grafana/issues/9210), thx [@mtanda](https://github.com/mtanda) +- **Prometheus**: Support POST for query and query_range [#9859](https://github.com/grafana/grafana/pull/9859), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Add support for retries on alert queries [#5855](https://github.com/grafana/grafana/issues/5855), thx [@Thib17](https://github.com/Thib17) +- **Table**: Table plugin value mappings [#7119](https://github.com/grafana/grafana/issues/7119), thx [infernix](https://github.com/infernix) +- **IE11**: IE 11 compatibility [#11165](https://github.com/grafana/grafana/issues/11165) +- **Scrolling**: Better scrolling experience [#11053](https://github.com/grafana/grafana/issues/11053), [#11252](https://github.com/grafana/grafana/issues/11252), [#10836](https://github.com/grafana/grafana/issues/10836), [#11185](https://github.com/grafana/grafana/issues/11185), [#11168](https://github.com/grafana/grafana/issues/11168) +- **Docker**: Improved docker image (breaking changes regarding file ownership) [grafana-docker #141](https://github.com/grafana/grafana-docker/issues/141), thx [@Spindel](https://github.com/Spindel), [@ChristianKniep](https://github.com/ChristianKniep), [@brancz](https://github.com/brancz) and [@jangaraj](https://github.com/jangaraj) +- **Folders**: A folder admin cannot add user/team permissions for folder/its dashboards [#11173](https://github.com/grafana/grafana/issues/11173) +- **Provisioning**: Improved workflow for provisioned dashboards [#10883](https://github.com/grafana/grafana/issues/10883) + +### Minor + +- **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes) +- **Cloudwatch**: Support high resolution metrics [#10925](https://github.com/grafana/grafana/pull/10925), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add dimension filtering to CloudWatch `dimension_values()` [#10029](https://github.com/grafana/grafana/issues/10029), thx [@willyhutw](https://github.com/willyhutw) +- **Units**: Second to HH:mm:ss formatter [#11107](https://github.com/grafana/grafana/issues/11107), thx [@gladdiologist](https://github.com/gladdiologist) +- **Singlestat**: Add color to prefix and postfix in singlestat panel [#11143](https://github.com/grafana/grafana/pull/11143), thx [@ApsOps](https://github.com/ApsOps) +- **Dashboards**: Version cleanup fails on old databases with many entries [#11278](https://github.com/grafana/grafana/issues/11278) +- **Server**: Adjust permissions of unix socket [#11343](https://github.com/grafana/grafana/pull/11343), thx [@corny](https://github.com/corny) +- **Shortcuts**: Add shortcut for duplicate panel [#11102](https://github.com/grafana/grafana/issues/11102) +- **AuthProxy**: Support IPv6 in Auth proxy white list [#11330](https://github.com/grafana/grafana/pull/11330), thx [@corny](https://github.com/corny) +- **SMTP**: Don't connect to STMP server using TLS unless configured. [#7189](https://github.com/grafana/grafana/issues/7189) +- **Prometheus**: Escape backslash in labels correctly. [#10555](https://github.com/grafana/grafana/issues/10555), thx [@roidelapluie](https://github.com/roidelapluie) +- **Variables**: Case-insensitive sorting for template values [#11128](https://github.com/grafana/grafana/issues/11128) thx [@cross](https://github.com/cross) +- **Annotations (native)**: Change default limit from 10 to 100 when querying api [#11569](https://github.com/grafana/grafana/issues/11569), thx [@flopp999](https://github.com/flopp999) +- **MySQL/Postgres/MSSQL**: PostgreSQL data source generates invalid query with dates before 1970 [#11530](https://github.com/grafana/grafana/issues/11530) thx [@ryantxu](https://github.com/ryantxu) +- **Kiosk**: Adds url parameter for starting a dashboard in inactive mode [#11228](https://github.com/grafana/grafana/issues/11228), thx [@towolf](https://github.com/towolf) +- **Dashboard**: Enable closing timepicker using escape key [#11332](https://github.com/grafana/grafana/issues/11332) +- **Data Sources**: Rename direct access mode in the data source settings [#11391](https://github.com/grafana/grafana/issues/11391) +- **Search**: Display dashboards in folder indented [#11073](https://github.com/grafana/grafana/issues/11073) +- **Units**: Use B/s instead Bps for Bytes per second [#9342](https://github.com/grafana/grafana/pull/9342), thx [@mayli](https://github.com/mayli) +- **Units**: Radiation units [#11001](https://github.com/grafana/grafana/issues/11001), thx [@victorclaessen](https://github.com/victorclaessen) +- **Units**: Timeticks unit [#11183](https://github.com/grafana/grafana/pull/11183), thx [@jtyr](https://github.com/jtyr) +- **Units**: Concentration units and "Normal cubic meter" [#11211](https://github.com/grafana/grafana/issues/11211), thx [@flopp999](https://github.com/flopp999) +- **Units**: New currency - Czech koruna [#11384](https://github.com/grafana/grafana/pull/11384), thx [@Rohlik](https://github.com/Rohlik) +- **Avatar**: Fix DISABLE_GRAVATAR option [#11095](https://github.com/grafana/grafana/issues/11095) +- **Heatmap**: Disable log scale when using time time series buckets [#10792](https://github.com/grafana/grafana/issues/10792) +- **Provisioning**: Remove `id` from json when provisioning dashboards, [#11138](https://github.com/grafana/grafana/issues/11138) +- **Prometheus**: tooltip for legend format not showing properly [#11516](https://github.com/grafana/grafana/issues/11516), thx [@svenklemm](https://github.com/svenklemm) +- **Playlist**: Empty playlists cannot be deleted [#11133](https://github.com/grafana/grafana/issues/11133), thx [@kichristensen](https://github.com/kichristensen) +- **Switch Orgs**: Alphabetic order in Switch Organization modal [#11556](https://github.com/grafana/grafana/issues/11556) +- **Postgres**: improve `$__timeFilter` macro [#11578](https://github.com/grafana/grafana/issues/11578), thx [@svenklemm](https://github.com/svenklemm) +- **Permission list**: Improved ux [#10747](https://github.com/grafana/grafana/issues/10747) +- **Dashboard**: Sizing and positioning of settings menu icons [#11572](https://github.com/grafana/grafana/pull/11572) +- **Dashboard**: Add search filter/tabs to new panel control [#10427](https://github.com/grafana/grafana/issues/10427) +- **Folders**: User with org viewer role should not be able to save/move dashboards in/to general folder [#11553](https://github.com/grafana/grafana/issues/11553) +- **Influxdb**: Don't assume the first column in table response is time. [#11476](https://github.com/grafana/grafana/issues/11476), thx [@hahnjo](https://github.com/hahnjo) + +### Tech + +- Backend code simplification [#11613](https://github.com/grafana/grafana/pull/11613), thx [@knweiss](https://github.com/knweiss) +- Add codespell to CI [#11602](https://github.com/grafana/grafana/pull/11602), thx [@mjtrangoni](https://github.com/mjtrangoni) +- Migrated JavaScript files to TypeScript + +# 5.0.4 (2018-03-28) + +- **Docker** Can't start Grafana on Kubernetes 1.7.14, 1.8.9, or 1.9.4 [#140 in grafana-docker repo](https://github.com/grafana/grafana-docker/issues/140) thx [@suquant](https://github.com/suquant) +- **Dashboard** Fixed bug where collapsed panels could not be directly linked to/renderer [#11114](https://github.com/grafana/grafana/issues/11114) & [#11086](https://github.com/grafana/grafana/issues/11086) & [#11296](https://github.com/grafana/grafana/issues/11296) +- **Dashboard** Provisioning dashboard with alert rules should create alerts [#11247](https://github.com/grafana/grafana/issues/11247) +- **Snapshots** For snapshots, the Graph panel renders the legend incorrectly on right hand side [#11318](https://github.com/grafana/grafana/issues/11318) +- **Alerting** Link back to Grafana returns wrong URL if root_path contains sub-path components [#11403](https://github.com/grafana/grafana/issues/11403) +- **Alerting** Incorrect default value for upload images setting for alert notifiers [#11413](https://github.com/grafana/grafana/pull/11413) + +# 5.0.3 (2018-03-16) + +- **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access (a bigger patch than the one in 5.0.2) [#11155](https://github.com/grafana/grafana/issues/11155) + +# 5.0.2 (2018-03-14) + +- **Mysql**: Mysql panic occurring occasionally upon Grafana dashboard access [#11155](https://github.com/grafana/grafana/issues/11155) +- **Dashboards**: Should be possible to browse dashboard using only uid [#11231](https://github.com/grafana/grafana/issues/11231) +- **Alerting**: Fixes bug where alerts from hidden panels where deleted [#11222](https://github.com/grafana/grafana/issues/11222) +- **Import**: Fixes bug where dashboards with alerts couldn't be imported [#11227](https://github.com/grafana/grafana/issues/11227) +- **Teams**: Remove quota restrictions from teams [#11220](https://github.com/grafana/grafana/issues/11220) +- **Render**: Fixes bug with legacy url redirection for panel rendering [#11180](https://github.com/grafana/grafana/issues/11180) + +# 5.0.1 (2018-03-08) + +- **Postgres**: PostgreSQL error when using ipv6 address as hostname in connection string [#11055](https://github.com/grafana/grafana/issues/11055), thanks [@svenklemm](https://github.com/svenklemm) +- **Dashboards**: Changing templated value from dropdown is causing unsaved changes [#11063](https://github.com/grafana/grafana/issues/11063) +- **Prometheus**: Fixes bundled Prometheus 2.0 dashboard [#11016](https://github.com/grafana/grafana/issues/11016), thx [@roidelapluie](https://github.com/roidelapluie) +- **Sidemenu**: Profile menu "invisible" when gravatar is disabled [#11097](https://github.com/grafana/grafana/issues/11097) +- **Dashboard**: Fixes a bug with resizable handles for panels [#11103](https://github.com/grafana/grafana/issues/11103) +- **Alerting**: Telegram inline image mode fails when caption too long [#10975](https://github.com/grafana/grafana/issues/10975) +- **Alerting**: Fixes silent failing validation [#11145](https://github.com/grafana/grafana/pull/11145) +- **OAuth**: Only use jwt token if it contains an email address [#11127](https://github.com/grafana/grafana/pull/11127) + +# 5.0.0-stable (2018-03-01) + +### Fixes + +- **oauth** Fix GitHub OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick) +- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010) +- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker) + +# 5.0.0-beta5 (2018-02-26) + +### Fixes + +- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774) +- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630) +- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982) +- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124) +- **oauth** GitHub OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964) +- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793) +- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996) + +# 5.0.0-beta4 (2018-02-19) + +### Fixes + +- **Dashboard** Fixed dashboard overwrite permission issue [#10814](https://github.com/grafana/grafana/issues/10814) +- **Keyboard shortcuts** Fixed Esc key when in panel edit/view mode [#10945](https://github.com/grafana/grafana/issues/10945) +- **Save dashboard** Fixed issue with time range & variable reset after saving [#10946](https://github.com/grafana/grafana/issues/10946) + +# 5.0.0-beta3 (2018-02-16) + +### Fixes + +- **MySQL** Fixed new migration issue with index length [#10931](https://github.com/grafana/grafana/issues/10931) +- **Modal** Escape key no closes modals everywhere, fixes [#10887](https://github.com/grafana/grafana/issues/10887) +- **Row repeats** Fix for repeating rows issue, fixes [#10932](https://github.com/grafana/grafana/issues/10932) +- **Docs** Team api documented, fixes [#10832](https://github.com/grafana/grafana/issues/10832) +- **Plugins** Plugin info page broken, fixes [#10943](https://github.com/grafana/grafana/issues/10943) + +# 5.0.0-beta2 (2018-02-15) + +### Fixes + +- **Permissions** Fixed search permissions issues [#10822](https://github.com/grafana/grafana/issues/10822) +- **Permissions** Fixed problem issues displaying permissions lists [#10864](https://github.com/grafana/grafana/issues/10864) +- **PNG-Rendering** Fixed problem rendering legend to the right [#10526](https://github.com/grafana/grafana/issues/10526) +- **Reset password** Fixed problem with reset password form [#10870](https://github.com/grafana/grafana/issues/10870) +- **Light theme** Fixed problem with light theme in safari, [#10869](https://github.com/grafana/grafana/issues/10869) +- **Provisioning** Now handles deletes when dashboard json files removed from disk [#10865](https://github.com/grafana/grafana/issues/10865) +- **MySQL** Fixed issue with schema migration on old mysql (index too long) [#10779](https://github.com/grafana/grafana/issues/10779) +- **GitHub OAuth** Fixed fetching github orgs from private github org [#10823](https://github.com/grafana/grafana/issues/10823) +- **Embedding** Fixed issues embedding panel [#10787](https://github.com/grafana/grafana/issues/10787) + +# 5.0.0-beta1 (2018-02-05) + +Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5. + +### New Major Features + +- **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) +- **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. +- **Dashboard grid**: Panels are now laid out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). +- **Templating**: Vertical repeat direction for panel repeats. +- **UX**: Major update to page header and navigation +- **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) +- **Persistent dashboard url's**: New url's for dashboards that allows renaming dashboards without breaking links. [#7883](https://github.com/grafana/grafana/issues/7883) + +## Breaking changes + +- **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). + Config files for provisioning data sources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. + From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. + +- **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) + +- **HTTP API** + - `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url). + +## New Dashboard Grid + +The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. + +Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. + +## New Features + +- **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM) +- **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) +- **Postgres/MySQL**: add \_\_timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm) +- **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda) +- **Teams**: Add Microsoft Teams notifier as [#8523](https://github.com/grafana/grafana/issues/8523), thx [@anthu](https://github.com/anthu) +- **Data Sources**: Its now possible to configure data sources with config files [#1789](https://github.com/grafana/grafana/issues/1789) +- **Graphite**: Query editor updated to support new query by tag features [#9230](https://github.com/grafana/grafana/issues/9230) +- **Dashboard history**: New config file option versions_to_keep sets how many versions per dashboard to store, [#9671](https://github.com/grafana/grafana/issues/9671) +- **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) +- **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) +- **Table**: Support multiple table formatted queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +- **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) + +## Minor + +- **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) +- **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) +- **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) +- **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) +- **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda) +- **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871) +- **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798) +- **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler) +- **GitHub**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx + [@adiletmaratov](https://github.com/adiletmaratov) +- **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131) +- **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada) +- **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell) + +## Fixes + +- **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) +- **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) +- **Pagerduty**: Pagerduty don't auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +- **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903) + +## Tech + +- **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) + +## Deprecation notes + +### HTTP API + +The following operations have been deprecated and will be removed in a future release: + +- `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead +- `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead + +The following properties have been deprecated and will be removed in a future release: + +- `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead +- `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead + +# 4.6.4 (2018-08-29) + +### Important fix for LDAP & OAuth login vulnerability + +See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details. + +# 4.6.3 (2017-12-14) + +## Fixes + +- **Gzip**: Fixes bug gravatar images when gzip was enabled [#5952](https://github.com/grafana/grafana/issues/5952) +- **Alert list**: Now shows alert state changes even after adding manual annotations on dashboard [#9951](https://github.com/grafana/grafana/issues/9951) +- **Alerting**: Fixes bug where rules evaluated as firing when all conditions was false and using OR operator. [#9318](https://github.com/grafana/grafana/issues/9318) +- **Cloudwatch**: CloudWatch no longer display metrics' default alias [#10151](https://github.com/grafana/grafana/issues/10151), thx [@mtanda](https://github.com/mtanda) + +# 4.6.2 (2017-11-16) + +## Important + +- **Prometheus**: Fixes bug with new prometheus alerts in Grafana. Make sure to download this version if you're using Prometheus for alerting. More details in the issue. [#9777](https://github.com/grafana/grafana/issues/9777) + +## Fixes + +- **Color picker**: Bug after using textbox input field to change/paste color string [#9769](https://github.com/grafana/grafana/issues/9769) +- **Cloudwatch**: Fix for cloudwatch templating query `ec2_instance_attribute` [#9667](https://github.com/grafana/grafana/issues/9667), thanks [@mtanda](https://github.com/mtanda) +- **Heatmap**: Fixed tooltip for "time series buckets" mode [#9332](https://github.com/grafana/grafana/issues/9332) +- **InfluxDB**: Fixed query editor issue when using `>` or `<` operators in WHERE clause [#9871](https://github.com/grafana/grafana/issues/9871) + +# 4.6.1 (2017-11-01) + +- **Singlestat**: Lost thresholds when using save dashboard as [#9681](https://github.com/grafana/grafana/issues/9681) +- **Graph**: Fix for series override color picker [#9715](https://github.com/grafana/grafana/issues/9715) +- **Go**: build using golang 1.9.2 [#9713](https://github.com/grafana/grafana/issues/9713) +- **Plugins**: Fixed problem with loading plugin js files behind auth proxy [#9509](https://github.com/grafana/grafana/issues/9509) +- **Graphite**: Annotation tooltip should render empty string when undefined [#9707](https://github.com/grafana/grafana/issues/9707) + +# 4.6.0 (2017-10-26) + +## Fixes + +- **Alerting**: Viewer can no longer pause alert rules [#9640](https://github.com/grafana/grafana/issues/9640) +- **Playlist**: Bug where playlist controls was missing [#9639](https://github.com/grafana/grafana/issues/9639) +- **Firefox**: Creating region annotations now work in firefox [#9638](https://github.com/grafana/grafana/issues/9638) + +# 4.6.0-beta3 (2017-10-23) + +## Fixes + +- **Prometheus**: Fix for browser crash for short time ranges. [#9575](https://github.com/grafana/grafana/issues/9575) +- **Heatmap**: Fix for y-axis not showing. [#9576](https://github.com/grafana/grafana/issues/9576) +- **Save to file**: Fix for save to file in export modal. [#9586](https://github.com/grafana/grafana/issues/9586) +- **Postgres**: modify group by time macro so it can be used in select clause [#9527](https://github.com/grafana/grafana/pull/9527), thanks [@svenklemm](https://github.com/svenklemm) + +# 4.6.0-beta2 (2017-10-17) + +## Fixes + +- **ColorPicker**: Fix for color picker not showing [#9549](https://github.com/grafana/grafana/issues/9549) +- **Alerting**: Fix for broken test rule button in alert tab [#9539](https://github.com/grafana/grafana/issues/9539) +- **Cloudwatch**: Provide error message when failing to add cloudwatch data source [#9534](https://github.com/grafana/grafana/pull/9534), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Fix unused period parameter [#9536](https://github.com/grafana/grafana/pull/9536), thx [@mtanda](https://github.com/mtanda) +- **CSV Export**: Fix for broken CSV export [#9525](https://github.com/grafana/grafana/issues/9525) +- **Text panel**: Fix for issue with break lines in Firefox [#9491](https://github.com/grafana/grafana/issues/9491) +- **Annotations**: Fix for issue saving annotation event in MySQL DB [#9550](https://github.com/grafana/grafana/issues/9550), thanks [@krise3k](https://github.com/krise3k) + +# 4.6.0-beta1 (2017-10-13) + +## New Features + +- **Annotations**: Add support for creating annotations from graph panel [#8197](https://github.com/grafana/grafana/pull/8197) +- **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +- **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +- **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +- **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +- **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +- **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +- **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add support for alerting using the cloudwatch data source [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +- **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +- **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +- **Prometheus**: Align \$\_\_interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +- **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +- **Data sources**: Make data source HTTP requests verify TLS by default. closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) +- **OAuth**: Verify TLS during OAuth callback [#9373](https://github.com/grafana/grafana/issues/9373), thx [@mattbostock](https://github.com/mattbostock) + +## Minor + +- **SMTP**: Make it possible to set specific HELO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) +- **Dataproxy**: Allow grafana to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250) +- **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367) +- **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +- **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +- **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +- **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +- **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +- **Kafka**: Add support for sending alert notifications to kafka [#7104](https://github.com/grafana/grafana/issues/7104), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **Alerting**: add count_non_null as series reducer [#9516](https://github.com/grafana/grafana/issues/9516) + +## Tech + +- **Go**: Grafana is now built using golang 1.9 +- **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins. + +# 4.5.2 (2017-09-22) + +## Fixes + +- **Graphite**: Fix for issues with jsonData & graphiteVersion null errors [#9258](https://github.com/grafana/grafana/issues/9258) +- **Graphite**: Fix for Grafana internal metrics to Graphite sending NaN values [#9279](https://github.com/grafana/grafana/issues/9279) +- **HTTP API**: Fix for HEAD method requests [#9307](https://github.com/grafana/grafana/issues/9307) +- **Templating**: Fix for duplicate template variable queries when refresh is set to time range change [#9185](https://github.com/grafana/grafana/issues/9185) +- **Metrics**: don't write NaN values to graphite [#9279](https://github.com/grafana/grafana/issues/9279) + +# 4.5.1 (2017-09-15) + +## Fixes + +- **MySQL**: Fixed issue with query editor not showing [#9247](https://github.com/grafana/grafana/issues/9247) + +## Breaking changes + +- **Metrics**: The metric structure for internal metrics about Grafana published to graphite has changed. This might break dashboards for internal metrics. + +# 4.5.0 (2017-09-14) + +## Fixes & Enhancements since beta1 + +- **Security**: Security fix for api vulnerability (in multiple org setups). +- **Shortcuts**: Adds shortcut for creating new dashboard [#8876](https://github.com/grafana/grafana/pull/8876) thx [@mtanda](https://github.com/mtanda) +- **Graph**: Right Y-Axis label position fixed [#9172](https://github.com/grafana/grafana/pull/9172) +- **General**: Improve rounding of time intervals [#9197](https://github.com/grafana/grafana/pull/9197), thx [@alin-amana](https://github.com/alin-amana) + +# 4.5.0-beta1 (2017-09-05) + +## New Features + +- **Table panel**: Render cell values as links that can have an url template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +- **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +- **MySQL**: New rich query editor with syntax highlighting +- **Prometheus**: New rich query editor with syntax highlighting, metric & range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) + +## Enhancements + +- **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) +- **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboard time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +- **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) + +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +- **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. +- **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formatted data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. + +## Changes + +- **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) +- **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) + +## Bug Fixes + +- **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +- **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) +- **MySQL/Postgres**: Fix for max_idle_conn option default which was wrongly set to zero which does not mean unlimited but means zero, which in practice kind of disables connection pooling, which is not good. Fixes [#8513](https://github.com/grafana/grafana/issues/8513) + +# 4.4.3 (2017-08-07) + +## Bug Fixes + +- **Search**: Fix for issue that caused search view to hide when you clicked starred or tags filters, fixes [#8981](https://github.com/grafana/grafana/issues/8981) +- **Modals**: ESC key now closes modal again, fixes [#8981](https://github.com/grafana/grafana/issues/8988), thx [@j-white](https://github.com/j-white) + +# 4.4.2 (2017-08-01) + +## Bug Fixes + +- **GrafanaDB(mysql)**: Fix for dashboard_version.data column type, now changed to MEDIUMTEXT, fixes [#8813](https://github.com/grafana/grafana/issues/8813) +- **Dashboard(settings)**: Closing setting views using ESC key did not update url correctly, fixes [#8869](https://github.com/grafana/grafana/issues/8869) +- **InfluxDB**: Wrong username/password parameter name when using direct access, fixes [#8789](https://github.com/grafana/grafana/issues/8789) +- **Forms(TextArea)**: Bug fix for no scroll in text areas [#8797](https://github.com/grafana/grafana/issues/8797) +- **Png Render API**: Bug fix for timeout url parameter. It now works as it should. Default value was also increased from 30 to 60 seconds [#8710](https://github.com/grafana/grafana/issues/8710) +- **Search**: Fix for not being able to close search by clicking on right side of search result container, [8848](https://github.com/grafana/grafana/issues/8848) +- **Cloudwatch**: Fix for using variables in templating metrics() query, [8965](https://github.com/grafana/grafana/issues/8965) + +## Changes + +- **Settings(defaults)**: allow_sign_up default changed from true to false [#8743](https://github.com/grafana/grafana/issues/8743) +- **Settings(defaults)**: allow_org_create default changed from true to false + +# 4.4.1 (2017-07-05) + +## Bug Fixes + +- **Migrations**: migration fails where dashboard.created_by is null [#8783](https://github.com/grafana/grafana/issues/8783) + +# 4.4.0 (2017-07-04) + +## New Features + +**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature +was contributed by **Walmart Labs**. Big thanks to them for this massive contribution! +Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638) +Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) + +## Enhancements + +- **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk) +- **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller) +- **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo) +- **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617) +- **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang) + +## Minor Enhancements + +- **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia) + +## Bug Fixes + +- **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628) + +# 4.3.2 (2017-05-31) + +## Bug fixes + +- **InfluxDB**: Fixed issue with query editor not showing ALIAS BY input field when in text editor mode [#8459](https://github.com/grafana/grafana/issues/8459) +- **Graph Log Scale**: Fixed issue with log scale going below x-axis [#8244](https://github.com/grafana/grafana/issues/8244) +- **Playlist**: Fixed dashboard play order issue [#7688](https://github.com/grafana/grafana/issues/7688) +- **Elasticsearch**: Fixed table query issue with ES 2.x [#8467](https://github.com/grafana/grafana/issues/8467), thx [@goldeelox](https://github.com/goldeelox) + +## Changes + +- **Lazy Loading Of Panels**: Panels are no longer loaded as they are scrolled into view, this was reverted due to Chrome bug, might be reintroduced when Chrome fixes it's JS blocking behavior on scroll. [#8500](https://github.com/grafana/grafana/issues/8500) + +# 4.3.1 (2017-05-23) + +## Bug fixes + +- **S3 image upload**: Fixed image url issue for us-east-1 (us standard) region. If you were missing slack images for alert notifications this should fix it. [#8444](https://github.com/grafana/grafana/issues/8444) + +# 4.3.0-stable (2017-05-23) + +## Bug fixes + +- **Gzip**: Fixed crash when gzip was enabled [#8380](https://github.com/grafana/grafana/issues/8380) +- **Graphite**: Fixed issue with Toggle edit mode did in query editor [#8377](https://github.com/grafana/grafana/issues/8377) +- **Alerting**: Fixed issue with state history not showing query execution errors [#8412](https://github.com/grafana/grafana/issues/8412) +- **Alerting**: Fixed issue with missing state history events/annotations when using sqlite3 database [#7992](https://github.com/grafana/grafana/issues/7992) +- **Sqlite**: Fixed with database table locked and using sqlite3 database [#7992](https://github.com/grafana/grafana/issues/7992) +- **Alerting**: Fixed issue with annotations showing up in unsaved dashboards, new graph & alert panel. [#8361](https://github.com/grafana/grafana/issues/8361) +- **webdav**: Fixed http proxy env variable support for webdav image upload [#7922](https://github.com/grafana/grafana/issues/79222), thx [@berghauz](https://github.com/berghauz) +- **Prometheus**: Fixed issue with hiding query [#8413](https://github.com/grafana/grafana/issues/8413) + +## Enhancements + +- **VictorOps**: Now supports panel image & auto resolve [#8431](https://github.com/grafana/grafana/pull/8431), thx [@davidmscott](https://github.com/davidmscott) +- **Alerting**: Alert annotations now provide more info [#8421](https://github.com/grafana/grafana/pull/8421) + +# 4.3.0-beta1 (2017-05-12) + +## Enhancements + +- **InfluxDB**: influxdb query builder support for ORDER BY and LIMIT (allows TOPN queries) [#6065](https://github.com/grafana/grafana/issues/6065) Support influxdb's SLIMIT Feature [#7232](https://github.com/grafana/grafana/issues/7232) thx [@thuck](https://github.com/thuck) +- **Panels**: Delay loading & Lazy load panels as they become visible (scrolled into view) [#5216](https://github.com/grafana/grafana/issues/5216) thx [@jifwin](https://github.com/jifwin) +- **Graph**: Support auto grid min/max when using log scale [#3090](https://github.com/grafana/grafana/issues/3090), thx [@bigbenhur](https://github.com/bigbenhur) +- **Graph**: Support for histograms [#600](https://github.com/grafana/grafana/issues/600) +- **Prometheus**: Support table response formats (column per label) [#6140](https://github.com/grafana/grafana/issues/6140), thx [@mtanda](https://github.com/mtanda) +- **Single Stat Panel**: support for non time series data [#6564](https://github.com/grafana/grafana/issues/6564) +- **Server**: Monitoring Grafana (health check endpoint) [#3302](https://github.com/grafana/grafana/issues/3302) +- **Heatmap**: Heatmap Panel [#7934](https://github.com/grafana/grafana/pull/7934) +- **Elasticsearch**: histogram aggregation [#3164](https://github.com/grafana/grafana/issues/3164) + +## Minor Enhancements + +- **InfluxDB**: Small fix for the "glow" when focus the field for LIMIT and SLIMIT [#7799](https://github.com/grafana/grafana/pull/7799) thx [@thuck](https://github.com/thuck) +- **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778) +- **Prometheus**: Step parameter changed semantics to min step to reduce the load on Prometheus and rendering in browser [#8073](https://github.com/grafana/grafana/pull/8073), thx [@bobrik](https://github.com/bobrik) +- **Templating**: Should not be possible to create self-referencing (recursive) template variable definitions [#7614](https://github.com/grafana/grafana/issues/7614) thx [@thuck](https://github.com/thuck) +- **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs) +- **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce) +- **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky) +- **Templating**: Data source variable now supports multi value and panel repeats [#7030](https://github.com/grafana/grafana/issues/7030) thx [@mtanda](https://github.com/mtanda) +- **Telegram**: Telegram alert is not sending metric and legend. [#8110](https://github.com/grafana/grafana/issues/8110), thx [@bashgeek](https://github.com/bashgeek) +- **Graph**: Support dashed lines [#514](https://github.com/grafana/grafana/issues/514), thx [@smalik03](https://github.com/smalik03) +- **Table**: Support to change column header text [#3551](https://github.com/grafana/grafana/issues/3551) +- **Alerting**: Better error when SMTP is not configured [#8093](https://github.com/grafana/grafana/issues/8093) +- **Pushover**: Add an option to attach graph image link in Pushover notification [#8043](https://github.com/grafana/grafana/issues/8043) thx [@devkid](https://github.com/devkid) +- **WebDAV**: Allow to set different ImageBaseUrl for WebDAV upload and image link [#7914](https://github.com/grafana/grafana/issues/7914) +- **Panels**: type-ahead mixed data source selection [#7697](https://github.com/grafana/grafana/issues/7697) thx [@mtanda](https://github.com/mtanda) +- **Security**:User enumeration problem [#7619](https://github.com/grafana/grafana/issues/7619) +- **InfluxDB**: Register new queries available in InfluxDB - Holt Winters [#5619](https://github.com/grafana/grafana/issues/5619) thx [@rikkuness](https://github.com/rikkuness) +- **Server**: Support listening on a UNIX socket [#4030](https://github.com/grafana/grafana/issues/4030), thx [@mitjaziv](https://github.com/mitjaziv) +- **Graph**: Support log scaling for values smaller 1 [#5278](https://github.com/grafana/grafana/issues/5278) +- **InfluxDB**: Slow 'select measurement' rendering for InfluxDB [#2524](https://github.com/grafana/grafana/issues/2524), thx [@sbhenderson](https://github.com/sbhenderson) +- **Config**: Configurable signout menu activation [#7968](https://github.com/grafana/grafana/pull/7968), thx [@seuf](https://github.com/seuf) + +## Fixes + +- **Table Panel**: Fixed annotation display in table panel, [#8023](https://github.com/grafana/grafana/issues/8023) +- **Dashboard**: If refresh is blocked due to tab not visible, then refresh when it becomes visible [#8076](https://github.com/grafana/grafana/issues/8076) thanks [@SimenB](https://github.com/SimenB) +- **Snapshots**: Fixed problem with annotations & snapshots [#7659](https://github.com/grafana/grafana/issues/7659) +- **Graph**: MetricSegment loses type when value is an asterisk [#8277](https://github.com/grafana/grafana/issues/8277), thx [@Gordiychuk](https://github.com/Gordiychuk) +- **Alerting**: Alert notifications do not show charts when using a non public S3 bucket [#8250](https://github.com/grafana/grafana/issues/8250) thx [@rogerswingle](https://github.com/rogerswingle) +- **Graph**: 100% client CPU usage on red alert glow animation [#8222](https://github.com/grafana/grafana/issues/8222) +- **InfluxDB**: Templating: "All" query does match too much [#8165](https://github.com/grafana/grafana/issues/8165) +- **Dashboard**: Description tooltip is not fully displayed [#7970](https://github.com/grafana/grafana/issues/7970) +- **Proxy**: Redirect after switching Org does not obey sub path in root_url (using reverse proxy) [#8089](https://github.com/grafana/grafana/issues/8089) +- **Templating**: Restoration of ad-hoc variable from URL does not work correctly [#8056](https://github.com/grafana/grafana/issues/8056) thx [@tamayika](https://github.com/tamayika) +- **InfluxDB**: timeFilter cannot be used twice in alerts [#7969](https://github.com/grafana/grafana/issues/7969) +- **MySQL**: 4-byte UTF8 not supported when using MySQL database (allows Emojis) [#7958](https://github.com/grafana/grafana/issues/7958) +- **Alerting**: api/alerts and api/alert/:id hold previous data for "message" and "Message" field when field value is changed from "some string" to empty string. [#7927](https://github.com/grafana/grafana/issues/7927) +- **Graph**: Cannot add fill below to series override [#7916](https://github.com/grafana/grafana/issues/7916) +- **InfluxDB**: Influxb Data source test passes even if the Database doesn't exist [#7864](https://github.com/grafana/grafana/issues/7864) +- **Prometheus**: Displaying Prometheus annotations is incredibly slow [#7750](https://github.com/grafana/grafana/issues/7750), thx [@mtanda](https://github.com/mtanda) +- **Graphite**: grafana generates empty find query to graphite -> 422 Unprocessable Entity [#7740](https://github.com/grafana/grafana/issues/7740) +- **Admin**: make organization filter case insensitive [#8194](https://github.com/grafana/grafana/issues/8194), thx [@Alexander-N](https://github.com/Alexander-N) + +## Changes + +- **Elasticsearch**: Changed elasticsearch Terms aggregation to default to Min Doc Count to 1, and sort order to Top [#8321](https://github.com/grafana/grafana/issues/8321) + +## Tech + +- **Library Upgrade**: inconshreveable/log15 outdated - no support for solaris [#8262](https://github.com/grafana/grafana/issues/8262) +- **Library Upgrade**: Upgrade Macaron [#7600](https://github.com/grafana/grafana/issues/7600) + +# 4.2.0 (2017-03-22) + +## Minor Enhancements + +- **Templates**: Prevent use of the prefix `__` for templates in web UI [#7678](https://github.com/grafana/grafana/issues/7678) +- **Threema**: Add emoji to Threema alert notifications [#7676](https://github.com/grafana/grafana/pull/7676) thx [@dbrgn](https://github.com/dbrgn) +- **Panels**: Support dm3 unit [#7695](https://github.com/grafana/grafana/issues/7695) thx [@mitjaziv](https://github.com/mitjaziv) +- **Docs**: Added some details about Sessions in Postgres [#7694](https://github.com/grafana/grafana/pull/7694) thx [@rickard-von-essen](https://github.com/rickard-von-essen) +- **Influxdb**: Allow commas in template variables [#7681](https://github.com/grafana/grafana/issues/7681) thx [@thuck](https://github.com/thuck) +- **Cloudwatch**: stop using deprecated session.New() [#7736](https://github.com/grafana/grafana/issues/7736) thx [@mtanda](https://github.com/mtanda) + \*TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Pass dropcounter rate option if no max counter and no reset value or reset value as 0 is specified [#7743](https://github.com/grafana/grafana/pull/7743) thx [@r4um](https://github.com/r4um) +- **Templating**: support full resolution for \$interval variable [#7696](https://github.com/grafana/grafana/pull/7696) thx [@mtanda](https://github.com/mtanda) +- **Elasticsearch**: Unique Count on string fields in ElasticSearch [#3536](https://github.com/grafana/grafana/issues/3536), thx [@pyro2927](https://github.com/pyro2927) +- **Templating**: Data source template variable that refers to other variable in regex filter [#6365](https://github.com/grafana/grafana/issues/6365) thx [@rlodge](https://github.com/rlodge) +- **Admin**: Global User List: add search and pagination [#7469](https://github.com/grafana/grafana/issues/7469) +- **User Management**: Invite UI is now disabled when login form is disabled [#7875](https://github.com/grafana/grafana/issues/7875) + +## Bugfixes + +- **Webhook**: Use proxy settings from environment variables [#7710](https://github.com/grafana/grafana/issues/7710) +- **Panels**: Deleting a dashboard with unsaved changes raises an error message [#7591](https://github.com/grafana/grafana/issues/7591) thx [@thuck](https://github.com/thuck) +- **Influxdb**: Query builder detects regex to easily for measurement [#7276](https://github.com/grafana/grafana/issues/7276) thx [@thuck](https://github.com/thuck) +- **Docs**: router_logging not documented [#7723](https://github.com/grafana/grafana/issues/7723) +- **Alerting**: Spelling mistake [#7739](https://github.com/grafana/grafana/pull/7739) thx [@woutersmit](https://github.com/woutersmit) +- **Alerting**: Graph legend scrolls to top when an alias is toggled/clicked [#7680](https://github.com/grafana/grafana/issues/7680) thx [@p4ddy1](https://github.com/p4ddy1) +- **Panels**: Fixed panel tooltip description after scrolling down [#7708](https://github.com/grafana/grafana/issues/7708) thx [@askomorokhov](https://github.com/askomorokhov) + +# 4.2.0-beta1 (2017-02-27) + +## Enhancements + +- **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +- **Templating**: Make $__interval and $\_\_interval_ms global built in variables that can be used in by any data source (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +- **S3 Image Store**: External s3 image store (used in alert notifications) now support AWS IAM Roles, closes [#6985](https://github.com/grafana/grafana/issues/6985), [#7058](https://github.com/grafana/grafana/issues/7058) thx [@mtanda](https://github.com/mtanda) +- **SingleStat**: Implements diff aggregation method for singlestat [#7234](https://github.com/grafana/grafana/issues/7234), thx [@oliverpool](https://github.com/oliverpool) +- **Dataproxy**: Added setting to enable more verbose logging in dataproxy [#7209](https://github.com/grafana/grafana/pull/7209), thx [@Ricky-N](https://github.com/Ricky-N) +- **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +- **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +- **LINE**: Adds image to notification message [#7417](https://github.com/grafana/grafana/pull/7417), thx [@Erliz](https://github.com/Erliz) +- **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +- **Alerting**: Uploading images for alert notifications is now optional [#7419](https://github.com/grafana/grafana/issues/7419) +- **Dashboard**: Adds shortcut for collapsing/expanding all rows [#552](https://github.com/grafana/grafana/issues/552), thx [@mtanda](https://github.com/mtanda) +- **Alerting**: Adds de duping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +- **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#1613](https://github.com/grafana/grafana/issues/1613) +- **Pushover**: Add Pushover alert notifications [#7526](https://github.com/grafana/grafana/pull/7526) thx [@devkid](https://github.com/devkid) +- **Threema**: Add Threema Gateway alert notification integration [#7482](https://github.com/grafana/grafana/pull/7482) thx [@dbrgn](https://github.com/dbrgn) + +## Minor Enhancements + +- **Optimization**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda) +- **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259) +- **Elasticsearch**: Support for Min Doc Count options in Terms aggregation [#7324](https://github.com/grafana/grafana/pull/7324), thx [@lpic10](https://github.com/lpic10) +- **Elasticsearch**: Term aggregation limit can now be changed in template queries [#7112](https://github.com/grafana/grafana/issues/7112), thx [@FFalcon](https://github.com/FFalcon) +- **Elasticsearch**: Ad-hoc filters now support all operators [#7612](https://github.com/grafana/grafana/issues/7612), thx [@tamayika](https://github.com/tamayika) +- **Graph**: Add full series name as title for legends. [#7493](https://github.com/grafana/grafana/pull/7493), thx [@kolobaev](https://github.com/kolobaev) +- **Table**: Add a message when queries returns no data. [#6109](https://github.com/grafana/grafana/issues/6109), thx [@xginn8](https://github.com/xginn8) +- **Graph**: Set max width for series names in legend tables. [#2385](https://github.com/grafana/grafana/issues/2385), thx [@kolobaev](https://github.com/kolobaev) +- **Database**: Allow max db connection pool configuration [#7427](https://github.com/grafana/grafana/issues/7427), thx [@huydx](https://github.com/huydx) +- **Data Sources** Delete datsource by name [#7476](https://github.com/grafana/grafana/issues/7476), thx [@huydx](https://github.com/huydx) +- **Dataproxy**: Only allow get that begins with api/ to access Prometheus [#7459](https://github.com/grafana/grafana/pull/7459), thx [@mtanda](https://github.com/mtanda) +- **Snapshot**: Make timeout for snapshot creation configurable [#7449](https://github.com/grafana/grafana/pull/7449) thx [@ryu1-sakai](https://github.com/ryu1-sakai) +- **Panels**: Add more physics units [#7554](https://github.com/grafana/grafana/pull/7554) thx [@ryantxu](https://github.com/ryantxu) +- **Email**: Add sender's name on email [#2131](https://github.com/grafana/grafana/issues/2131) thx [@jacobbednarz](https://github.com/jacobbednarz) +- **HTTPS**: Set tls 1.2 as lowest tls version. [#7347](https://github.com/grafana/grafana/pull/7347) thx [@roman-vynar](https://github.com/roman-vynar) +- **Table**: Added suppressing of empty results to table plugin. [#7602](https://github.com/grafana/grafana/pull/7602) thx [@LLIyRiK](https://github.com/LLIyRiK) + +## Tech + +- **Library Upgrade**: Upgraded angularjs from 1.5.8 to 1.6.1 [#7274](https://github.com/grafana/grafana/issues/7274) +- **Backend**: Grafana is now built using golang 1.8 + +## Bugfixes + +- **Alerting**: Fixes missing support for no_data and execution error when testing alerts [#7149](https://github.com/grafana/grafana/issues/7149) +- **Dashboard**: Avoid duplicate data in dashboard json for panels with alerts [#7256](https://github.com/grafana/grafana/pull/7256) +- **Alertlist**: Only show scrollbar when required [#7269](https://github.com/grafana/grafana/issues/7269) +- **SMTP**: Set LocalName to hostname [#7223](https://github.com/grafana/grafana/issues/7223) +- **Sidemenu**: Disable sign out in sidemenu for AuthProxyEnabled [#7377](https://github.com/grafana/grafana/pull/7377), thx [@solugebefola](https://github.com/solugebefola) +- **Prometheus**: Add support for basic auth in Prometheus tsdb package [#6799](https://github.com/grafana/grafana/issues/6799), thx [@hagen1778](https://github.com/hagen1778) +- **OAuth**: Redirect to original page when logging in with OAuth [#7513](https://github.com/grafana/grafana/issues/7513) +- **Annotations**: Wrap text in annotations tooltip [#7542](https://github.com/grafana/grafana/pull/7542), thx [@xginn8](https://github.com/xginn8) +- **Templating**: Fixes error when using numeric sort on empty strings [#7382](https://github.com/grafana/grafana/issues/7382) +- **Templating**: Fixed issue detecting template variable dependency [#7354](https://github.com/grafana/grafana/issues/7354) + +# 4.1.2 (2017-02-13) + +### Bugfixes + +- **Table**: Fixes broken annotation rendering mode in the table panel [#7268](https://github.com/grafana/grafana/issues/7268) +- **Data Sources**: Sorting for lists of data sources in UI is now case insensitive [#7491](https://github.com/grafana/grafana/issues/7491) +- **Admin**: Support more then 1000 users in global users list [#7469](https://github.com/grafana/grafana/issues/7469) + +# 4.1.1 (2017-01-11) + +### Bugfixes + +- **Graph Panel**: Fixed issue with legend height in table mode [#7221](https://github.com/grafana/grafana/issues/7221) + +# 4.1.0 (2017-01-11) + +### Bugfixes + +- **Server side PNG rendering**: Fixed issue with y-axis label rotation in phantomjs rendered images [#6924](https://github.com/grafana/grafana/issues/6924) +- **Graph**: Fixed centering of y-axis label [#7099](https://github.com/grafana/grafana/issues/7099) +- **Graph**: Fixed graph legend table mode and always visible scrollbar [#6828](https://github.com/grafana/grafana/issues/6828) +- **Templating**: Fixed template variable value groups/tags feature [#6752](https://github.com/grafana/grafana/issues/6752) +- **Webhook**: Fixed webhook username mismatch [#7195](https://github.com/grafana/grafana/pull/7195), thx [@theisenmark](https://github.com/theisenmark) +- **Influxdb**: Handles time(auto) the same way as time(\$interval) [#6997](https://github.com/grafana/grafana/issues/6997) + +## Enhancements + +- **Elasticsearch**: Added support for all moving average options [#7154](https://github.com/grafana/grafana/pull/7154), thx [@vaibhavinbayarea](https://github.com/vaibhavinbayarea) + +# 4.1-beta1 (2016-12-21) + +### Enhancements + +- **Postgres**: Add support for Certs for Postgres database [#6655](https://github.com/grafana/grafana/issues/6655) +- **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) +- **Opsgenie**: Add OpsGenie notification integration [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) +- **Singlestat**: New aggregation on singlestat panel [#6740](https://github.com/grafana/grafana/pull/6740), thx [@dirk-leroux](https://github.com/dirk-leroux) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source config page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Table**: Added Hidden Column Style for Table Panel [#5677](https://github.com/grafana/grafana/pull/5677), thx [@bmundt](https://github.com/bmundt) +- **Graph**: Shared crosshair option renamed to shared tooltip, shows tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) +- **Elasticsearch**: Added support for Missing option (bucket) for terms aggregation [#4244](https://github.com/grafana/grafana/pull/4244), thx [@shanielh](https://github.com/shanielh) +- **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) +- **CLI**: Make it possible to reset the admin password using the grafana-cli. [#5479](https://github.com/grafana/grafana/issues/5479) +- **Influxdb**: Support multiple tags in InfluxDB annotations. [#4550](https://github.com/grafana/grafana/pull/4550), thx [@adrianlzt](https://github.com/adrianlzt) +- **LDAP**: Basic Auth now supports LDAP username and password, [#6940](https://github.com/grafana/grafana/pull/6940), thx [@utkarshcmu](https://github.com/utkarshcmu) +- **LDAP**: Now works with Auth Proxy, role and organization mapping & sync will regularly be performed. [#6895](https://github.com/grafana/grafana/pull/6895), thx [@Seuf](https://github.com/seuf) +- **Alerting**: Adds OK as no data option. [#6866](https://github.com/grafana/grafana/issues/6866) +- **Alert list**: Order alerts based on state. [#6676](https://github.com/grafana/grafana/issues/6676) +- **Alerting**: Add api endpoint for pausing all alerts. [#6589](https://github.com/grafana/grafana/issues/6589) +- **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) + +### Bugfixes + +- **API**: HTTP API for deleting org returning incorrect message for a non-existing org [#6679](https://github.com/grafana/grafana/issues/6679) +- **Dashboard**: Posting empty dashboard result in corrupted dashboard [#5443](https://github.com/grafana/grafana/issues/5443) +- **Logging**: Fixed logging level config issue [#6978](https://github.com/grafana/grafana/issues/6978) +- **Notifications**: Remove html escaping the email subject. [#6905](https://github.com/grafana/grafana/issues/6905) +- **Influxdb**: Fixes broken field dropdown when using template vars as measurement. [#6473](https://github.com/grafana/grafana/issues/6473) + +# 4.0.2 (2016-12-08) + +### Enhancements + +- **Playlist**: Add support for kiosk mode [#6727](https://github.com/grafana/grafana/issues/6727) + +### Bugfixes + +- **Alerting**: Add alert message to webhook notifications [#6807](https://github.com/grafana/grafana/issues/6807) +- **Alerting**: Fixes a bug where avg() reducer treated null as zero. [#6879](https://github.com/grafana/grafana/issues/6879) +- **PNG Rendering**: Fix for server side rendering when using non default http addr bind and domain setting [#6813](https://github.com/grafana/grafana/issues/6813) +- **PNG Rendering**: Fix for server side rendering when setting enforce_domain to true [#6769](https://github.com/grafana/grafana/issues/6769) +- **Webhooks**: Add content type json to outgoing webhooks [#6822](https://github.com/grafana/grafana/issues/6822) +- **Keyboard shortcut**: Fixed zoom out shortcut [#6837](https://github.com/grafana/grafana/issues/6837) +- **Webdav**: Adds basic auth headers to webdav uploader [#6779](https://github.com/grafana/grafana/issues/6779) + +# 4.0.1 (2016-12-02) + +> **Notice** +> 4.0.0 had serious connection pooling issue when using a data source in proxy access. This bug caused lots of resource issues +> due to too many connections/file handles on the data source backend. This problem is fixed in this release. + +### Bugfixes + +- **Metrics**: Fixes nil pointer dereference on my arm build [#6749](https://github.com/grafana/grafana/issues/6749) +- **Data proxy**: Fixes a tcp pooling issue in the data source reverse proxy [#6759](https://github.com/grafana/grafana/issues/6759) + +# 4.0-stable (2016-11-29) + +### Bugfixes + +- **Server-side rendering**: Fixed address used when rendering panel via phantomjs and using non default http_addr config [#6660](https://github.com/grafana/grafana/issues/6660) +- **Graph panel**: Fixed graph panel tooltip sort order issue [#6648](https://github.com/grafana/grafana/issues/6648) +- **Unsaved changes**: You now navigate to the intended page after saving in the unsaved changes dialog [#6675](https://github.com/grafana/grafana/issues/6675) +- **TLS Client Auth**: Support for TLS client authentication for data source proxies [#2316](https://github.com/grafana/grafana/issues/2316) +- **Alerts out of sync**: Saving dashboards with broken alerts causes sync problem[#6576](https://github.com/grafana/grafana/issues/6576) +- **Alerting**: Saving an alert with condition "HAS NO DATA" throws an error[#6701](https://github.com/grafana/grafana/issues/6701) +- **Config**: Improve error message when parsing broken config file [#6731](https://github.com/grafana/grafana/issues/6731) +- **Table**: Render empty dates as - instead of current date [#6728](https://github.com/grafana/grafana/issues/6728) + +# 4.0-beta2 (2016-11-21) + +### Bugfixes + +- **Graph Panel**: Log base scale on right Y-axis had no effect, max value calc was not applied, [#6534](https://github.com/grafana/grafana/issues/6534) +- **Graph Panel**: Bar width if bars was only used in series override, [#6528](https://github.com/grafana/grafana/issues/6528) +- **UI/Browser**: Fixed issue with page/view header gradient border not showing in Safari, [#6530](https://github.com/grafana/grafana/issues/6530) +- **Cloudwatch**: Fixed cloudwatch data source requesting to many datapoints, [#6544](https://github.com/grafana/grafana/issues/6544) +- **UX**: Panel Drop zone visible after duplicating panel, and when entering fullscreen/edit view, [#6598](https://github.com/grafana/grafana/issues/6598) +- **Templating**: Newly added variable was not visible directly only after dashboard reload, [#6622](https://github.com/grafana/grafana/issues/6622) + +### Enhancements + +- **Singlestat**: Support repeated template variables in prefix/postfix [#6595](https://github.com/grafana/grafana/issues/6595) +- **Templating**: Don't persist variable options with refresh option [#6586](https://github.com/grafana/grafana/issues/6586) +- **Alerting**: Add ability to have OR conditions (and mixing AND & OR) [#6579](https://github.com/grafana/grafana/issues/6579) +- **InfluxDB**: Fix for Ad-Hoc Filters variable & changing dashboards [#6821](https://github.com/grafana/grafana/issues/6821) + +# 4.0-beta1 (2016-11-09) + +### Enhancements + +- **Login**: Adds option to disable username/password logins, closes [#4674](https://github.com/grafana/grafana/issues/4674) +- **SingleStat**: Add seriesName as option in singlestat panel, closes [#4740](https://github.com/grafana/grafana/issues/4740) +- **Localization**: Week start day now dependent on browser locale setting, closes [#3003](https://github.com/grafana/grafana/issues/3003) +- **Templating**: Update panel repeats for variables that change on time refresh, closes [#5021](https://github.com/grafana/grafana/issues/5021) +- **Templating**: Add support for numeric and alphabetical sorting of variable values, closes [#2839](https://github.com/grafana/grafana/issues/2839) +- **Elasticsearch**: Support to set Precision Threshold for Unique Count metric, closes [#4689](https://github.com/grafana/grafana/issues/4689) +- **Navigation**: Add search to org switcher, closes [#2609](https://github.com/grafana/grafana/issues/2609) +- **Database**: Allow database config using one property, closes [#5456](https://github.com/grafana/grafana/pull/5456) +- **Graphite**: Add support for groupByNodes, closes [#5613](https://github.com/grafana/grafana/pull/5613) +- **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827) +- **OpenTSDB**: Add support for explicitTags for OpenTSDB>=2.3, closes [#6360](https://github.com/grafana/grafana/pull/6361) +- **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718) +- **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003) +- **Background Tasks**: Now support automatic purging of old snapshots, closes [#4087](https://github.com/grafana/grafana/issues/4087) +- **Background Tasks**: Now support automatic purging of old rendered images, closes [#2172](https://github.com/grafana/grafana/issues/2172) +- **Dashboard**: After inactivity hide nav/row actions, fade to nice clean view, can be toggled with `d v`, also added kiosk mode, toggled via `d k` [#6476](https://github.com/grafana/grafana/issues/6476) +- **Dashboard**: Improved dashboard row menu & add panel UX [#6442](https://github.com/grafana/grafana/issues/6442) +- **Graph Panel**: Support for stacking null values [#2912](https://github.com/grafana/grafana/issues/2912), [#6287](https://github.com/grafana/grafana/issues/6287), thanks @benrubson! + +### Breaking changes + +- **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971) +- **lodash upgrade**: Upgraded lodash from 2.4.2 to 4.15.0, this contains a number of breaking changes that could effect plugins. closes [#6021](https://github.com/grafana/grafana/pull/6021) + +### Bug fixes + +- **Table Panel**: Fixed problem when switching to Mixed data source in metrics tab, fixes [#5999](https://github.com/grafana/grafana/pull/5999) +- **Playlist**: Fixed problem with play order not matching order defined in playlist, fixes [#5467](https://github.com/grafana/grafana/pull/5467) +- **Graph panel**: Fixed problem with auto decimals on y axis when datamin=datamax, fixes [#6070](https://github.com/grafana/grafana/pull/6070) +- **Snapshot**: Can view embedded panels/png rendered panels in snapshots without login, fixes [#3769](https://github.com/grafana/grafana/pull/3769) +- **Elasticsearch**: Fix for query template variable when looking up terms without query, no longer relies on elasticsearch default field, fixes [#3887](https://github.com/grafana/grafana/pull/3887) +- **Elasticsearch**: Fix for displaying IP address used in terms aggregations, fixes [#4393](https://github.com/grafana/grafana/pull/4393) +- **PNG Rendering**: Fix for server side rendering when using auth proxy, fixes [#5906](https://github.com/grafana/grafana/pull/5906) +- **OpenTSDB**: Fixed multi-value nested templating for opentsdb, fixes [#6455](https://github.com/grafana/grafana/pull/6455) +- **Playlist**: Remove playlist items when dashboard is removed, fixes [#6292](https://github.com/grafana/grafana/issues/6292) + +# 3.1.2 (unreleased) + +- **Templating**: Fixed issue when combining row & panel repeats, fixes [#5790](https://github.com/grafana/grafana/issues/5790) +- **Drag&Drop**: Fixed issue with drag and drop in latest Chrome(51+), fixes [#5767](https://github.com/grafana/grafana/issues/5767) +- **Internal Metrics**: Fixed issue with dots in instance_name when sending internal metrics to Graphite, fixes [#5739](https://github.com/grafana/grafana/issues/5739) +- **Grafana-CLI**: Add default plugin path for MAC OS, fixes [#5806](https://github.com/grafana/grafana/issues/5806) +- **Grafana-CLI**: Improve error message for upgrade-all command, fixes [#5885](https://github.com/grafana/grafana/issues/5885) + +# 3.1.1 (2016-08-01) + +- **IFrame embedding**: Fixed issue of using full iframe height, fixes [#5605](https://github.com/grafana/grafana/issues/5606) +- **Panel PNG rendering**: Fixed issue detecting render completion, fixes [#5605](https://github.com/grafana/grafana/issues/5606) +- **Elasticsearch**: Fixed issue with templating query and json parse error, fixes [#5615](https://github.com/grafana/grafana/issues/5615) +- **Tech**: Upgraded JQuery to 2.2.4 to fix Security vulnerabilities in 2.1.4, fixes [#5627](https://github.com/grafana/grafana/issues/5627) +- **Graphite**: Fixed issue with mixed data sources and Graphite, fixes [#5617](https://github.com/grafana/grafana/issues/5617) +- **Templating**: Fixed issue with template variable query was issued multiple times during dashboard load, fixes [#5637](https://github.com/grafana/grafana/issues/5637) +- **Zoom**: Fixed issues with zoom in and out on embedded (iframed) panel, fixes [#4489](https://github.com/grafana/grafana/issues/4489), [#5666](https://github.com/grafana/grafana/issues/5666) + +# 3.1.0 stable (2016-07-12) + +### Bugfixes & Enhancements, + +- **User Alert Notices**: Backend error alert popups did not show properly, fixes [#5435](https://github.com/grafana/grafana/issues/5435) +- **Table**: Added sanitize HTML option to allow links in table cells, fixes [#4596](https://github.com/grafana/grafana/issues/4596) +- **Apps**: App dashboards are automatically synced to DB at startup after plugin update, fixes [#5529](https://github.com/grafana/grafana/issues/5529) + +# 3.1.0-beta1 (2016-06-23) + +### Enhancements + +- **Dashboard Export/Import**: Dashboard export now templatize data sources and constant variables, users pick these on import, closes [#5084](https://github.com/grafana/grafana/issues/5084) +- **Dashboard Url**: Time range changes updates url, closes [#458](https://github.com/grafana/grafana/issues/458) +- **Dashboard Url**: Template variable change updates url, closes [#5002](https://github.com/grafana/grafana/issues/5002) +- **Singlestat**: Add support for range to text mappings, closes [#1319](https://github.com/grafana/grafana/issues/1319) +- **Graph**: Adds sort order options for graph tooltip, closes [#1189](https://github.com/grafana/grafana/issues/1189) +- **Theme**: Add default theme to config file [#5011](https://github.com/grafana/grafana/pull/5011) +- **Page Footer**: Added page footer with links to docs, shows Grafana version and info if new version is available, closes [#4889](https://github.com/grafana/grafana/pull/4889) +- **InfluxDB**: Add spread function, closes [#5211](https://github.com/grafana/grafana/issues/5211) +- **Scripts**: Use restart instead of start for deb package script, closes [#5282](https://github.com/grafana/grafana/pull/5282) +- **Logging**: Moved to structured logging lib, and moved to component specific level filters via config file, closes [#4590](https://github.com/grafana/grafana/issues/4590) +- **OpenTSDB**: Support nested template variables in tag_values function, closes [#4398](https://github.com/grafana/grafana/issues/4398) +- **Data Source**: Pending data source requests are canceled before new ones are issues (Graphite & Prometheus), closes [#5321](https://github.com/grafana/grafana/issues/5321) + +### Breaking changes + +- **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log output. +- **Graphite** : The Graph panel no longer have a Graphite PNG option. closes [#5367](https://github.com/grafana/grafana/issues/5367) + +### Bug fixes + +- **PNG rendering**: Fixed phantomjs rendering and y-axis label rotation. fixes [#5220](https://github.com/grafana/grafana/issues/5220) +- **CLI**: The cli tool now supports reading plugin.json from dist/plugin.json. fixes [#5410](https://github.com/grafana/grafana/issues/5410) + +# 3.0.4 Patch release (2016-05-25) + +- **Panel**: Fixed blank dashboard issue when switching to other dashboard while in fullscreen edit mode, fixes [#5163](https://github.com/grafana/grafana/pull/5163) +- **Templating**: Fixed issue with nested multi select variables and cascading and updating child variable selection state, fixes [#4861](https://github.com/grafana/grafana/pull/4861) +- **Templating**: Fixed issue with using templated data source in another template variable query, fixes [#5165](https://github.com/grafana/grafana/pull/5165) +- **Singlestat gauge**: Fixed issue with gauge render position, fixes [#5143](https://github.com/grafana/grafana/pull/5143) +- **Home dashboard**: Fixes broken home dashboard api, fixes [#5167](https://github.com/grafana/grafana/issues/5167) + +# 3.0.3 Patch release (2016-05-23) + +- **Annotations**: Annotations can now use a template variable as data source, closes [#5054](https://github.com/grafana/grafana/issues/5054) +- **Time picker**: Fixed issue timepicker and UTC when reading time from URL, fixes [#5078](https://github.com/grafana/grafana/issues/5078) +- **CloudWatch**: Support for Multiple Account by AssumeRole, closes [#3522](https://github.com/grafana/grafana/issues/3522) +- **Singlestat**: Fixed alignment and minimum height issue, fixes [#5113](https://github.com/grafana/grafana/issues/5113), fixes [#4679](https://github.com/grafana/grafana/issues/4679) +- **Share modal**: Fixed link when using grafana under dashboard sub url, fixes [#5109](https://github.com/grafana/grafana/issues/5109) +- **Prometheus**: Fixed bug in query editor that caused it not to load when reloading page, fixes [#5107](https://github.com/grafana/grafana/issues/5107) +- **Elasticsearch**: Fixed bug when template variable query returns numeric values, fixes [#5097](https://github.com/grafana/grafana/issues/5097), fixes [#5088](https://github.com/grafana/grafana/issues/5088) +- **Logging**: Fixed issue with reading logging level value, fixes [#5079](https://github.com/grafana/grafana/issues/5079) +- **Timepicker**: Fixed issue with timepicker and UTC when reading time from URL, fixes [#5078](https://github.com/grafana/grafana/issues/5078) +- **Docs**: Added docs for org & user preferences HTTP API, closes [#5069](https://github.com/grafana/grafana/issues/5069) +- **Plugin list panel**: Now shows correct enable state for apps when not enabled, fixes [#5068](https://github.com/grafana/grafana/issues/5068) +- **Elasticsearch**: Templating & Annotation queries that use template variables are now formatted correctly, fixes [#5135](https://github.com/grafana/grafana/issues/5135) + +# 3.0.2 Patch release (2016-05-16) + +- **Templating**: Fixed issue mixing row repeat and panel repeats, fixes [#4988](https://github.com/grafana/grafana/issues/4988) +- **Templating**: Fixed issue detecting dependencies in nested variables, fixes [#4987](https://github.com/grafana/grafana/issues/4987), fixes [#4986](https://github.com/grafana/grafana/issues/4986) +- **Graph**: Fixed broken PNG rendering in graph panel, fixes [#5025](https://github.com/grafana/grafana/issues/5025) +- **Graph**: Fixed broken xaxis on graph panel, fixes [#5024](https://github.com/grafana/grafana/issues/5024) + +- **Influxdb**: Fixes crash when hiding middle series, fixes [#5005](https://github.com/grafana/grafana/issues/5005) + +# 3.0.1 Stable (2016-05-11) + +### Bug fixes + +- **Templating**: Fixed issue with new data source variable not persisting current selected value, fixes [#4934](https://github.com/grafana/grafana/issues/4934) + +# 3.0.0-beta7 (2016-05-02) + +### Bug fixes + +- **Dashboard title**: Fixed max dashboard title width (media query) for large screens, fixes [#4859](https://github.com/grafana/grafana/issues/4859) +- **Annotations**: Fixed issue with entering annotation edit view, fixes [#4857](https://github.com/grafana/grafana/issues/4857) +- **Remove query**: Fixed issue with removing query for data sources without collapsible query editors, fixes [#4856](https://github.com/grafana/grafana/issues/4856) +- **Graphite PNG**: Fixed issue graphite png rendering option, fixes [#4864](https://github.com/grafana/grafana/issues/4864) +- **InfluxDB**: Fixed issue missing plus group by iconn, fixes [#4862](https://github.com/grafana/grafana/issues/4862) +- **Graph**: Fixes missing line mode for thresholds, fixes [#4902](https://github.com/grafana/grafana/pull/4902) + +### Enhancements + +- **InfluxDB**: Added new functions moving_average and difference to query editor, closes [#4698](https://github.com/grafana/grafana/issues/4698) + +# 3.0.0-beta6 (2016-04-29) + +### Enhancements + +- **Singlestat**: Support for gauges in singlestat panel. closes [#3688](https://github.com/grafana/grafana/pull/3688) +- **Templating**: Support for data source as variable, closes [#816](https://github.com/grafana/grafana/pull/816) + +### Bug fixes + +- **InfluxDB 0.12**: Fixed issue templating and `show tag values` query only returning tags for first measurement, fixes [#4726](https://github.com/grafana/grafana/issues/4726) +- **Templating**: Fixed issue with regex formatting when matching multiple values, fixes [#4755](https://github.com/grafana/grafana/issues/4755) +- **Templating**: Fixed issue with custom all value and escaping, fixes [#4736](https://github.com/grafana/grafana/issues/4736) +- **Dashlist**: Fixed issue dashboard list panel and caching tags, fixes [#4768](https://github.com/grafana/grafana/issues/4768) +- **Graph**: Fixed issue with unneeded scrollbar in legend for Firefox, fixes [#4760](https://github.com/grafana/grafana/issues/4760) +- **Table panel**: Fixed issue table panel formatting string array properties, fixes [#4791](https://github.com/grafana/grafana/issues/4791) +- **grafana-cli**: Improve error message when failing to install plugins due to corrupt response, fixes [#4651](https://github.com/grafana/grafana/issues/4651) +- **Singlestat**: Fixes prefix an postfix for gauges, fixes [#4812](https://github.com/grafana/grafana/issues/4812) +- **Singlestat**: Fixes auto-refresh on change for some options, fixes [#4809](https://github.com/grafana/grafana/issues/4809) + +### Breaking changes + +**Data Source Query Editors**: Issue [#3900](https://github.com/grafana/grafana/issues/3900) + +Query editors have been updated to use the new form styles. External data source plugins needs to be +updated to work. Sorry to introduce breaking change this late in beta phase. We wanted to get this change +in before 3.0 stable is released so we don't have to break data sources in next release (3.1). If you are +a data source plugin author and want help for how the new form styles work please ask for help in +slack channel (link to slack channel in readme). + +# 3.0.0-beta5 (2016-04-15) + +### Bug fixes + +- **grafana-cli**: Fixed issue grafana-cli tool, did not detect the right plugin dir, fixes [#4723](https://github.com/grafana/grafana/issues/4723) +- **Graph**: Fixed issue with light theme text color issue in tooltip, fixes [#4702](https://github.com/grafana/grafana/issues/4702) +- **Snapshot**: Fixed issue with empty snapshots, fixes [#4706](https://github.com/grafana/grafana/issues/4706) + +# 3.0.0-beta4 (2016-04-13) + +### Bug fixes + +- **Home dashboard**: Fixed issue with permission denied error on home dashboard, fixes [#4686](https://github.com/grafana/grafana/issues/4686) +- **Templating**: Fixed issue templating variables that use regex extraction, fixes [#4672](https://github.com/grafana/grafana/issues/4672) + +# 3.0.0-beta3 (2016-04-12) + +### Enhancements + +- **InfluxDB**: Changed multi query encoding to work with InfluxDB 0.11 & 0.12, closes [#4533](https://github.com/grafana/grafana/issues/4533) +- **Timepicker**: Add arrows and shortcuts for moving back and forth in current dashboard, closes [#119](https://github.com/grafana/grafana/issues/119) + +### Bug fixes + +- **Postgres**: Fixed page render crash when using postgres, fixes [#4558](https://github.com/grafana/grafana/issues/4558) +- **Table panel**: Fixed table panel bug when trying to show annotations in table panel, fixes [#4563](https://github.com/grafana/grafana/issues/4563) +- **App Config**: Fixed app config issue showing content of other app config, fixes [#4575](https://github.com/grafana/grafana/issues/4575) +- **Graph Panel**: Fixed legend option max not updating, fixes [#4601](https://github.com/grafana/grafana/issues/4601) +- **Graph Panel**: Fixed issue where newly added graph panels shared same axes config, fixes [#4582](https://github.com/grafana/grafana/issues/4582) +- **Graph Panel**: Fixed issue with axis labels overlapping Y-axis, fixes [#4626](https://github.com/grafana/grafana/issues/4626) +- **InfluxDB**: Fixed issue with templating query containing template variable, fixes [#4602](https://github.com/grafana/grafana/issues/4602) +- **Graph Panel**: Fixed issue with hiding series and stacking, fixes [#4557](https://github.com/grafana/grafana/issues/4557) +- **Graph Panel**: Fixed issue with legend height in table mode with few series, affected iframe embedding as well, fixes [#4640](https://github.com/grafana/grafana/issues/4640) + +# 3.0.0-beta2 (2016-04-04) + +### New Features (introduces since 3.0-beta1) + +- **Preferences**: Set home dashboard on user and org level, closes [#1678](https://github.com/grafana/grafana/issues/1678) +- **Preferences**: Set timezone on user and org level, closes [#3214](https://github.com/grafana/grafana/issues/3214), [#1200](https://github.com/grafana/grafana/issues/1200) +- **Preferences**: Set theme on user and org level, closes [#3214](https://github.com/grafana/grafana/issues/3214), [#1917](https://github.com/grafana/grafana/issues/1917) + +### Bug fixes + +- **Dashboard**: Fixed dashboard panel layout for mobile devices, fixes [#4529](https://github.com/grafana/grafana/issues/4529) +- **Table Panel**: Fixed issue with table panel sort, fixes [#4532](https://github.com/grafana/grafana/issues/4532) +- **Page Load Crash**: A data source with null jsonData would make Grafana fail to load page, fixes [#4536](https://github.com/grafana/grafana/issues/4536) +- **Metrics tab**: Fix for missing data source name in data source selector, fixes [#4541](https://github.com/grafana/grafana/issues/4540) +- **Graph**: Fix legend in table mode with series on right-y axis, fixes [#4551](https://github.com/grafana/grafana/issues/4551), [#1145](https://github.com/grafana/grafana/issues/1145) + +# 3.0.0-beta1 (2016-03-31) + +### New Features + +- **Playlists**: Playlists can now be persisted and started from urls, closes [#3655](https://github.com/grafana/grafana/issues/3655) +- **Metadata**: Settings panel now shows dashboard metadata, closes [#3304](https://github.com/grafana/grafana/issues/3304) +- **InfluxDB**: Support for policy selection in query editor, closes [#2018](https://github.com/grafana/grafana/issues/2018) +- **Snapshots UI**: Dashboard snapshots list can be managed through UI, closes[#1984](https://github.com/grafana/grafana/issues/1984) +- **Prometheus**: Prometheus annotation support, closes[#2883](https://github.com/grafana/grafana/pull/2883) +- **Cli**: New cli tool for downloading and updating plugins +- **Annotations**: Annotations can now contain links that can be clicked (you can navigate on to annotation popovers), closes [#1588](https://github.com/grafana/grafana/issues/1588) +- **Opentsdb**: Opentsdb 2.2 filters support, closes[#3077](https://github.com/grafana/grafana/issues/3077) + +### Breaking changes + +- **Plugin API**: Both data source and panel plugin api (and plugin.json schema) have been updated, requiring an update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info. +- **InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3523](https://github.com/grafana/grafana/issues/3523) +- **KairosDB** The data source is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3524](https://github.com/grafana/grafana/issues/3524) +- **Templating**: Templating value formats (glob/regex/pipe etc) are now handled automatically and not specified by the user, this makes variable values possible to reuse in many contexts. It can in some edge cases break existing dashboards that have template variables that do not reload on dashboard load. To fix any issue just go into template variable options and update the variable (so it's values are reloaded.). + +### Enhancements + +- **LDAP**: Support for nested LDAP Groups, closes [#4401](https://github.com/grafana/grafana/issues/4401), [#3808](https://github.com/grafana/grafana/issues/3808) +- **Sessions**: Support for memcached as session storage, closes [#3458](https://github.com/grafana/grafana/issues/3458) +- **mysql**: Grafana now supports ssl for mysql, closes [#3584](https://github.com/grafana/grafana/issues/3584) +- **snapshot**: Annotations are now included in snapshots, closes [#3635](https://github.com/grafana/grafana/issues/3635) +- **Admin**: Admin can now have global overview of Grafana setup, closes [#3812](https://github.com/grafana/grafana/issues/3812) +- **graph**: Right side legend height is now fixed at row height, closes [#1277](https://github.com/grafana/grafana/issues/1277) +- **Table**: All content in table panel is now html escaped, closes [#3673](https://github.com/grafana/grafana/issues/3673) +- **graph**: Template variables can now be used in TimeShift and TimeFrom, closes[#1960](https://github.com/grafana/grafana/issues/1960) +- **Tooltip**: Optionally add milliseconds to timestamp in tool tip, closes[#2248](https://github.com/grafana/grafana/issues/2248) +- **Opentsdb**: Support milliseconds when using openTSDB data source, closes [#2865](https://github.com/grafana/grafana/issues/2865) +- **Opentsdb**: Add support for annotations, closes[#664](https://github.com/grafana/grafana/issues/664) + +### Bug fixes + +- **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794) +- **InfluxDB**: Fix for InfluxDB and table panel when using Format As Table and having group by time, fixes [#3928](https://github.com/grafana/grafana/issues/3928) +- **Panel Time shift**: Fix for panel time range and using dashboard times like `Today` and `This Week`, fixes [#3941](https://github.com/grafana/grafana/issues/3941) +- **Row repeat**: Repeated rows will now appear next to each other and not by the bottom of the dashboard, fixes [#3942](https://github.com/grafana/grafana/issues/3942) +- **Png renderer**: Fix for phantomjs path on windows, fixes [#3657](https://github.com/grafana/grafana/issues/3657) + +# 2.6.1 (unreleased, 2.6.x branch) + +### New Features + +- **Elasticsearch**: Support for derivative unit option, closes [#3512](https://github.com/grafana/grafana/issues/3512) + +### Bug fixes + +- **Graph Panel**: Fixed typehead when adding series style override, closes [#3554](https://github.com/grafana/grafana/issues/3554) + +# 2.6.0 (2015-12-14) + +### New Features + +- **Elasticsearch**: Support for pipeline aggregations Moving average and derivative, closes [#2715](https://github.com/grafana/grafana/issues/2715) +- **Elasticsearch**: Support for inline script and missing options for metrics, closes [#3500](https://github.com/grafana/grafana/issues/3500) +- **Syslog**: Support for syslog logging, closes [#3161](https://github.com/grafana/grafana/pull/3161) +- **Timepicker**: Always show refresh button even with refresh rate, closes [#3498](https://github.com/grafana/grafana/pull/3498) +- **Login**: Make it possible to change the login hint on the login page, closes [#2571](https://github.com/grafana/grafana/pull/2571) + +### Bug Fixes + +- **metric editors**: Fix for clicking typeahead auto dropdown option, fixes [#3428](https://github.com/grafana/grafana/issues/3428) +- **influxdb**: Fixed issue showing Group By label only on first query, fixes [#3453](https://github.com/grafana/grafana/issues/3453) +- **logging**: Add more verbose info logging for http requests, closes [#3405](https://github.com/grafana/grafana/pull/3405) + +# 2.6.0-Beta1 (2015-12-04) + +### New Table Panel + +- **table**: New powerful and flexible table panel, closes [#215](https://github.com/grafana/grafana/issues/215) + +### Enhancements + +- **CloudWatch**: Support for multiple AWS Credentials, closes [#3053](https://github.com/grafana/grafana/issues/3053), [#3080](https://github.com/grafana/grafana/issues/3080) +- **Elasticsearch**: Support for dynamic daily indices for annotations, closes [#3061](https://github.com/grafana/grafana/issues/3061) +- **Elasticsearch**: Support for setting min_doc_count for date histogram, closes [#3416](https://github.com/grafana/grafana/issues/3416) +- **Graph Panel**: Option to hide series with all zeroes from legend and tooltip, closes [#1381](https://github.com/grafana/grafana/issues/1381), [#3336](https://github.com/grafana/grafana/issues/3336) + +### Bug Fixes + +- **cloudwatch**: fix for handling of period for long time ranges, fixes [#3086](https://github.com/grafana/grafana/issues/3086) +- **dashboard**: fix for collapse row by clicking on row title, fixes [#3065](https://github.com/grafana/grafana/issues/3065) +- **influxdb**: fix for relative time ranges `last x months` and `last x years`, fixes [#3067](https://github.com/grafana/grafana/issues/3067) +- **graph**: layout fix for color picker when right side legend was enabled, fixes [#3093](https://github.com/grafana/grafana/issues/3093) +- **elasticsearch**: disabling elastic query (via eye) caused error, fixes [#3300](https://github.com/grafana/grafana/issues/3300) + +### Breaking changes + +- **elasticsearch**: Manual json edited queries are not supported any more (They very barely worked in 2.5) + +# 2.5 (2015-10-28) + +**New Feature: Mix data sources** + +- A built in data source is now available named `-- Mixed --`, When picked in the metrics tab, + it allows you to add queries of different data source types & instances to the same graph/panel! + [Issue #436](https://github.com/grafana/grafana/issues/436) + +**New Feature: Elasticsearch Metrics Query Editor and Viz Support** + +- Feature rich query editor and processing features enables you to issues all kind of metric queries to Elasticsearch +- See [Issue #1034](https://github.com/grafana/grafana/issues/1034) for more info. + +**New Feature: New and much improved time picker** + +- Support for quick ranges like `Today`, `This day last week`, `This week`, `The day so far`, etc. +- Improved UI and improved support for UTC, [Issue #2761](https://github.com/grafana/grafana/issues/2761) for more info. + +**User Onboarding** + +- Org admin can now send email invites (or invite links) to people who are not yet Grafana users +- Sign up flow now supports email verification (if enabled) +- See [Issue #2353](https://github.com/grafana/grafana/issues/2353) for more info. + +**Other new Features && Enhancements** + +- [Pull #2720](https://github.com/grafana/grafana/pull/2720). Admin: Initial basic quota support (per Org) +- [Issue #2577](https://github.com/grafana/grafana/issues/2577). Panel: Resize handles in panel bottom right corners for easy width and height change +- [Issue #2457](https://github.com/grafana/grafana/issues/2457). Admin: admin page for all grafana organizations (list / edit view) +- [Issue #1186](https://github.com/grafana/grafana/issues/1186). Time Picker: New option `today`, will set time range from midnight to now +- [Issue #2647](https://github.com/grafana/grafana/issues/2647). InfluxDB: You can now set group by time interval on each query +- [Issue #2599](https://github.com/grafana/grafana/issues/2599). InfluxDB: Improved alias support, you can now use the `AS` clause for each select statement +- [Issue #2708](https://github.com/grafana/grafana/issues/2708). InfluxDB: You can now set math expression for select clauses. +- [Issue #1575](https://github.com/grafana/grafana/issues/1575). Drilldown link: now you can click on the external link icon in the panel header to access drilldown links! +- [Issue #1646](https://github.com/grafana/grafana/issues/1646). OpenTSDB: Fetch list of aggregators from OpenTSDB +- [Issue #2955](https://github.com/grafana/grafana/issues/2955). Graph: More axis units (Length, Volume, Temperature, Pressure, etc), thanks @greglook +- [Issue #2928](https://github.com/grafana/grafana/issues/2928). LDAP: Support for searching for groups memberships, i.e. POSIX (no memberOf) schemas, also multiple ldap servers, and root ca cert, thanks @abligh + +**Fixes** + +- [Issue #2413](https://github.com/grafana/grafana/issues/2413). InfluxDB 0.9: Fix for handling empty series object in response from influxdb +- [Issue #2574](https://github.com/grafana/grafana/issues/2574). Snapshot: Fix for snapshot with expire 7 days option, 7 days option not correct, was 7 hours +- [Issue #2568](https://github.com/grafana/grafana/issues/2568). AuthProxy: Fix for server side rendering of panel when using auth proxy +- [Issue #2490](https://github.com/grafana/grafana/issues/2490). Graphite: Dashboard import was broken in 2.1 and 2.1.1, working now +- [Issue #2565](https://github.com/grafana/grafana/issues/2565). TimePicker: Fix for when you applied custom time range it did not refresh dashboard +- [Issue #2563](https://github.com/grafana/grafana/issues/2563). Annotations: Fixed issue when html sanitizer fails for title to annotation body, now fallbacks to html escaping title and text +- [Issue #2564](https://github.com/grafana/grafana/issues/2564). Templating: Another attempt at fixing #2534 (Init multi value template var used in repeat panel from url) +- [Issue #2620](https://github.com/grafana/grafana/issues/2620). Graph: multi series tooltip did no highlight correct point when stacking was enabled and series were of different resolution +- [Issue #2636](https://github.com/grafana/grafana/issues/2636). InfluxDB: Do no show template vars in dropdown for tag keys and group by keys +- [Issue #2604](https://github.com/grafana/grafana/issues/2604). InfluxDB: More alias options, can now use `$[0-9]` syntax to reference part of a measurement name (separated by dots) + +**Breaking Changes** + +- Notice to makers/users of custom data sources, there is a minor breaking change in 2.2 that + require an update to custom data sources for them to work in 2.2. [Read this doc](https://github.com/grafana/grafana/tree/master/docs/sources/datasources/plugin_api.md) for more on the + data source api change. +- Data source api changes, [PLUGIN_CHANGES.md](https://github.com/grafana/grafana/blob/master/public/app/plugins/PLUGIN_CHANGES.md) +- The duplicate query function used in data source editors is changed, and moveMetricQuery function was renamed + +**Tech (Note for devs)** +Started using Typescript (transpiled to ES5), uncompiled typescript files and less files are in public folder (in source tree) +This folder is never modified by build steps. Compiled css and javascript files are put in public_gen, all other files +that do not undergo transformation are just copied from public to public_gen, it is public_gen that is used by grafana-server +if it is found. + +Grunt & Watch tasks: + +- `grunt` : default task, will remove public_gen, copy over all files from public, do less & typescript compilation +- `grunt watch`: will watch for changes to less, and typescript files and compile them to public_gen, and for other files it will just copy them to public_gen + +# 2.1.3 (2015-08-24) + +**Fixes** + +- [Issue #2580](https://github.com/grafana/grafana/issues/2580). Packaging: ldap.toml was not marked as config file and could be overwritten in upgrade +- [Issue #2564](https://github.com/grafana/grafana/issues/2564). Templating: Another attempt at fixing #2534 (Init multi value template var used in repeat panel from url) + +# 2.1.2 (2015-08-20) + +**Fixes** + +- [Issue #2558](https://github.com/grafana/grafana/issues/2558). DragDrop: Fix for broken drag drop behavior +- [Issue #2534](https://github.com/grafana/grafana/issues/2534). Templating: fix for setting template variable value via url and having repeated panels or rows + +# 2.1.1 (2015-08-11) + +**Fixes** + +- [Issue #2443](https://github.com/grafana/grafana/issues/2443). Templating: Fix for buggy repeat row behavior when combined with with repeat panel due to recent change before 2.1 release +- [Issue #2442](https://github.com/grafana/grafana/issues/2442). Templating: Fix text panel when using template variables in text in in repeated panel +- [Issue #2446](https://github.com/grafana/grafana/issues/2446). InfluxDB: Fix for using template vars inside alias field (InfluxDB 0.9) +- [Issue #2460](https://github.com/grafana/grafana/issues/2460). SinglestatPanel: Fix to handle series with no data points +- [Issue #2461](https://github.com/grafana/grafana/issues/2461). LDAP: Fix for ldap users with empty email address +- [Issue #2484](https://github.com/grafana/grafana/issues/2484). Graphite: Fix bug when using series ref (#A-Z) and referenced series is hidden in query editor. +- [Issue #1896](https://github.com/grafana/grafana/issues/1896). Postgres: Dashboard search is now case insensitive when using Postgres + +**Enhancements** + +- [Issue #2477](https://github.com/grafana/grafana/issues/2477). InfluxDB(0.9): Added more condition operators (`<`, `>`, `<>`, `!~`), thx @thuck +- [Issue #2483](https://github.com/grafana/grafana/issues/2484). InfluxDB(0.9): Use \$col as option in alias patterns, thx @thuck + +# 2.1.0 (2015-08-04) + +**Data sources** + +- [Issue #1525](https://github.com/grafana/grafana/issues/1525). InfluxDB: Full support for InfluxDB 0.9 with new adapted query editor +- [Issue #2191](https://github.com/grafana/grafana/issues/2191). KariosDB: Grafana now ships with a KariosDB data source plugin, thx @masaori335 +- [Issue #1177](https://github.com/grafana/grafana/issues/1177). OpenTSDB: Limit tags by metric, OpenTSDB config option tsd.core.meta.enable_realtime_ts must enabled for OpenTSDB lookup api +- [Issue #1250](https://github.com/grafana/grafana/issues/1250). OpenTSDB: Support for template variable values lookup queries + +**New dashboard features** + +- [Issue #1144](https://github.com/grafana/grafana/issues/1144). Templating: You can now select multiple template variables values at the same time. +- [Issue #1922](https://github.com/grafana/grafana/issues/1922). Templating: Specify multiple variable values via URL params. +- [Issue #1888](https://github.com/grafana/grafana/issues/1144). Templating: Repeat panel or row for each selected template variable value +- [Issue #1888](https://github.com/grafana/grafana/issues/1944). Dashboard: Custom Navigation links & dynamic links to related dashboards +- [Issue #590](https://github.com/grafana/grafana/issues/590). Graph: Define series color using regex rule +- [Issue #2162](https://github.com/grafana/grafana/issues/2162). Graph: New series style override, negative-y transform and stack groups +- [Issue #2096](https://github.com/grafana/grafana/issues/2096). Dashboard list panel: Now supports search by multiple tags +- [Issue #2203](https://github.com/grafana/grafana/issues/2203). Singlestat: Now support string values + +**User or Organization admin** + +- [Issue #1899](https://github.com/grafana/grafana/issues/1899). Organization: You can now update the organization user role directly (without removing and readding the organization user). +- [Issue #2088](https://github.com/grafana/grafana/issues/2088). Roles: New user role `Read Only Editor` that replaces the old `Viewer` role behavior + +**Backend** + +- [Issue #2218](https://github.com/grafana/grafana/issues/2218). Auth: You can now authenticate against api with username / password using basic auth +- [Issue #2095](https://github.com/grafana/grafana/issues/2095). Search: Search now supports filtering by multiple dashboard tags +- [Issue #1905](https://github.com/grafana/grafana/issues/1905). GitHub OAuth: You can now configure a GitHub team membership requirement, thx @dewski +- [Issue #2052](https://github.com/grafana/grafana/issues/2052). GitHub OAuth: You can now configure a GitHub organization requirement, thx @indrekj +- [Issue #1891](https://github.com/grafana/grafana/issues/1891). Security: New config option to disable the use of gravatar for profile images +- [Issue #1921](https://github.com/grafana/grafana/issues/1921). Auth: Support for user authentication via reverse proxy header (like X-Authenticated-User, or X-WEBAUTH-USER) +- [Issue #960](https://github.com/grafana/grafana/issues/960). Search: Backend can now index a folder with json files, will be available in search (saving back to folder is not supported, this feature is meant for static generated json dashboards) + +**Breaking changes** + +- [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior +- [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request naming +- Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`) +- Search HTTP API response has changed (simplified), tags list moved to separate HTTP resource URI +- Data source HTTP api breaking change, ADD data source is now POST /api/datasources/, update is now PUT /api/datasources/:id + +**Fixes** + +- [Issue #2185](https://github.com/grafana/grafana/issues/2185). Graph: fixed PNG rendering of panels with legend table to the right +- [Issue #2163](https://github.com/grafana/grafana/issues/2163). Backend: Load dashboards with capital letters in the dashboard url slug (url id) + +# 2.0.3 (unreleased - 2.0.x branch) + +**Fixes** + +- [Issue #1872](https://github.com/grafana/grafana/issues/1872). Firefox/IE issue, invisible text in dashboard search fixed +- [Issue #1857](https://github.com/grafana/grafana/issues/1857). /api/login/ping Fix for issue when behind reverse proxy and subpath +- [Issue #1863](https://github.com/grafana/grafana/issues/1863). MySQL: Dashboard.data column type changed to mediumtext (sql migration added) + +# 2.0.2 (2015-04-22) + +**Fixes** + +- [Issue #1832](https://github.com/grafana/grafana/issues/1832). Graph Panel + Legend Table mode: Many series caused zero height graph, now legend will never reduce the height of the graph below 50% of row height. +- [Issue #1846](https://github.com/grafana/grafana/issues/1846). Snapshots: Fixed issue with snapshotting dashboards with an interval template variable +- [Issue #1848](https://github.com/grafana/grafana/issues/1848). Panel timeshift: You can now use panel timeshift without a relative time override + +# 2.0.1 (2015-04-20) + +**Fixes** + +- [Issue #1784](https://github.com/grafana/grafana/issues/1784). Data source proxy: Fixed issue with using data source proxy when grafana is behind nginx suburl +- [Issue #1749](https://github.com/grafana/grafana/issues/1749). Graph Panel: Table legends are now visible when rendered to PNG +- [Issue #1786](https://github.com/grafana/grafana/issues/1786). Graph Panel: Legend in table mode now aligns, graph area is reduced depending on how many series +- [Issue #1734](https://github.com/grafana/grafana/issues/1734). Support for unicode / international characters in dashboard title (improved slugify) +- [Issue #1782](https://github.com/grafana/grafana/issues/1782). GitHub OAuth: Now works with GitHub for Enterprise, thanks @williamjoy +- [Issue #1780](https://github.com/grafana/grafana/issues/1780). Dashboard snapshot: Should not require login to view snapshot, Fixes #1780 + +# 2.0.0-Beta3 (2015-04-12) + +**RPM / DEB Package changes (to follow HFS)** + +- binary name changed to grafana-server +- does not install to `/opt/grafana` any more, installs to `/usr/share/grafana` +- binary to `/usr/sbin/grafana-server` +- init.d script improvements, renamed to `/etc/init.d/grafana-server` +- added default file with environment variables, + + - `/etc/default/grafana-server` (deb/ubuntu) + - `/etc/sysconfig/grafana-server` (centos/redhat) + +- added systemd service file, tested on debian jessie and centos7 +- config file in same location `/etc/grafana/grafana.ini` (now complete config file but with every setting commented out) +- data directory (where sqlite3) file is stored is now by default `/var/lib/grafana` +- no symlinking current to versions anymore +- For more info see [Issue #1758](https://github.com/grafana/grafana/issues/1758). + +**Config breaking change (setting rename)** + +- `[log] root_path` has changed to `[paths] logs` + +# 2.0.0-Beta2 (...) + +**Enhancements** + +- [Issue #1701](https://github.com/grafana/grafana/issues/1701). Share modal: Override UI theme via URL param for Share link, rendered panel, or embedded panel +- [Issue #1660](https://github.com/grafana/grafana/issues/1660). OAuth: Specify allowed email address domains for google or and github oauth logins + +**Fixes** + +- [Issue #1649](https://github.com/grafana/grafana/issues/1649). HTTP API: grafana /render calls nows with api keys +- [Issue #1667](https://github.com/grafana/grafana/issues/1667). Data source proxy & session timeout fix (caused 401 Unauthorized error after a while) +- [Issue #1707](https://github.com/grafana/grafana/issues/1707). Unsaved changes: Do not show for snapshots, scripted and file based dashboards +- [Issue #1703](https://github.com/grafana/grafana/issues/1703). Unsaved changes: Do not show for users with role `Viewer` +- [Issue #1675](https://github.com/grafana/grafana/issues/1675). Data source proxy: Fixed issue with Gzip enabled and data source proxy +- [Issue #1681](https://github.com/grafana/grafana/issues/1681). MySQL session: fixed problem using mysql as session store +- [Issue #1671](https://github.com/grafana/grafana/issues/1671). Data sources: Fixed issue with changing default data source (should not require full page load to take effect, now fixed) +- [Issue #1685](https://github.com/grafana/grafana/issues/1685). Search: Dashboard results should be sorted alphabetically +- [Issue #1673](https://github.com/grafana/grafana/issues/1673). Basic auth: Fixed issue when using basic auth proxy infront of Grafana + +# 2.0.0-Beta1 (2015-03-30) + +**Important Note** + +Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated backend server. Please read the [Documentation](http://docs.grafana.org) for more detailed about this SIGNIFICANT change to Grafana + +**New features** + +- [Issue #1623](https://github.com/grafana/grafana/issues/1623). Share Dashboard: Dashboard snapshot sharing (dash and data snapshot), save to local or save to public snapshot dashboard snapshots.raintank.io site +- [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embed a single graph on another web site +- [Issue #718](https://github.com/grafana/grafana/issues/718). Dashboard: When saving a dashboard and another user has made changes in between the user is prompted with a warning if he really wants to overwrite the other's changes +- [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views +- [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change `now` to be for example `now-1m`, useful when you want to ignore last minute because it contains incomplete data +- [Issue #171](https://github.com/grafana/grafana/issues/171). Panel: Different time periods, panels can override dashboard relative time and/or add a time shift +- [Issue #1488](https://github.com/grafana/grafana/issues/1488). Dashboard: Clone dashboard / Save as +- [Issue #1458](https://github.com/grafana/grafana/issues/1458). User: persisted user option for dark or light theme (no longer an option on a dashboard) +- [Issue #452](https://github.com/grafana/grafana/issues/452). Graph: Adds logarithmic scale option for base 10, base 16 and base 1024 + +**Enhancements** + +- [Issue #1366](https://github.com/grafana/grafana/issues/1366). Graph & Singlestat: Support for additional units, Fahrenheit (°F) and Celsius (°C), Humidity (%H), kW, watt-hour (Wh), kilowatt-hour (kWh), velocities (m/s, km/h, mpg, knot) +- [Issue #978](https://github.com/grafana/grafana/issues/978). Graph: Shared tooltip improvement, can now support metrics of different resolution/intervals +- [Issue #1297](https://github.com/grafana/grafana/issues/1297). Graphite: Added cumulative and minimumBelow graphite functions +- [Issue #1296](https://github.com/grafana/grafana/issues/1296). InfluxDB: Auto escape column names with special characters. Thanks @steven-aerts +- [Issue #1321](https://github.com/grafana/grafana/issues/1321). SingleStatPanel: You can now use template variables in pre & postfix +- [Issue #599](https://github.com/grafana/grafana/issues/599). Graph: Added right y axis label setting and graph support +- [Issue #1253](https://github.com/grafana/grafana/issues/1253). Graph & Singlestat: Users can now set decimal precision for legend and tooltips (override auto precision) +- [Issue #1255](https://github.com/grafana/grafana/issues/1255). Templating: Dashboard will now wait to load until all template variables that have refresh on load set or are initialized via url to be fully loaded and so all variables are in valid state before panels start issuing metric requests. +- [Issue #1344](https://github.com/grafana/grafana/issues/1344). OpenTSDB: Alias patterns (reference tag values), syntax is: \$tag_tagname or [[tag_tagname]] + +**Fixes** + +- [Issue #1298](https://github.com/grafana/grafana/issues/1298). InfluxDB: Fix handling of empty array in templating variable query +- [Issue #1309](https://github.com/grafana/grafana/issues/1309). Graph: Fixed issue when using zero as a grid threshold +- [Issue #1345](https://github.com/grafana/grafana/issues/1345). UI: Fixed position of confirm modal when scrolled down +- [Issue #1372](https://github.com/grafana/grafana/issues/1372). Graphite: Fix for nested complex queries, where a query references a query that references another query (ie the #[A-Z] syntax) +- [Issue #1363](https://github.com/grafana/grafana/issues/1363). Templating: Fix to allow custom template variables to contain white space, now only splits on ',' +- [Issue #1359](https://github.com/grafana/grafana/issues/1359). Graph: Fix for all series tooltip showing series with all null values when `Hide Empty` option is enabled +- [Issue #1497](https://github.com/grafana/grafana/issues/1497). Dashboard: Fixed memory leak when switching dashboards + +**Changes** + +- Dashboard title change & save will no longer create a new dashboard, it will just change the title. + +**OpenTSDB breaking change** + +- [Issue #1438](https://github.com/grafana/grafana/issues/1438). OpenTSDB: Automatic downsample interval passed to OpenTSDB (depends on timespan and graph width) +- NOTICE, Downsampling is now enabled by default, so if you have not picked a downsample aggregator in your metric query do so or your graphs will be misleading +- This will make Grafana a lot quicker for OpenTSDB users when viewing large time spans without having to change the downsample interval manually. + +**Tech** + +- [Issue #1311](https://github.com/grafana/grafana/issues/1311). Tech: Updated Font-Awesome from 3.2 to 4.2 + +# 1.9.1 (2014-12-29) + +**Enhancements** + +- [Issue #1028](https://github.com/grafana/grafana/issues/1028). Graph: New legend option `hideEmpty` to hide series with only null values from legend +- [Issue #1242](https://github.com/grafana/grafana/issues/1242). OpenTSDB: Downsample query field now supports interval template variable +- [Issue #1126](https://github.com/grafana/grafana/issues/1126). InfluxDB: Support more than 10 series name segments when using alias `$number` patterns + +**Fixes** + +- [Issue #1251](https://github.com/grafana/grafana/issues/1251). Graph: Fix for y axis and scaled units (GiB etc) caused rounding, for example 400 GiB instead of 378 GiB +- [Issue #1199](https://github.com/grafana/grafana/issues/1199). Graph: fix for series tooltip when one series is hidden/disabled +- [Issue #1207](https://github.com/grafana/grafana/issues/1207). Graphite: movingAverage / movingMedian parameter type improvement, now handles int and interval parameter + +# 1.9.0 (2014-12-02) + +**Enhancements** + +- [Issue #1130](https://github.com/grafana/grafana/issues/1130). SinglestatPanel: Added null point handling, and value to text mapping + +**Fixes** + +- [Issue #1087](https://github.com/grafana/grafana/issues/1087). Panel: Fixed IE9 crash due to angular drag drop +- [Issue #1093](https://github.com/grafana/grafana/issues/1093). SingleStatPanel: Fixed position for drilldown link tooltip when dashboard requires scrolling +- [Issue #1095](https://github.com/grafana/grafana/issues/1095). DrilldownLink: template variables in params property was not interpolated +- [Issue #1114](https://github.com/grafana/grafana/issues/1114). Graphite: Lexer fix, allow equal sign (=) in metric paths +- [Issue #1136](https://github.com/grafana/grafana/issues/1136). Graph: Fix to legend value Max and negative values +- [Issue #1150](https://github.com/grafana/grafana/issues/1150). SinglestatPanel: Fixed absolute drilldown link issue +- [Issue #1123](https://github.com/grafana/grafana/issues/1123). Firefox: Workaround for Firefox bug, caused input text fields to not be selectable and not have placeable cursor +- [Issue #1108](https://github.com/grafana/grafana/issues/1108). Graph: Fix for tooltip series order when series draw order was changed with zindex property + +# 1.9.0-rc1 (2014-11-17) + +**UI Improvements** + +- [Issue #770](https://github.com/grafana/grafana/issues/770). UI: Panel dropdown menu replaced with a new panel menu + +**Graph** + +- [Issue #877](https://github.com/grafana/grafana/issues/877). Graph: Smart auto decimal precision when using scaled unit formats +- [Issue #850](https://github.com/grafana/grafana/issues/850). Graph: Shared tooltip that shows multiple series & crosshair line, thx @toni-moreno +- [Issue #940](https://github.com/grafana/grafana/issues/940). Graph: New series style override option "Fill below to", useful to visualize max & min as a shadow for the mean +- [Issue #1030](https://github.com/grafana/grafana/issues/1030). Graph: Legend table display/look changed, now includes column headers for min/max/avg, and full width (unless on right side) +- [Issue #861](https://github.com/grafana/grafana/issues/861). Graph: Export graph time series data as csv file + +**New Panels** + +- [Issue #951](https://github.com/grafana/grafana/issues/951). SingleStat: New singlestat panel + +**Misc** + +- [Issue #864](https://github.com/grafana/grafana/issues/846). Panel: Share panel feature, get a link to panel with the current time range +- [Issue #938](https://github.com/grafana/grafana/issues/938). Panel: Plugin panels now reside outside of app/panels directory +- [Issue #952](https://github.com/grafana/grafana/issues/952). Help: Shortcut "?" to open help modal with list of all shortcuts +- [Issue #991](https://github.com/grafana/grafana/issues/991). ScriptedDashboard: data source services are now available in scripted dashboards, you can query data source for metric keys, generate dashboards, and even save them in a scripted dashboard (see scripted_gen_and_save.js for example) +- [Issue #1041](https://github.com/grafana/grafana/issues/1041). Panel: All panels can now have links to other dashboards or absolute links, these links are available in the panel menu. + +**Changes** + +- [Issue #1007](https://github.com/grafana/grafana/issues/1007). Graph: Series hide/show toggle changed to be default exclusive, so clicking on a series name will show only that series. (SHIFT or meta)+click will toggle hide/show. + +**OpenTSDB** + +- [Issue #930](https://github.com/grafana/grafana/issues/930). OpenTSDB: Adding counter max and counter reset value to open tsdb query editor, thx @rsimiciuc +- [Issue #917](https://github.com/grafana/grafana/issues/917). OpenTSDB: Templating support for OpenTSDB series name and tags, thx @mchataigner + +**InfluxDB** + +- [Issue #714](https://github.com/grafana/grafana/issues/714). InfluxDB: Support for sub second resolution graphs + +**Fixes** + +- [Issue #925](https://github.com/grafana/grafana/issues/925). Graph: bar width calculation fix for some edge cases (bars would render on top of each other) +- [Issue #505](https://github.com/grafana/grafana/issues/505). Graph: fix for second y axis tick unit labels wrapping on the next line +- [Issue #987](https://github.com/grafana/grafana/issues/987). Dashboard: Collapsed rows became invisible when hide controls was enabled + +======= + +# 1.8.1 (2014-09-30) + +**Fixes** + +- [Issue #855](https://github.com/grafana/grafana/issues/855). Graph: Fix for scroll issue in graph edit mode when dropdown goes below screen +- [Issue #847](https://github.com/grafana/grafana/issues/847). Graph: Fix for series draw order not being the same after hiding/unhiding series +- [Issue #851](https://github.com/grafana/grafana/issues/851). Annotations: Fix for annotations not reloaded when switching between 2 dashboards with annotations +- [Issue #846](https://github.com/grafana/grafana/issues/846). Edit panes: Issue when open row or json editor when scrolled down the page, unable to scroll and you did not see editor +- [Issue #840](https://github.com/grafana/grafana/issues/840). Import: Fixes to import from json file and import from graphite. Issues was lingering state from previous dashboard. +- [Issue #859](https://github.com/grafana/grafana/issues/859). InfluxDB: Fix for bug when saving dashboard where title is the same as slugified url id +- [Issue #852](https://github.com/grafana/grafana/issues/852). White theme: Fixes for hidden series legend text and disabled annotations color + +# 1.8.0 (2014-09-22) + +Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-released) for an overview of all improvements. + +**Fixes** + +- [Issue #802](https://github.com/grafana/grafana/issues/802). Annotations: Fix when using InfluxDB data source +- [Issue #795](https://github.com/grafana/grafana/issues/795). Chrome: Fix for display issue in chrome beta & chrome canary when entering edit mode +- [Issue #818](https://github.com/grafana/grafana/issues/818). Graph: Added percent y-axis format +- [Issue #828](https://github.com/grafana/grafana/issues/828). Elasticsearch: saving new dashboard with title equal to slugified url would cause it to deleted. +- [Issue #830](https://github.com/grafana/grafana/issues/830). Annotations: Fix for elasticsearch annotations and mapping nested fields + +# 1.8.0-RC1 (2014-09-12) + +**UI polish / changes** + +- [Issue #725](https://github.com/grafana/grafana/issues/725). UI: All modal editors are removed and replaced by an edit pane under menu. The look of editors is also updated and polished. Search dropdown is also shown as pane under menu and has seen some UI polish. + +**Filtering/Templating feature overhaul** + +- Filtering renamed to Templating, and filter items to variables +- Filter editing has gotten its own edit pane with much improved UI and options +- [Issue #296](https://github.com/grafana/grafana/issues/296). Templating: Can now retrieve variable values from a non-default data source +- [Issue #219](https://github.com/grafana/grafana/issues/219). Templating: Template variable value selection is now a typeahead autocomplete dropdown +- [Issue #760](https://github.com/grafana/grafana/issues/760). Templating: Extend template variable syntax to include \$variable syntax replacement +- [Issue #234](https://github.com/grafana/grafana/issues/234). Templating: Interval variable type for time intervals summarize/group by parameter, included "auto" option, and auto step counts option. +- [Issue #262](https://github.com/grafana/grafana/issues/262). Templating: Ability to use template variables for function parameters via custom variable type, can be used as parameter for movingAverage or scaleToSeconds for example +- [Issue #312](https://github.com/grafana/grafana/issues/312). Templating: Can now use template variables in panel titles +- [Issue #613](https://github.com/grafana/grafana/issues/613). Templating: Full support for InfluxDB, filter by part of series names, extract series substrings, nested queries, multiple where clauses! +- Template variables can be initialized from url, with var-my_varname=value, breaking change, before it was just my_varname. +- Templating and url state sync has some issues that are not solved for this release, see [Issue #772](https://github.com/grafana/grafana/issues/772) for more details. + +**InfluxDB Breaking changes** + +- To better support templating, fill(0) and group by time low limit some changes has been made to the editor and query model schema +- Currently some of these changes are breaking +- If you used custom condition filter you need to open the graph in edit mode, the editor will update the schema, and the queries should work again +- If you used a raw query you need to remove the time filter and replace it with \$timeFilter (this is done automatically when you switch from query editor to raw query, but old raw queries needs to updated) +- If you used group by and later removed the group by the graph could break, open in editor and should correct it +- InfluxDB annotation queries that used [[timeFilter]] should be updated to use \$timeFilter syntax instead +- Might write an upgrade tool to update dashboards automatically, but right now master (1.8) includes the above breaking changes + +**InfluxDB query editor enhancements** + +- [Issue #756](https://github.com/grafana/grafana/issues/756). InfluxDB: Add option for fill(0) and fill(null), integrated help in editor for why this option is important when stacking series +- [Issue #743](https://github.com/grafana/grafana/issues/743). InfluxDB: A group by time option for all queries in graph panel that supports a low limit for auto group by time, very important for stacking and fill(0) +- The above to enhancements solves the problems associated with stacked bars and lines when points are missing, these issues are solved: +- [Issue #673](https://github.com/grafana/grafana/issues/673). InfluxDB: stacked bars missing intermediate data points, unless lines also enabled +- [Issue #674](https://github.com/grafana/grafana/issues/674). InfluxDB: stacked chart ignoring series without latest values +- [Issue #534](https://github.com/grafana/grafana/issues/534). InfluxDB: No order in stacked bars mode + +**New features and improvements** + +- [Issue #117](https://github.com/grafana/grafana/issues/117). Graphite: Graphite query builder can now handle functions that multiple series as arguments! +- [Issue #281](https://github.com/grafana/grafana/issues/281). Graphite: Metric node/segment selection is now a textbox with autocomplete dropdown, allow for custom glob expression for single node segment without entering text editor mode. +- [Issue #304](https://github.com/grafana/grafana/issues/304). Dashboard: View dashboard json, edit/update any panel using json editor, makes it possible to quickly copy a graph from one dashboard to another. +- [Issue #578](https://github.com/grafana/grafana/issues/578). Dashboard: Row option to display row title even when the row is visible +- [Issue #672](https://github.com/grafana/grafana/issues/672). Dashboard: panel fullscreen & edit state is present in url, can now link to graph in edit & fullscreen mode. +- [Issue #709](https://github.com/grafana/grafana/issues/709). Dashboard: Small UI look polish to search results, made dashboard title link are larger +- [Issue #425](https://github.com/grafana/grafana/issues/425). Graph: New section in 'Display Styles' tab to override any display setting on per series bases (mix and match lines, bars, points, fill, stack, line width etc) +- [Issue #634](https://github.com/grafana/grafana/issues/634). Dashboard: Dashboard tags now in different colors (from fixed palette) determined by tag name. +- [Issue #685](https://github.com/grafana/grafana/issues/685). Dashboard: New config.js option to change/remove window title prefix. +- [Issue #781](https://github.com/grafana/grafana/issues/781). Dashboard: Title URL is now slugified for greater URL readability, works with both ES & InfluxDB storage, is backward compatible +- [Issue #785](https://github.com/grafana/grafana/issues/785). Elasticsearch: Support for full elasticsearch lucene search grammar when searching for dashboards, better async search +- [Issue #787](https://github.com/grafana/grafana/issues/787). Dashboard: time range can now be read from URL parameters, will override dashboard saved time range + +**Fixes** + +- [Issue #696](https://github.com/grafana/grafana/issues/696). Graph: Fix for y-axis format 'none' when values are in scientific notation (ex 2.3e-13) +- [Issue #733](https://github.com/grafana/grafana/issues/733). Graph: Fix for tooltip current value decimal precision when 'none' axis format was selected +- [Issue #697](https://github.com/grafana/grafana/issues/697). Graphite: Fix for Glob syntax in graphite queries ([1-9] and ?) that made the query editor / parser bail and fallback to a text box. +- [Issue #702](https://github.com/grafana/grafana/issues/702). Graphite: Fix for nonNegativeDerivative function, now possible to not include optional first parameter maxValue +- [Issue #277](https://github.com/grafana/grafana/issues/277). Dashboard: Fix for timepicker date & tooltip when UTC timezone selected. +- [Issue #699](https://github.com/grafana/grafana/issues/699). Dashboard: Fix for bug when adding rows from dashboard settings dialog. +- [Issue #723](https://github.com/grafana/grafana/issues/723). Dashboard: Fix for hide controls setting not used/initialized on dashboard load +- [Issue #724](https://github.com/grafana/grafana/issues/724). Dashboard: Fix for zoom out causing right hand "to" range to be set in the future. + +**Tech** + +- Upgraded from angularjs 1.1.5 to 1.3 beta 17; +- Switch from underscore to lodash +- helpers to easily unit test angularjs controllers and services +- Test coverage through coveralls +- Upgrade from jquery 1.8.0 to 2.1.1 (**Removes support for IE7 & IE8**) + +# 1.7.1 (unreleased) + +**Fixes** + +- [Issue #691](https://github.com/grafana/grafana/issues/691). Dashboard: Tooltip fixes, sometimes they would not show, and sometimes they would get stuck. +- [Issue #695](https://github.com/grafana/grafana/issues/695). Dashboard: Tooltip on goto home menu icon would get stuck after clicking on it + +# 1.7.0 (2014-08-11) + +**Fixes** + +- [Issue #652](https://github.com/grafana/grafana/issues/652). Timepicker: Entering custom date range impossible when refresh is low (now is constantly reset) +- [Issue #450](https://github.com/grafana/grafana/issues/450). Graph: Tooltip does not disappear sometimes and would get stuck +- [Issue #655](https://github.com/grafana/grafana/issues/655). General: Auto refresh not initiated / started after dashboard loading +- [Issue #657](https://github.com/grafana/grafana/issues/657). General: Fix for refresh icon in IE browsers +- [Issue #661](https://github.com/grafana/grafana/issues/661). Annotations: Elasticsearch querystring with filter template replacements was not interpolated +- [Issue #660](https://github.com/grafana/grafana/issues/660). OpenTSDB: fix opentsdb queries that returned more than one series + +**Change** + +- [Issue #681](https://github.com/grafana/grafana/issues/681). Dashboard: The panel error bar has been replaced with a small error indicator, this indicator does not change panel height and is a lot less intrusive. Hover over it for short details, click on it for more details. + +# 1.7.0-rc1 (2014-08-05) + +**New features or improvements** + +- [Issue #581](https://github.com/grafana/grafana/issues/581). InfluxDB: Add continuous query in series results (series typeahead). +- [Issue #584](https://github.com/grafana/grafana/issues/584). InfluxDB: Support for alias & alias patterns when using raw query mode +- [Issue #394](https://github.com/grafana/grafana/issues/394). InfluxDB: Annotation support +- [Issue #633](https://github.com/grafana/grafana/issues/633). InfluxDB: InfluxDB can now act as a datastore for dashboards +- [Issue #610](https://github.com/grafana/grafana/issues/610). InfluxDB: Support for InfluxdB v0.8 list series response schema (series typeahead) +- [Issue #525](https://github.com/grafana/grafana/issues/525). InfluxDB: Enhanced series aliasing (legend names) with pattern replacements +- [Issue #266](https://github.com/grafana/grafana/issues/266). Graphite: New option cacheTimeout to override graphite default memcache timeout +- [Issue #606](https://github.com/grafana/grafana/issues/606). General: New global option in config.js to specify admin password (useful to hinder users from accidentally make changes) +- [Issue #201](https://github.com/grafana/grafana/issues/201). Annotations: Elasticsearch data source support for events +- [Issue #344](https://github.com/grafana/grafana/issues/344). Annotations: Annotations can now be fetched from non default data sources +- [Issue #631](https://github.com/grafana/grafana/issues/631). Search: max_results config.js option & scroll in search results (To show more or all dashboards) +- [Issue #511](https://github.com/grafana/grafana/issues/511). Text panel: Allow [[..]] filter notation in all text panels (markdown/html/text) +- [Issue #136](https://github.com/grafana/grafana/issues/136). Graph: New legend display option "Align as table" +- [Issue #556](https://github.com/grafana/grafana/issues/556). Graph: New legend display option "Right side", will show legend to the right of the graph +- [Issue #604](https://github.com/grafana/grafana/issues/604). Graph: New axis format, 'bps' (SI unit in steps of 1000) useful for network gear metrics +- [Issue #626](https://github.com/grafana/grafana/issues/626). Graph: Downscale y axis to more precise unit, value of 0.1 for seconds format will be formatted as 100 ms. Thanks @kamaradclimber +- [Issue #618](https://github.com/grafana/grafana/issues/618). OpenTSDB: Series alias option to override metric name returned from opentsdb. Thanks @heldr + +**Documentation** + +- [Issue #635](https://github.com/grafana/grafana/issues/635). Docs for features and changes in v1.7, new troubleshooting guide, new Getting started guide, improved install & config guide. + +**Changes** + +- [Issue #536](https://github.com/grafana/grafana/issues/536). Graphite: Use unix epoch for Graphite from/to for absolute time ranges +- [Issue #641](https://github.com/grafana/grafana/issues/536). General: Dashboard save temp copy feature settings moved from dashboard to config.js, default is enabled, and ttl to 30 days +- [Issue #532](https://github.com/grafana/grafana/issues/532). Schema: Dashboard schema changes, "Unsaved changes" should not appear for schema changes. All changes are backward compatible with old schema. + +**Fixes** + +- [Issue #545](https://github.com/grafana/grafana/issues/545). Graph: Fix formatting negative values (axis formats, legend values) +- [Issue #460](https://github.com/grafana/grafana/issues/460). Graph: fix for max legend value when max value is zero +- [Issue #628](https://github.com/grafana/grafana/issues/628). Filtering: Fix for nested filters, changing a child filter could result in infinite recursion in some cases +- [Issue #528](https://github.com/grafana/grafana/issues/528). Graphite: Fix for graphite expressions parser failure when metric expressions starts with curly brace segment + +# 1.6.1 (2014-06-24) + +**New features or improvements** + +- [Issue #360](https://github.com/grafana/grafana/issues/360). Ability to set y min/max for right y-axis (RR #519) + +**Fixes** + +- [Issue #500](https://github.com/grafana/grafana/issues/360). Fixes regex InfluxDB queries introduced in 1.6.0 +- [Issue #506](https://github.com/grafana/grafana/issues/506). Bug in when using % sign in legends (aliases), fixed by removing url decoding of metric names +- [Issue #522](https://github.com/grafana/grafana/issues/522). Series names and column name typeahead cache fix +- [Issue #504](https://github.com/grafana/grafana/issues/504). Fixed influxdb issue with raw query that caused wrong value column detection +- [Issue #526](https://github.com/grafana/grafana/issues/526). Default property that marks which data source is default in config.js is now optional +- [Issue #342](https://github.com/grafana/grafana/issues/342). Auto-refresh caused 2 refreshes (and hence multiple queries) each time (at least in firefox) + +# 1.6.0 (2014-06-16) + +#### New features or improvements + +- [Issue #427](https://github.com/grafana/grafana/issues/427). New Y-axis formater for metric values that represent seconds, Thanks @jippi +- [Issue #390](https://github.com/grafana/grafana/issues/390). Allow special characters in series names (influxdb data source), Thanks @majst01 +- [Issue #428](https://github.com/grafana/grafana/issues/428). Refactoring of filterSrv, Thanks @Tetha +- [Issue #445](https://github.com/grafana/grafana/issues/445). New config for playlist feature. Set playlist_timespan to set default playlist interval, Thanks @rmca +- [Issue #461](https://github.com/grafana/grafana/issues/461). New graphite function definition added isNonNull, Thanks @tmonk42 +- [Issue #455](https://github.com/grafana/grafana/issues/455). New InfluxDB function difference add to function dropdown +- [Issue #459](https://github.com/grafana/grafana/issues/459). Added parameter to keepLastValue graphite function definition (default 100) + [Issue #418](https://github.com/grafana/grafana/issues/418). to the browser cache when upgrading grafana and improve load performance +- [Issue #327](https://github.com/grafana/grafana/issues/327). Partial support for url encoded metrics when using Graphite data source. Thanks @axe-felix +- [Issue #473](https://github.com/grafana/grafana/issues/473). Improvement to InfluxDB query editor and function/value column selection +- [Issue #375](https://github.com/grafana/grafana/issues/375). Initial support for filtering (templated queries) for InfluxDB. Thanks @mavimo +- [Issue #475](https://github.com/grafana/grafana/issues/475). Row editing and adding new panel is now a lot quicker and easier with the new row menu +- [Issue #211](https://github.com/grafana/grafana/issues/211). New data source! Initial support for OpenTSDB, Thanks @mpage +- [Issue #492](https://github.com/grafana/grafana/issues/492). Improvement and polish to the OpenTSDB query editor +- [Issue #441](https://github.com/grafana/grafana/issues/441). Influxdb group by support, Thanks @piis3 +- improved asset (css/js) build pipeline, added revision to css and js. Will remove issues related + +#### Changes + +- [Issue #475](https://github.com/grafana/grafana/issues/475). Add panel icon and Row edit button is replaced by the Row edit menu +- New graphs now have a default empty query +- Add Row button now creates a row with default height of 250px (no longer opens dashboard settings modal) +- Clean up of config.sample.js, graphiteUrl removed (still works, but deprecated, removed in future) + Use data sources config instead. panel_names removed from config.js. Use plugins.panels to add custom panels +- Graphite panel is now renamed graph (Existing dashboards will still work) + +#### Fixes + +- [Issue #126](https://github.com/grafana/grafana/issues/126). Graphite query lexer change, can now handle regex parameters for aliasSub function +- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having multiple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh in between. +- [Issue #412](https://github.com/grafana/grafana/issues/412). After a filter option is changed and a nested template param is reloaded, if the current value exists after the options are reloaded the current selected value is kept. +- [Issue #460](https://github.com/grafana/grafana/issues/460). Legend Current value did not display when value was zero +- [Issue #328](https://github.com/grafana/grafana/issues/328). Fix to series toggling bug that caused annotations to be hidden when toggling/hiding series. +- [Issue #293](https://github.com/grafana/grafana/issues/293). Fix for graphite function selection menu that some times draws outside screen. It now displays upward +- [Issue #350](https://github.com/grafana/grafana/issues/350). Fix for exclusive series toggling (hold down CTRL, SHIFT or META key) and left click a series for exclusive toggling +- [Issue #472](https://github.com/grafana/grafana/issues/472). CTRL does not work on MAC OSX but SHIFT or META should (depending on browser) + +# 1.5.4 (2014-05-13) + +### New features and improvements + +- InfluxDB enhancement: support for multiple hosts (with retries) and raw queries ([Issue #318](https://github.com/grafana/grafana/issues/318), thx @toddboom) +- Added rounding for graphites from and to time range filters + for very short absolute ranges ([Issue #320](https://github.com/grafana/grafana/issues/320)) +- Increased resolution for graphite datapoints (maxDataPoints), now equal to panel pixel width. ([Issue #5](https://github.com/grafana/grafana/issues/5)) +- Improvement to influxdb query editor, can now add where clause and alias ([Issue #331](https://github.com/grafana/grafana/issues/331), thanks @mavimo) +- New config setting for graphite data source to control if json render request is POST or GET ([Issue #345](https://github.com/grafana/grafana/issues/345)) +- Unsaved changes warning feature ([Issue #324](https://github.com/grafana/grafana/issues/324)) +- Improvement to series toggling, CTRL+MouseClick on series name will now hide all others ([Issue #350](https://github.com/grafana/grafana/issues/350)) + +### Changes + +- Graph default setting for Y-Min changed from zero to auto scaling (will not effect existing dashboards). ([Issue #386](https://github.com/grafana/grafana/issues/386)) - thx @kamaradclimber + +### Fixes + +- Fixes to filters and "All" option. It now never uses "\*" as value, but all options in a {node1, node2, node3} expression ([Issue #228](https://github.com/grafana/grafana/issues/228), #359) +- Fix for InfluxDB query generation with columns containing dots or dashes ([Issue #369](https://github.com/grafana/grafana/issues/369), #348) - Thanks to @jbripley + +# 1.5.3 (2014-04-17) + +- Add support for async scripted dashboards ([Issue #274](https://github.com/grafana/grafana/issues/274)) +- Text panel now accepts html (for links to other dashboards, etc) ([Issue #236](https://github.com/grafana/grafana/issues/236)) +- Fix for Text panel, now changes take effect directly ([Issue #251](https://github.com/grafana/grafana/issues/251)) +- Fix when adding functions without params that did not cause graph to update ([Issue #267](https://github.com/grafana/grafana/issues/267)) +- Graphite errors are now much easier to see and troubleshoot with the new inspector ([Issue #265](https://github.com/grafana/grafana/issues/265)) +- Use influxdb aliases to distinguish between multiple columns ([Issue #283](https://github.com/grafana/grafana/issues/283)) +- Correction to ms axis formater, now formats days correctly. ([Issue #189](https://github.com/grafana/grafana/issues/189)) +- Css fix for Firefox and using top menu dropdowns in panel fullscreen / edit mode ([Issue #106](https://github.com/grafana/grafana/issues/106)) +- Browser page title is now Grafana - {{dashboard title}} ([Issue #294](https://github.com/grafana/grafana/issues/294)) +- Disable auto refresh zooming in (every time you change to an absolute time range), refresh will be restored when you change time range back to relative ([Issue #282](https://github.com/grafana/grafana/issues/282)) +- More graphite functions + +# 1.5.2 (2014-03-24) + +### New Features and improvements + +- Support for second optional params for functions like aliasByNode ([Issue #167](https://github.com/grafana/grafana/issues/167)). Read the wiki on the [Function Editor](https://github.com/torkelo/grafana/wiki/Graphite-Function-Editor) for more info. +- More functions added to InfluxDB query editor ([Issue #218](https://github.com/grafana/grafana/issues/218)) +- Filters can now be used inside other filters (templated segments) ([Issue #128](https://github.com/grafana/grafana/issues/128)) +- More graphite functions added + +### Fixes + +- Float arguments now work for functions like scale ([Issue #223](https://github.com/grafana/grafana/issues/223)) +- Fix for graphite function editor, the graph & target was not updated after adding a function and leaving default params as is #191 + +The zip files now contains a sub folder with project name and version prefix. ([Issue #209](https://github.com/grafana/grafana/issues/209)) + +# 1.5.1 (2014-03-10) + +### Fixes + +- maxDataPoints must be an integer #184 (thanks @frejsoya for fixing this) + +For people who are find Grafana slow for large time spans or high resolution metrics. This is most likely due to graphite returning a large number of datapoints. The maxDataPoints parameter solves this issue. For maxDataPoints to work you need to run the latest graphite-web (some builds of 0.9.12 does not include this feature). + +Read this for more info: +[Performance for large time spans](https://github.com/torkelo/grafana/wiki/Performance-for-large-time-spans) + +# 1.5.0 (2014-03-09) + +### New Features and improvements + +- New function editor [video demo](http://youtu.be/I90WHRwE1ZM) ([Issue #178](https://github.com/grafana/grafana/issues/178)) +- Links to function documentation from function editor ([Issue #3](https://github.com/grafana/grafana/issues/3)) +- Reorder functions ([Issue #130](https://github.com/grafana/grafana/issues/130)) +- [Initial support for InfluxDB](https://github.com/torkelo/grafana/wiki/InfluxDB) as metric data source (#103), need feedback! +- [Dashboard playlist](https://github.com/torkelo/grafana/wiki/Dashboard-playlist) ([Issue #36](https://github.com/grafana/grafana/issues/36)) +- When adding aliasByNode smartly set node number ([Issue #175](https://github.com/grafana/grafana/issues/175)) +- Support graphite identifiers with embedded colons ([Issue #173](https://github.com/grafana/grafana/issues/173)) +- Typeahead & autocomplete when adding new function ([Issue #164](https://github.com/grafana/grafana/issues/164)) +- More graphite function definitions +- Make "ms" axis format include hour, day, weeks, month and year ([Issue #149](https://github.com/grafana/grafana/issues/149)) +- Microsecond axis format ([Issue #146](https://github.com/grafana/grafana/issues/146)) +- Specify template parameters in URL ([Issue #123](https://github.com/grafana/grafana/issues/123)) + +### Fixes + +- Basic Auth fix ([Issue #152](https://github.com/grafana/grafana/issues/152)) +- Fix to annotations with graphite source & null values ([Issue #138](https://github.com/grafana/grafana/issues/138)) + +# 1.4.0 (2014-02-21) + +### New Features + +- #44 Annotations! Required a lot of work to get right. Read wiki article for more info. Supported annotations data sources are graphite metrics and graphite events. Support for more will be added in the future! +- #35 Support for multiple graphite servers! (Read wiki article for more) +- #116 Back to dashboard link in top menu to easily exist full screen / edit mode. +- #114, #97 Legend values now use the same y axes formatter +- #77 Improvements and polish to the light theme + +### Changes + +- #98 Stack is no longer by default turned on in graph display settings. +- Hide controls (Ctrl+h) now hides the sub menu row (where filtering, and annotations are). So if you had filtering enabled and hide controls enabled you will not see the filtering sub menu. + +### Fixes: + +- #94 Fix for bug that caused dashboard settings to sometimes not contain timepicker tab. +- #110 Graph with many many metrics caused legend to push down graph editor below screen. You can now scroll in edit mode & full screen mode for graphs with lots of series & legends. +- #104 Improvement to graphite target editor, select wildcard now gives you a "select metric" link for the next node. +- #105 Added zero as a possible node value in groupByAlias function + +# 1.3.0 (2014-02-13) + +### New features or improvements + +- #86 Dashboard tags and search (see wiki article for details) +- #54 Enhancement to filter / template. "Include All" improvement +- #82 Dashboard search result sorted in alphabetical order + +### Fixes + +- #91 Custom date selector is one day behind +- #89 Filter / template does not work after switching dashboard +- #88 Closed / Minimized row css bug +- #85 Added all parameters to summarize function +- #83 Stack as percent should now work a lot better! + +# 1.2.0 (2014-02-10) + +### New features + +- #70 Grid Thresholds (warning and error regions or lines in graph) +- #72 Added an example of a scripted dashboard and a short wiki article documenting scripted dashboards. + +### Fixes + +- #81 Grid min/max values are ignored bug +- #80 "stacked as percent" graphs should always use "max" value of 100 bug +- #73 Left Y format change did not work +- #42 Fixes to grid min/max auto scaling +- #69 Fixes to lexer/parser for metrics segments like "10-20". +- #67 Allow decimal input for scale function +- #68 Bug when trying to open dashboard while in edit mode + +# 1.1.0 (2014-02-06) + +### New features: + +- #22 Support for native graphite png renderer, does not support click and select zoom yet +- #60 Support for legend values (cactiStyle, min, max, current, total, avg). The options for these are found in the new "Axes & Grid" tab for now. +- #62 There is now a "New" button in the search/open dashboard view to quickly open a clean empty dashboard. +- #55 Basic auth is now supported for elastic search as well +- some new function definitions added (will focus more on this for next release). + +### Fixes + +- #45 zero values from graphite was handled as null. +- #63 Kibana / Grafana on same host would use same localStorage keys, now fixed +- #46 Impossible to edit graph without a name fixed. +- #24 fix for dashboard search when elastic search is configured to disable \_all field. +- #38 Improvement to lexer / parser to support pure numeric literals in metric segments + +Thanks to everyone who contributed fixes and provided feedback :+1: + +# 1.0.4 (2014-01-24) + +- [Issue #28](https://github.com/grafana/grafana/issues/28) - Relative time range caused 500 graphite error in some cases (thx rsommer for the fix) + +# 1.0.3 (2014-01-23) + +- #9 Add Y-axis format for milliseconds +- #16 Add support for Basic Auth (use http://username:password@yourgraphitedomain.com) +- #13 Relative time ranges now uses relative time ranges when issuing graphite query + +# 1.0.2 (2014-01-21) + +- [Issue #12](https://github.com/grafana/grafana/issues/12), should now work ok without ElasticSearch + +# 1.0.1 (2014-01-21) + +- Resize fix +- Improvements to drag & drop +- Added a few graphite function definitions +- Fixed duplicate panel bug +- Updated default dashboard with welcome message and randomWalk graph + +# 1.0.0 (2014-01-19) + +First public release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3d4caa4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at conduct@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9d21822 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to Grafana + +Thank you for your interest in contributing to Grafana! We welcome all people who want to contribute in a healthy and constructive manner within our community. To help us create a safe and positive community experience for all, we require all participants to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). + +This document is a guide to help you through the process of contributing to Grafana. + +## Become a contributor + +You can contribute to Grafana in several ways. Here are some examples: + +- Contribute to the Grafana codebase. +- Report and triage bugs. +- Develop community plugins and dashboards. +- Write technical documentation and blog posts, for users and contributors. +- Organize meetups and user groups in your local area. +- Help others by answering questions about Grafana. + +For more ways to contribute, check out the [Open Source Guides](https://opensource.guide/how-to-contribute/). + +### Report bugs + +Before submitting a new issue, try to make sure someone hasn't already reported the problem. Look through the [existing issues](https://github.com/grafana/grafana/issues) for similar issues. + +Report a bug by submitting a [bug report](https://github.com/grafana/grafana/issues/new?labels=type%3A+bug&template=1-bug_report.md). Make sure that you provide as much information as possible on how to reproduce the bug. + +Follow the issue template and add additional information that will help us replicate the problem. + +For data visualization issues: +- Query results from the inspect drawer (data tab & query inspector) +- Panel settings can be extracted in the panel inspect drawer JSON tab + +For a dashboard related issues: +- Dashboard JSON can be found in the dashboard settings JSON model view + +For authentication and alerting Grafana server logs are useful. + +#### Security issues + +If you believe you've found a security vulnerability, please read our [security policy](https://github.com/grafana/grafana/security/policy) for more details. + +### Suggest enhancements + +If you have an idea of how to improve Grafana, submit an [enhancement request](https://github.com/grafana/grafana/issues/new?labels=type%3A+feature+request&template=2-feature_request.md). + +We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?labels=type%3A+accessibility&template=3-accessibility.md) to help us understand what we can improve. + +### Triage issues + +If you don't have the knowledge or time to code, consider helping with _issue triage_. The community will thank you for saving them time by spending some of yours. + +Read more about the ways you can [Triage issues](/contribute/triage-issues.md). + +### Answering questions + +If you have a question and you can't find the answer in the [documentation](https://grafana.com/docs/), the next step is to ask it on the [community site](https://community.grafana.com/). + +It's important to us to help these users, and we'd love your help. Sign up to our [community site](https://community.grafana.com/), and start helping other Grafana users by answering their questions. + +### Your first contribution + +Unsure where to begin contributing to Grafana? Start by browsing issues labeled `beginner friendly` or `help wanted`. + +- [Beginner-friendly](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) issues are generally straightforward to complete. +- [Help wanted](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) issues are problems we would like the community to help us with regardless of complexity. + +If you're looking to make a code change, see how to set up your environment for [local development](contribute/developer-guide.md). + +When you're ready to contribute, it's time to [Create a pull request](/contribute/create-pull-request.md). + +#### Contributor License Agreement (CLA) + +Before we can accept your pull request, you need to [sign our CLA](https://grafana.com/docs/grafana/latest/developers/cla/). If you haven't, our CLA assistant prompts you to when you create your pull request. + +## Where do I go from here? + +- Set up your [development environment](contribute/developer-guide.md). +- Learn how to [contribute documentation](contribute/documentation.md). +- Get started [developing plugins](https://grafana.com/docs/grafana/latest/developers/plugins/) for Grafana. +- Look through the resources in the [contribute](https://github.com/grafana/grafana/tree/master/contribute) folder. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bea8dd4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,85 @@ +FROM node:14.15.5-alpine3.13 as js-builder + +WORKDIR /usr/src/app/ + +COPY package.json yarn.lock ./ +COPY packages packages + +RUN yarn install --pure-lockfile --no-progress + +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY public public +COPY tools tools +COPY scripts scripts +COPY emails emails + +ENV NODE_ENV production +RUN yarn build + +FROM golang:1.16.1-alpine3.13 as go-builder + +RUN apk add --no-cache gcc g++ + +WORKDIR $GOPATH/src/github.com/grafana/grafana + +COPY go.mod go.sum ./ + +RUN go mod verify + +COPY pkg pkg +COPY build.go package.json ./ + +RUN go run build.go build + +# Final stage +FROM alpine:3.13 + +LABEL maintainer="Grafana team " + +ARG GF_UID="472" +ARG GF_GID="0" + +ENV PATH="/usr/share/grafana/bin:$PATH" \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +RUN apk add --no-cache ca-certificates bash tzdata && \ + apk add --no-cache openssl musl-utils + +COPY conf ./conf + +RUN if [ ! $(getent group "$GF_GID") ]; then \ + addgroup -S -g $GF_GID grafana; \ + fi + +RUN export GF_GID_NAME=$(getent group $GF_GID | cut -d':' -f1) && \ + mkdir -p "$GF_PATHS_HOME/.aws" && \ + adduser -S -u $GF_UID -G "$GF_GID_NAME" grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp "$GF_PATHS_HOME/conf/sample.ini" "$GF_PATHS_CONFIG" && \ + cp "$GF_PATHS_HOME/conf/ldap.toml" /etc/grafana/ldap.toml && \ + chown -R "grafana:$GF_GID_NAME" "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ + chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" + +COPY --from=go-builder /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-server /go/src/github.com/grafana/grafana/bin/linux-amd64/grafana-cli ./bin/ +COPY --from=js-builder /usr/src/app/public ./public +COPY --from=js-builder /usr/src/app/tools ./tools + +EXPOSE 3000 + +COPY ./packaging/docker/run.sh /run.sh + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu new file mode 100644 index 0000000..f4775e0 --- /dev/null +++ b/Dockerfile.ubuntu @@ -0,0 +1,78 @@ +FROM node:14.15.1-slim AS js-builder + +WORKDIR /usr/src/app/ + +COPY package.json yarn.lock ./ +COPY packages packages + +RUN apt-get update && apt-get install -yq git +RUN yarn install --pure-lockfile + +COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ +COPY public public +COPY tools tools +COPY scripts scripts +COPY emails emails + +ENV NODE_ENV production +RUN yarn build + +FROM golang:1.15.1 AS go-builder + +WORKDIR /src/grafana + +COPY go.mod go.sum ./ + +RUN go mod verify + +COPY build.go package.json ./ +COPY pkg pkg/ + +RUN go run build.go build + +FROM ubuntu:20.04 + +LABEL maintainer="Grafana team " +EXPOSE 3000 + +ARG GF_UID="472" +ARG GF_GID="472" + +ENV PATH="/usr/share/grafana/bin:$PATH" \ + GF_PATHS_CONFIG="/etc/grafana/grafana.ini" \ + GF_PATHS_DATA="/var/lib/grafana" \ + GF_PATHS_HOME="/usr/share/grafana" \ + GF_PATHS_LOGS="/var/log/grafana" \ + GF_PATHS_PLUGINS="/var/lib/grafana/plugins" \ + GF_PATHS_PROVISIONING="/etc/grafana/provisioning" + +WORKDIR $GF_PATHS_HOME + +COPY conf conf + +# curl should be part of the image +RUN apt-get update && apt-get install -y ca-certificates curl + +RUN mkdir -p "$GF_PATHS_HOME/.aws" && \ + addgroup --system --gid $GF_GID grafana && \ + adduser --uid $GF_UID --system --ingroup grafana grafana && \ + mkdir -p "$GF_PATHS_PROVISIONING/datasources" \ + "$GF_PATHS_PROVISIONING/dashboards" \ + "$GF_PATHS_PROVISIONING/notifiers" \ + "$GF_PATHS_PROVISIONING/plugins" \ + "$GF_PATHS_LOGS" \ + "$GF_PATHS_PLUGINS" \ + "$GF_PATHS_DATA" && \ + cp conf/sample.ini "$GF_PATHS_CONFIG" && \ + cp conf/ldap.toml /etc/grafana/ldap.toml && \ + chown -R grafana:grafana "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" && \ + chmod -R 777 "$GF_PATHS_DATA" "$GF_PATHS_HOME/.aws" "$GF_PATHS_LOGS" "$GF_PATHS_PLUGINS" "$GF_PATHS_PROVISIONING" + +COPY --from=go-builder /src/grafana/bin/linux-amd64/grafana-server /src/grafana/bin/linux-amd64/grafana-cli bin/ +COPY --from=js-builder /usr/src/app/public public +COPY --from=js-builder /usr/src/app/tools tools + +COPY packaging/docker/run.sh / + +USER grafana +ENTRYPOINT [ "/run.sh" ] diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..ea66461 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,205 @@ +# Governance + +This document describes the rules and governance of the project. It is meant to be followed by all the developers of the project and the Grafana community. Common terminology used in this governance document are listed below: + +- **Team members**: Any members of the private [grafana-team][team] Google group. + +- **Maintainers**: Maintainers lead an individual project or parts thereof ([`MAINTAINERS.md`][maintainers]). + +- **Projects**: A single repository in the Grafana GitHub organization and listed below is referred to as a project: + + - clock-panel + - devtools + - gel-app + - grafana + - grafana-github-datasource + - grafana-image-renderer + - grafana-kiosk + - grafana-plugin-sdk-go + - grafana-polystat-panel + - grafonnet-lib + - kairosdb-datasource + - piechart-panel + - simple-angular-panel + - simple-app-plugin + - simple-datasource + - simple-datasource-backend + - simple-json-backend-datasource + - simple-json-datasource + - simple-react-panel + - strava-datasource + - tutorials + - worldmap-panel + +- **The Grafana project**: The sum of all activities performed under this governance, concerning one or more repositories or the community. + +## Values + +The Grafana developers and community are expected to follow the values defined in the Grafana Code of Conduct. Furthermore, the Grafana community strives for kindness, giving feedback effectively, and building a welcoming environment. The Grafana developers generally decide by consensus and only resort to conflict resolution by a majority vote if consensus cannot be reached. + +## Projects + +Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the Grafana Labs blog. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. + +## Decision making + +### Team members + +Team member status may be given to those who have made ongoing contributions to the Grafana project for at least 3 months. This is usually in the form of code improvements and/or notable work on documentation, but organizing events or user support could also be taken into account. + +New members may be proposed by any existing member by email to [grafana-team][team]. It is highly desirable to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a formal [supermajority vote](#supermajority-vote). + +If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm or deny their acceptance of team membership. This email will also be CC'd to [grafana-team][team] for record-keeping purposes. + +If they choose to accept, the [onboarding](#onboarding) procedure is followed. + +Team members may retire at any time by emailing [the team][team]. + +Team members can be removed by [supermajority vote](#supermajority-vote) on [the team mailing list][team]. +For this vote, the member in question is not eligible to vote and does not count towards the quorum. +Any removal vote can cover only one single person. + +Upon death of a member, they leave the team automatically. + +In case a member leaves, the [offboarding](#offboarding) procedure is applied. + +The current team members are: + +- Alexander Zobnin ([Grafana Labs](https://grafana.com/)) +- Alex Khomenko ([Grafana Labs](https://grafana.com/)) +- Andrej Ocenas ([Grafana Labs](https://grafana.com/)) +- Arve Knudsen ([Grafana Labs](https://grafana.com/)) +- Brian Gann ([Grafana Labs](https://grafana.com/)) +- Carl Bergquist ([Grafana Labs](https://grafana.com/)) +- Chris Trott ([Grafana Labs](https://grafana.com/)) +- Daniel Lee ([Grafana Labs](https://grafana.com/)) +- David Kaltschmidt ([Grafana Labs](https://grafana.com/)) +- Diana Payton ([Grafana Labs](https://grafana.com/)) +- Diana Sarlinska ([Grafana Labs](https://grafana.com/)) +- Dominik Prokop ([Grafana Labs](https://grafana.com/)) +- Emil Tullstedt ([Grafana Labs](https://grafana.com/)) +- Fredrik Enestad ([Soundtrack Your Brand](https://www.soundtrackyourbrand.com/)) +- Hugo Häggmark ([Grafana Labs](https://grafana.com/)) +- Ivana Huckova ([Grafana Labs](https://grafana.com/)) +- Jeroen Op 't Eynde ([Grafana Labs](https://grafana.com/)) +- Jessica Müller ([Grafana Labs](https://grafana.com/)) +- Julien Pivotto ([Inuits](https://inuits.eu/)) +- Kay Delaney ([Grafana Labs](https://grafana.com/)) +- Kyle Brandt ([Grafana Labs](https://grafana.com/)) +- Leonard Gram ([Grafana Labs](https://grafana.com/)) +- Lukas Siatka ([Grafana Labs](https://grafana.com/)) +- Malcolm Holmes ([Grafana Labs](https://grafana.com/)) +- Marcus Andersson ([Grafana Labs](https://grafana.com/)) +- Marcus Efraimsson ([Grafana Labs](https://grafana.com/)) +- Marcus Olsson ([Grafana Labs](https://grafana.com/)) +- Mitsuhiro Tanda ([GREE](https://corp.gree.net/jp/en/)) +- Patrick O’Carroll ([Grafana Labs](https://grafana.com/)) +- Peter Holmberg ([Grafana Labs](https://grafana.com/)) +- Richard Hartmann ([Grafana Labs](https://grafana.com/)) +- Ryan McKinley ([Grafana Labs](https://grafana.com/)) +- Sofia Papagiannaki ([Grafana Labs](https://grafana.com/)) +- Stephanie Closson ([Grafana Labs](https://grafana.com/)) +- Tobias Skarhed ([Grafana Labs](https://grafana.com/)) +- Torkel Ödegaard ([Grafana Labs](https://grafana.com/)) +- Utkarsh Bhatnagar ([Tinder](https://www.tinder.com/)) + +### Maintainers + +Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable maintainers that, for whatever reason, are not yet team members. + +Changes in maintainership have to be announced on the [developers mailing list][devs]. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. + +Maintainers are granted commit rights to all projects covered by this governance. + +A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team member to take over the project. + +A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This includes coordinating who handles which issues and pull requests. + +### Technical decisions + +Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the Grafana project should be discussed and made on the [Grafana developer mailing list][devs]. + +Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved by [majority vote](#majority-vote). + +### Governance changes + +Changes to this document are made by Grafana Labs. + +### Other matters + +Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [developer mailing list][devs]. + +## Voting + +The Grafana project usually runs by informal consensus, however sometimes a formal decision must be made. + +Depending on the subject matter, as laid out [above](#decision-making), different methods of voting are used. + +For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. A vote may be called and closed early if enough votes have come in one way so that further votes cannot change the final decision. + +In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced removal of a team member, in which said member is not eligible to vote. + +Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [developer mailing list][devs]. + +For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to [team members](#team-members). + +### Consensus + +The default decision making mechanism for the Grafana project is [rough][rough] consensus. This means that any decision on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been considered but not necessarily accommodated. + +Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [developers mailing list][devs] at any time and by anyone. + +Consensus decisions can never override or go against the spirit of an earlier explicit vote. + +If any [team member](#team-members) raises objections, the team members work together towards a solution that all involved can accept. This solution is again subject to rough consensus. + +In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) may call a formal [majority vote](#majority-vote). + +### Majority vote + +Majority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if more vote in favor than against. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +### Supermajority vote + +Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +## On- / Offboarding + +### Onboarding + +The new member is + +- added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. +- announced on the [developers mailing list][devs] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. +- added to the projects with commit rights. +- added to the [team mailing list][team]. + +### Offboarding + +The ex-member is + +- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. In case of forced removal, no approval is needed. +- removed from the projects. Optionally, they can retain maintainership of one or more repositories if the [team](#team-members) agrees. +- removed from the team mailing list and demoted to a normal member of the other mailing lists. +- not allowed to call themselves an active team member any more, nor allowed to imply this to be the case. +- added to a list of previous members if they so choose. + +If needed, we reserve the right to publicly announce removal. + +[coc]: https://github.com/grafana/grafana/blob/master/CODE_OF_CONDUCT.md +[devs]: https://groups.google.com/forum/#!forum/grafana-developers +[maintainers]: https://github.com/grafana/grafana/blob/master/MAINTAINERS.md +[rough]: https://tools.ietf.org/html/rfc7282 +[team]: https://groups.google.com/forum/#!forum/grafana-team diff --git a/ISSUE_TRIAGE.md b/ISSUE_TRIAGE.md new file mode 100644 index 0000000..806c97a --- /dev/null +++ b/ISSUE_TRIAGE.md @@ -0,0 +1,348 @@ +# Triage issues + +The main goal of issue triage is to categorize all incoming Grafana issues and make sure each issue has all basic information needed for anyone else to understand and be able to start working on it. + +> **Note:** This information is for Grafana project Maintainers, Owners, and Admins. If you are a Contributor, then you will not be able to perform most of the tasks in this topic. + +The core maintainers of the Grafana project are responsible for categorizing all incoming issues and delegating any critical or important issue to other maintainers. Currently one maintainer each week is responsible. Besides that part, triage provides an important way to contribute to an open source project. + +Triage helps ensure issues resolve quickly by: + +- Ensuring the issue's intent and purpose is conveyed precisely. This is necessary because it can be difficult for an issue to explain how an end user experiences a problem and what actions they took. +- Giving a contributor the information they need before they commit to resolving an issue. +- Lowering the issue count by preventing duplicate issues. +- Streamlining the development process by preventing duplicate discussions. + +If you don't have the knowledge or time to code, consider helping with triage. The community will thank you for saving them time by spending some of yours. + +## Simplified flowchart diagram of the issue triage process + + +``` + +--------------------------+ + +----------------+ New issue opened/ | + | | more information added | + | +-------------+------------+ + | Ask for more | + | information +-------------+------------+ + | | All information needed | + | +--------+ to categorize the issue? +--------+ + | | | | | + | | NO +--------------------------+ YES | + | | | ++------+-------+-------------+ +------------+---------+ +----------------------------+ +| | | | | | +| label: needs more info | | Needs investigation? +--YES---+ label: needs investigation | +| | | | | | ++----------------------------+ +----------------+-----+ +--------------+-------------+ + NO | | + | Investigate | + +-----------+----------+ | + | label: type/* | | + | label: area/* +------------------+ + | label: datasource/* | + +-----|----------+-----+ + | | + | | + | +--+--------------------+ +--------------------+ + | | | | label: priority/* | + | | Needs priority? +--YES---+| milestone? | + | | | | | + | +--------------------+--+ +----+---------------+ + | NO | | + | | | + +----+-------------+ +---+----------+ | + | | | | | + | Close issue +----------+ Done +------+ + | | | | + +------------------+ +--------------+ +``` + +## 1. Find uncategorized issues + +To get started with issue triage and finding issues that haven't been triaged you have two alternatives. + +### Browse unlabeled issues + +The easiest and straight forward way of getting started and finding issues that haven't been triaged is to browse [unlabeled issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+no%3Alabel) and starting from the bottom and working yourself to the top. + +### Subscribe to all notifications + +The more advanced, but recommended way is to subscribe to all notifications from this repository which means that all new issues, pull requests, comments and important status changes are sent to your configured email address. Read this [guide](https://help.github.com/en/articles/watching-and-unwatching-repositories#watching-a-single-repository) for help with setting this up. + +It's highly recommended that you setup filters to automatically remove emails from the inbox and label/categorize them accordingly to make it easy for you to understand when you need to act upon a notification or where to look for finding issues that haven't been triaged etc. + +Instructions for setting up filters in Gmail can be found [here](#setting-up-gmail-filters). Another alternative is to use [Trailer](https://github.com/ptsochantaris/trailer) or similar software. + +## 2. Ensure the issue contains basic information + +Before triaging an issue very far, make sure that the issue's author provided the standard issue information. This will help you make an educated recommendation on how to categorize the issue. The Grafana project utilizes [GitHub issue templates](https://help.github.com/en/articles/creating-issue-templates-for-your-repository) to guide contributors to provide standard information that must be included for each type of template or type of issue. + +### Standard issue information that must be included + +Given a certain [issue template]([template](https://github.com/grafana/grafana/issues/new/choose)) have been used by the issue author or depending how the issue is perceived by the issue triage responsible, the following should help you understand what standard issue information that must be included. + +#### Bug reports + +Should explain what happened, what was expected and how to reproduce it together with any additional information that may help giving a complete picture of what happened such as screenshots, [query inspector](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630) output and any environment related information that's applicable and/or maybe related to the reported problem: +- Grafana version +- Data source type & version +- Platform & OS Grafana is installed on +- User OS & Browser + versions +- Using docker + what environment +- Which plugins +- Configuration database in use (sqlite, mysql, postgres) +- Reverse proxy in front of Grafana, what version and configuration +- Non-default configuration settings +- Development environment like Go and Node versions, if applicable + +#### Enhancement requests + +Should explain what enhancement or feature that the author wants to be added and why that is needed. + +#### Accessibility issues + +This is a mix between a bug report and enhancement request but focused on accessibility issues to help make Grafana improve keyboard navigation, screen-reader support and being accessible to everyone. The report should include relevant WCAG criteria, if applicable. + +#### Support requests + +In general, if the issue description and title is perceived as a question no more information is needed. + +### Good practices + +To make it easier for everyone to understand and find issues they're searching for it's suggested as a general rule of thumbs to: + +- Make sure that issue titles are named to explain the subject of the issue, has a correct spelling and doesn't include irrelevant information and/or sensitive information. +- Make sure that issue descriptions doesn't include irrelevant information, information from template that haven't been filled out and/or sensitive information. +- Do your best effort to change title and description or request suggested changes by adding a comment. + +> **Note:** Above rules is applicable to both new and existing issues of the Grafana project. + +### Do you have all the information needed to categorize an issue? + +Depending on the issue, you might not feel all this information is needed. Use your best judgement. If you cannot triage an issue using what its author provided, explain kindly to the author that they must provide the above information to clarify the problem. Label issue with `needs more detail` and add any related `area/*` or `datasource/*` labels. + +If the author provides the standard information but you are still unable to triage the issue, request additional information. Do this kindly and politely because you are asking for more of the author's time. + +If the author does not respond to the requested information within the timespan of a week, close the issue with a kind note stating that the author can request for the issue to be reopened when the necessary information is provided. + +When you feel you have all the information needed you're ready to [categorizing the issue](#3-categorizing-an-issue). + +If you receive a notification with additional information provided but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. + +## 3. Categorizing an issue + +An issue can have multiple of the following labels. Typically, a properly categorized issue should at least have: + +- One label identifying its type (`type/*`). +- One or multiple labels identifying the functional areas of interest or component (`area/*`) and/or data source (`datasource/*`), if applicable. + +| Label | Description | +| ------------------------ | ------------------------------------------------------------------------- | +| `type/bug` | A feature isn't working as expected given design or documentation. | +| `type/feature-request` | Request for a new feature or enhancement. | +| `type/docs` | Documentation problem or enhancement. | +| `type/accessibility` | Accessibility problem or enhancement. | +| `type/question` | Issue is a question or is perceived as such. | +| `type/duplicate` | An existing issue of the same subject/request have already been reported. | +| `type/works-as-intended` | A reported bug works as intended/by design. | +| `type/build-packaging` | Build or packaging problem or enhancement. | +| `area/*` | Subject is related to a functional area of interest or component. | +| `datasource/*` | Subject is related to a core data source plugin. | + +### Duplicate issues + +Make sure it's not a duplicate by searching existing issues using related terms from the issue title and description. If you think you know there is an existing issue, but can't find it, please reach out to one of the maintainers and ask for help. If you identify that the issue is a duplicate of an existing issue: + +1. Add a comment `/duplicate of #`. GitHub will recognize this and add some additional context to the issue activity. +2. The Grafana bot will do the rest, adding the correct label and closing comment +3. Optionally add any related `area/*` or `datasource/*` labels. + +### Bug reports + +If it's not perfectly clear that it's an actual bug, quickly try to reproduce it. + +**It's a bug/it can be reproduced:** + +1. Add a comment describing detailed steps for how to reproduce it, if applicable. +2. Label the issue `type/bug` and at least one `area/*` or `datasource/*` label. +3. If you know that maintainers wont be able to put any resources into it for some time then label the issue with `help wanted` and optionally `beginner friendly` together with pointers on which code to update to fix the bug. This should signal to the community that we would appreciate any help we can get to resolve this. +4. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +**It can't be reproduced:** +1. Either [ask for more information](#2-ensure-the-issue-contains-basic-information) needed to investigate it more thoroughly. +2. Either [delegate further investigations](#investigation-of-issues) to someone else. + +**It works as intended/by design:** +1. Kindly and politely add a comment explaining briefly why we think it works as intended and close the issue. +2. Label the issue `type/works-as-intended`. + +### Enhancement/feature? + +1. Label the issue `type/feature-request` and at least one `area/*` or `datasource/*` label. +2. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +### Documentation issue? + +First, evaluate if the documentation makes sense to be included in the Grafana project: + +- Is this something we want/can maintain as a project? +- Is this referring to usage of some specific integration/tool and in that case is that a popular use case in combination with Grafana? +- If unsure, kindly and politely add a comment explaining that we would need [upvotes](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments) to identify that lots of other users want/need this. + +Second, label the issue `type/docs` and at least one `area/*` or `datasource/*` label. + +**Minor typo/error/lack of information:** + +There's a minor typo/error/lack of information that adds a lot of confusion for users and given the amount of work is a big win to make sure fixing it: +1. Either update the documentation yourself and open a pull request. +2. Either delegate the work to someone else by assigning that person to the issue and add the issue to next major/minor milestone. + +**Major error/lack of information:** + +1. Label the issue with `help wanted` and `beginner friendly`, if applicable, to signal that we find this important to fix and we would appreciate any help we can get from the community. +2. Move on to [prioritizing the issue](#4-prioritization-of-issues). + +### Accessibility issues + +1. Label the issue `type/accessibility` and at least one `area/*` or `datasource/*` label. + +### Support requests + +1. Kindly and politely direct the issue author to the [community site](https://community.grafana.com/) and explain that GitHub is mainly used for tracking bugs and feature requests. If possible, it's usually a good idea to add some pointers to the issue author's question. +2. Close the issue and label it with `type/question`. + +## 4. Prioritization of issues + +In general bugs and enhancement issues should be labeled with a priority. + +This is the most difficult thing with triaging issues since it requires a lot of knowledge, context and experience before being able to think of and start feel comfortable adding a certain priority label. + +The key here is asking for help and discuss issues to understand how more experienced project members think and reason. By doing that you learn more and eventually be more and more comfortable with prioritizing issues. + +In case there is an uncertainty around the prioritization of an issue, please ask the maintainers for help. + +| Label | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `priority/critical` | Highest priority. Must be actively worked on as someone's top priority right now. | +| `priority/support-subscription` | This is important for one or several customers having a paid Grafana support subscription. | +| `priority/important-soon` | Must be staffed and worked on either currently, or very soon, ideally in time for the next release. | +| `priority/important-longterm` | Important over the long term, but may not be staffed and/or may need multiple releases to complete. | +| `priority/nice-to-have` | It's a good idea, but not scheduled for any release. | +| `priority/awaiting-more-evidence` | Lowest priority. Possibly useful, but not yet enough interest in it. | +| `priority/unscheduled` | Something to look into before and to be discussed during the planning of the next (upcoming) major/minor stable release. | + +**Critical bugs** + +1. If a bug has been categorized and any of the following criteria apply, the bug should be labeled as critical and must be actively worked on as someone's top priority right now. + + - Results in any data loss + - Critical security or performance issues + - Problem that makes a feature unusable + - Multiple users experience a severe problem affecting their business, users etc. + +2. Label the issue `priority/critical`. +3. If applicable, label the issue `priority/support-subscription`. +4. Add the issue to the next upcoming patch release milestone. Create a new milestone if there are none. +5. Escalate the problem to the maintainers. +6. Assign or ask a maintainer for help assigning someone to make this issue their top priority right now. + +**Important short-term** + +1. Label the issue `priority/important-soon`. +2. If applicable, label the issue `priority/support-subscription`. +3. Add the issue to the next upcoming patch or major/minor stable release milestone. Ask maintainers for help if unsure if it's a patch or not. Create a new milestone if there are none. +4. Make sure to add the issue to a suitable backlog of a GitHub project and prioritize it or assign someone to work on it now or very soon. +5. Consider requesting [help from the community](#5-requesting-help-from-the-community), even though it may be problematic given a short amount of time until it should be released. + +**Important long-term** + +1. Label the issue `priority/important-longterm`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +**Nice to have** + +1. Label the issue `priority/nice-to-have`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +**Not critical, but unsure?** + +1. Label the issue `priority/unscheduled`. +2. Consider requesting [help from the community](#5-requesting-help-from-the-community). + +## 5. Requesting help from the community + +Depending on the issue and/or priority, it's always a good idea to consider signalling to the community that help from community is appreciated and needed in case an issue is not prioritized to be worked on by maintainers. Use your best judgement. In general, requesting help from the community means that a contribution has a good chance of getting accepted and merged. + +In many cases the issue author or community as a whole is more suitable to contribute changes since they're experts in their domain. It's also quite common that someone has tried to get something to work using the documentation without success and made an effort to get it to work and/or reached out to the [community site](https://community.grafana.com/) to get the missing information. Particularly in these areas it's more likely that there exist experts in their own domain and it is usually a good idea to request help from contributors: + +- Database setups +- Authentication like OAuth providers and LDAP setups +- Platform specific things +- Reverse proxy setups +- Alert notifiers + +1. Kindly and politely add a comment to signal to users subscribed to updates of the issue. + - Explain that the issue would be nice to get resolved, but it isn't prioritized to work on by maintainers for an unforeseen future. + - If possible or applicable, try to help contributors getting starting by adding pointers and references to what code/files need to be changed and/or ideas of a good way to solve/implement the issue. +2. Label the issue with `help wanted`. +3. If applicable, label the issue with `beginner friendly` to denote that the issue is suitable for a beginner to work on. +4. If possible, try to estimate the amount of work by adding `effort/small`, `effort/medium` or `effort/large`. + +## Investigation of issues + +When an issue has all basic information provided, but the triage responsible haven't been able to reproduce the reported problem at a first glance, the issue is labeled [Needs investigation](https://github.com/grafana/grafana/labels/needs%20investigation). Depending on the perceived severity and/or number of [upvotes](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments), the investigation will either be delegated to another maintainer for further investigation or put on hold until someone else (maintainer or contributor) picks it up and eventually starts investigating it. + +Investigating issues can be a very time consuming task, especially for the maintainers, given the huge number of combinations of plugins, data sources, platforms, databases, browsers, tools, hardware, integrations, versions and cloud services, etc that are being used with Grafana. There is a certain number of combinations that are more common than others, and these are in general easier for maintainers to investigate. + +For some other combinations it may not be possible at all for a maintainer to setup a proper test environment to investigate the issue. In these cases we really appreciate any help we can get from the community. Otherwise the issue is highly likely to be closed. + +Even if you don't have the time or knowledge to investigate an issue we highly recommend that you [upvote](https://help.github.com/en/articles/about-conversations-on-github#reacting-to-ideas-in-comments) the issue if you happen to have the same problem. If you have further details that may help investigating the issue please provide as much information as possible. + +## Automation + +We have some automation that triggers on comments or labels being added to issues. Many of these automated behaviors are defined in [commands.json](https://github.com/grafana/grafana/blob/master/.github/commands.json). Or in other [GitHub Actions](https://github.com/grafana/grafana/tree/master/.github/workflows) + +* Add /duplicate `#` to have Grafana label & close issue with an appropriate message. +* Add `bot/question` and the bot will close it with an appropriate message. + +[Read more on bot actions](https://github.com/grafana/grafana/blob/master/.github/bot.md) + +## External PRs + +Part of issue triage should also be triaging of external PRs. Main goal should be to make sure PRs from external contributors have an owner/reviewer and are not forgotten. + +1. Check new external PRs which do not have a reviewer. +1. Check if there is a link to an existing issue. +1. If not and you know which issue it is solving, add the link yourself, otherwise ask the author to link the issue or create one. +1. Assign a reviewer based on who was handling the linked issue or what code or feature does the PR touches (look at who was the last to make changes there if all else fails). + +## Appendix + +### Setting up Gmail filters + +If you're using Gmail it's highly recommended that you setup filters to automatically remove email from the inbox and label them accordingly to make it easy for you to understand when you need to act upon a notification or process all incoming issues that haven't been triaged. + +This may be setup by personal preference, but here's a working configuration for reference. +1. Follow instructions in [gist](https://gist.github.com/marefr/9167c2e31466f6316c1cba118874e74f) +2. In Gmail, go to Settings -> Filters and Blocked Addresses +3. Import filters -> select xml file -> Open file +4. Review filters +5. Optional, Check Apply new filters to existing email +6. Create filters + +This will give you a structure of labels in the sidebar similar to the following: +``` + - Inbox + ... + - GitHub (mine) + - activity + - assigned + - mentions + - GitHub (other) + - Grafana +``` + +- All notifications you’ll need to read/take action on show up as unread in GitHub (mine) and its sub-labels. +- All other notifications you don’t need to take action on show up as unread in GitHub (other) and its sub-labels + - This is convenient for issue triage and to follow the activity in the Grafana project. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373dde5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..beb4c27 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,8 @@ +@torkelo is the main/default maintainer, some parts of the codebase have other maintainers: + +- Backend: + - @bergquist +- Plugins: + - @ryantxu +- UX/UI: + - @davkal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fb2721 --- /dev/null +++ b/Makefile @@ -0,0 +1,160 @@ +## This is a self-documented Makefile. For usage information, run `make help`: +## +## For more information, refer to https://suva.sh/posts/well-documented-makefiles/ + +-include local/Makefile + +.PHONY: all deps-go deps-js deps build-go build-server build-cli build-js build build-docker-dev build-docker-full lint-go revive golangci-lint test-go test-js test run run-frontend clean devenv devenv-down revive-strict protobuf help + +GO = GO111MODULE=on go +GO_FILES ?= ./pkg/... +SH_FILES ?= $(shell find ./scripts -name *.sh) + +all: deps build + +##@ Dependencies + +deps-go: ## Install backend dependencies. + $(GO) run build.go setup + +deps-js: node_modules ## Install frontend dependencies. + +deps: deps-js ## Install all dependencies. + +node_modules: package.json yarn.lock ## Install node modules. + @echo "install frontend dependencies" + yarn install --pure-lockfile --no-progress + +##@ Building + +build-go: ## Build all Go binaries. + @echo "build go files" + $(GO) run build.go build + +build-server: ## Build Grafana server. + @echo "build server" + $(GO) run build.go build-server + +build-cli: ## Build Grafana CLI application. + @echo "build in CI environment" + $(GO) run build.go build-cli + +build-js: ## Build frontend assets. + @echo "build frontend" + yarn run build + yarn run plugins:build-bundled + +build: build-go build-js ## Build backend and frontend. + +scripts/go/bin/bra: scripts/go/go.mod + @cd scripts/go; \ + $(GO) build -o ./bin/bra github.com/unknwon/bra + +run: scripts/go/bin/bra ## Build and run web server on filesystem changes. + @GO111MODULE=on scripts/go/bin/bra run + +run-frontend: deps-js ## Fetch js dependencies and watch frontend for rebuild + yarn start + +##@ Testing + +test-go: ## Run tests for backend. + @echo "test backend" + $(GO) test -v ./pkg/... + +test-js: ## Run tests for frontend. + @echo "test frontend" + yarn test + +test: test-go test-js ## Run all tests. + +##@ Linting + +scripts/go/bin/revive: scripts/go/go.mod + @cd scripts/go; \ + $(GO) build -o ./bin/revive github.com/mgechev/revive + +revive: scripts/go/bin/revive + @echo "lint via revive" + @scripts/go/bin/revive \ + -formatter stylish \ + -config ./scripts/go/configs/revive.toml \ + $(GO_FILES) + +revive-strict: scripts/go/bin/revive + @echo "lint via revive (strict)" + @scripts/revive-strict scripts/go/bin/revive + +scripts/go/bin/golangci-lint: scripts/go/go.mod + @cd scripts/go; \ + $(GO) build -o ./bin/golangci-lint github.com/golangci/golangci-lint/cmd/golangci-lint + +golangci-lint: scripts/go/bin/golangci-lint + @echo "lint via golangci-lint" + @scripts/go/bin/golangci-lint run \ + --config ./scripts/go/configs/.golangci.toml \ + $(GO_FILES) + +lint-go: golangci-lint revive revive-strict # Run all code checks for backend. + +# with disabled SC1071 we are ignored some TCL,Expect `/usr/bin/env expect` scripts +shellcheck: $(SH_FILES) ## Run checks for shell scripts. + @docker run --rm -v "$$PWD:/mnt" koalaman/shellcheck:stable \ + $(SH_FILES) -e SC1071 -e SC2162 + +##@ Docker + +build-docker-dev: ## Build Docker image for development (fast). + @echo "build development container" + @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." + $(GO) run build.go -goos linux -pkg-arch amd64 ${OPT} build pkg-archive latest + cp dist/grafana-latest.linux-x64.tar.gz packaging/docker + cd packaging/docker && docker build --tag grafana/grafana:dev . + +build-docker-full: ## Build Docker image for development. + @echo "build docker container" + docker build --tag grafana/grafana:dev . + +##@ Services + +# create docker-compose file with provided sources and start them +# example: make devenv sources=postgres,openldap +ifeq ($(sources),) +devenv: + @printf 'You have to define sources for this command \nexample: make devenv sources=postgres,openldap\n' +else +devenv: devenv-down ## Start optional services, e.g. postgres, prometheus, and elasticsearch. + $(eval targets := $(shell echo '$(sources)' | tr "," " ")) + + @cd devenv; \ + ./create_docker_compose.sh $(targets) || \ + (rm -rf {docker-compose.yaml,conf.tmp,.env}; exit 1) + + @cd devenv; \ + docker-compose up -d --build +endif + +devenv-down: ## Stop optional services. + @cd devenv; \ + test -f docker-compose.yaml && \ + docker-compose down || exit 0; + +##@ Helpers + +# We separate the protobuf generation because most development tasks on +# Grafana do not involve changing protobuf files and protoc is not a +# go-gettable dependency and so getting it installed can be inconvenient. +# +# If you are working on changes to protobuf interfaces you may either use +# this target or run the individual scripts below directly. +protobuf: ## Compile protobuf definitions + bash scripts/protobuf-check.sh + bash pkg/plugins/backendplugin/pluginextensionv2/generate.sh + +clean: ## Clean up intermediate build artifacts. + @echo "cleaning" + rm -rf node_modules + rm -rf public/build + +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..40cf840 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,6 @@ + +Copyright 2014-2021 Grafana Labs + +This software is based on Kibana: +Copyright 2012-2013 Elasticsearch BV + diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md new file mode 100644 index 0000000..0663af7 --- /dev/null +++ b/PLUGIN_DEV.md @@ -0,0 +1,33 @@ +# Plugin development + +This document is not meant as a complete guide for developing plugins but more as a changelog for changes in +Grafana that can impact plugin development. Whenever you as a plugin author encounter an issue with your plugin after +upgrading Grafana please check here before creating an issue. + +## Plugin development resources + +- [Grafana plugin developer guide](http://docs.grafana.org/plugins/developing/development/) +- [Webpack Grafana plugin template project](https://github.com/CorpGlory/grafana-plugin-template-webpack) +- [Simple JSON datasource plugin](https://github.com/grafana/simple-json-datasource) + +## Changes in Grafana v4.6 + +This version of Grafana has big changes that will impact a limited set of plugins. We moved from systemjs to webpack +for built-in plugins and everything internal. External plugins still use systemjs but now with a limited +set of Grafana components they can import. Plugins can depend on libs like lodash & moment and internal components +like before using the same import paths. However since everything in Grafana is no longer accessible, a few plugins could encounter issues when importing a Grafana dependency. + +[List of exposed components plugins can import/require](https://github.com/grafana/grafana/blob/master/public/app/features/plugins/plugin_loader.ts#L48) + +If you think we missed exposing a crucial lib or Grafana component let us know by opening an issue. + +### Deprecated components + +The angular directive `` is now deprecated (will still work for a version more) but we recommend plugin authors +upgrade to new `` + +## Changes in Grafana v6.0 + +### DashboardSrv.ts + +If you utilize [DashboardSrv](https://github.com/grafana/grafana/commit/8574dca081002f36e482b572517d8f05fd44453f#diff-1ab99561f9f6a10e1fafcddc39bc1d65) in your plugin code, `dash` was renamed to `dashboard`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9077dca --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +![Grafana](docs/logo-horizontal.png) + +The open-source platform for monitoring and observability. + +[![License](https://img.shields.io/github/license/grafana/grafana)](LICENSE) +[![Circle CI](https://img.shields.io/circleci/build/gh/grafana/grafana)](https://circleci.com/gh/grafana/grafana) +[![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) + +Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture: + +- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. +- **Dynamic Dashboards:** Create dynamic & reusable dashboards with template variables that appear as dropdowns at the top of the dashboard. +- **Explore Metrics:** Explore your data through ad-hoc queries and dynamic drilldown. Split view and compare different time ranges, queries and data sources side by side. +- **Explore Logs:** Experience the magic of switching from metrics to logs with preserved label filters. Quickly search through all your logs or streaming them live. +- **Alerting:** Visually define alert rules for your most important metrics. Grafana will continuously evaluate and send notifications to systems like Slack, PagerDuty, VictorOps, OpsGenie. +- **Mixed Data Sources:** Mix different data sources in the same graph! You can specify a data source on a per-query basis. This works for even custom datasources. + +## Get started + +- [Get Grafana](https://grafana.com/get) +- [Installation guides](http://docs.grafana.org/installation/) + +Unsure if Grafana is for you? Watch Grafana in action on [play.grafana.org](https://play.grafana.org/)! + +## Documentation + +The Grafana documentation is available at [grafana.com/docs](https://grafana.com/docs/). + +## Contributing + +If you're interested in contributing to the Grafana project: + +- Start by reading the [Contributing guide](/CONTRIBUTING.md). +- Learn how to set up your local environment, in our [Developer guide](/contribute/developer-guide.md). +- Explore our [beginner-friendly issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22). + +## Get involved + +- Follow [@grafana on Twitter](https://twitter.com/grafana/). +- Read and subscribe to the [Grafana blog](https://grafana.com/blog/). +- If you have a specific question, check out our [discussion forums](https://community.grafana.com/). +- For general discussions, join us on the [official Slack](http://slack.raintank.io/) team. + +## License + +Grafana is distributed under the [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d0e69b0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,5 @@ +# Roadmap + +The roadmap is a tentative plan for the core development team. Things change constantly as pull requests come in and priorities change, but it will give you an idea of our current vision and plan. + +To view the Roadmap, go to the Issues tab on GitHub. There you will find three roadmap issues pinned at the top. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..355a588 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Reporting security issues + +If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. + +Please encrypt your message to us; please use our PGP key. The key fingerprint is: + +F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA + +The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA&fingerprint=on&op=index). + +Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. + +**Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. + +## Security announcements + +We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/security-announcements), +where we will post a summary, remediation, and mitigation details for any patch containing security fixes. + +You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/security-announcements.rss). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..c9c94d9 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,10 @@ +# Get Grafana help +------------------ +First, check the official [Grafana documentation](https://grafana.com/docs/). + +If you require further help or support then ask a question in the [Grafana community site](https://community.grafana.com/) or [Grafana Slack](http://slack.raintank.io/). You can also search the community site for previously answered questions, in case someone already had your problem and got help. + + **Please note:** +- The Grafana project uses GitHub mainly for tracking bugs and feature requests. +- Do not open an issue just to ask a question. The issue will be closed immediately. +- Only submit issues for bug reports, feature requests, or enhancements. diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md new file mode 100644 index 0000000..56fa37d --- /dev/null +++ b/UPGRADING_DEPENDENCIES.md @@ -0,0 +1,108 @@ +# Guide to upgrading dependencies + +Upgrading Go or Node.js requires making changes in many different files. See below for a list and explanation for each. + +## Go + +- CircleCi +- `grafana/build-container` +- Appveyor +- Dockerfile + +## Node.js + +- CircleCI +- `grafana/build-container` +- Appveyor +- Dockerfile + +## Go dependencies + +The Grafana project uses [Go modules](https://golang.org/cmd/go/#hdr-Modules__module_versions__and_more) to manage dependencies on external packages. This requires a working Go environment with version 1.11 or greater installed. + +> **Note:** Since most developers of Grafana still use the `GOPATH` we need to specify `GO111MODULE=on` to make `go mod` and `got get` work as intended. If you have setup Grafana outside of the `GOPATH` on your machine you can skip `GO111MODULE=on` when running the commands below. + +To add or update a new dependency, use the `go get` command: + +```bash +# The GO111MODULE variable can be omitted when the code isn't located in GOPATH. +# Pick the latest tagged release. +GO111MODULE=on go get example.com/some/module/pkg + +# Pick a specific version. +GO111MODULE=on go get example.com/some/module/pkg@vX.Y.Z +``` + +Tidy up the `go.mod` and `go.sum` files: + +```bash +# The GO111MODULE variable can be omitted when the code isn't located in GOPATH. +GO111MODULE=on go mod tidy +``` + +You have to commit the changes to `go.mod` and `go.sum` before submitting the pull request. + +## Node.js dependencies + +Updated using `yarn`. + +- `package.json` + +## Where to make changes + +### CircleCI + +Our builds run on CircleCI through our build script. + +#### Files + +- `.circleci/config.yml`. + +#### Dependencies + +- nodejs +- golang +- grafana/build-container (our custom docker build container) + +### grafana/build-container + +The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the necessary dependencies. + +Link: [grafana/build-container](https://github.com/grafana/grafana/tree/master/scripts/build/ci-build) + +#### Dependencies + +- fpm +- nodejs +- golang +- crosscompiling (several compilers) + +### Appveyor + +Master and release builds trigger test runs on Appveyors build environment so that tests will run on Windows. + +#### Files: + +- `appveyor.yml` + +#### Dependencies + +- nodejs +- golang + +### Dockerfile + +There is a Docker build for Grafana in the root of the project that allows anyone to build Grafana just using Docker. + +#### Files + +- `Dockerfile` + +#### Dependencies + +- nodejs +- golang + +### Local developer environments + +Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments. diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..6a7b3ed --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,77 @@ +# Grafana workflow + +This document is based on [GOVERNANCE.md](GOVERNANCE.md). We assume good faith and intend to keep all processes as lightweight as possible but as specific as required. In case of disagreements about anything in this document, GOVERNANCE.md applies. + +The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in [RFC2119](http://tools.ietf.org/html/rfc2119). + +Git and [GitHub terminology](https://help.github.com/en/github/getting-started-with-github/github-glossary) are used throughout this document. + +Team members and their access to repositories is maintained through [GitHub teams](https://help.github.com/en/github/setting-up-and-managing-organizations-and-teams/about-teams). Team maintainers add and remove team members as outlined in GOVERNANCE.md. + +# Code changes + +## Proposing changes + +Examples of proposed changes are overarching architecture, component design, and specific code or graphical elements. Proposed changes SHOULD cover the big picture and intention, but individual parts SHOULD be split into the smallest possible changes. Changes SHOULD be based on and target the master branch. Depending on size of the proposed change, each change SHOULD be discussed, in increasing order of change size and complexity: +- Directly in a RR (Pull Request) - this MAY be done, but SHOULD not be the common case. +- Issue +- Developer mailing list +- Design document, shared via Google Docs, accessible to at least all team members. + +Significant changes MUST be discussed and agreed upon with the relevant subsystem maintainers. + +## Merging PRs (Pull Requests) + +Depending on the size and complexity of a PR, different requirements MUST be applied. Any team member contributing substantially to a PR MUST NOT count against review requirements. +Commits MUST be merged into master using PRs. They MUST NOT be merged into master directly. +- Every merge MUST be approved by at least one team member. +- Non-trivial changes MUST be approved by at least + - two team members, or + - one subsystem maintainer. +- Significant changes MUST be approved by at least + - two team members, AND + - the relevant subsystem maintainer. + +PRs MUST be [reviewed](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/reviewing-changes-in-pull-requests) and [approved](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/approving-a-pull-request-with-required-reviews) via GitHub’s review system. +- Reviewers MAY write comments if approving +- Reviewers MUST write comments if rejecting a PR or if requesting changes. + +Once a PR is approved as per above, any team member MAY merge the PR. + +## Backporting a PR + +PRs intended for inclusion in the next PATCH release they must be backported to the release branch. The bot can do this automatically. [Read more on backport PRs](https://github.com/grafana/grafana/blob/master/.github/bot.md). Both the source PR and the backport PR should be assigned to the patch release milestone, unless you are backporting to many releases then it can differ. + +Backport PRs are also needed during the beta period to get fixes into the stable release. + +# Release workflow + +## Branch structure + +Grafana uses trunk-based development. + +In particular, we found that the following principles match how we work: +- Master and release branches MUST always build without failure. +- Branches SHOULD be merged often. Larger changes SHOULD be activated with feature flags until they are ready. Long-lived development branches SHOULD be avoided. +- Changes MAY be enabled by default once they are in a complete state +- Changes which span multiple PRs MUST be described in an overarching issue or Google Doc. + +## Releases + +Releases MUST follow [Semantic Versioning](https://semver.org/) in naming and SHOULD follow Semantic Versioning as closely as reasonably possible for non-library software. + +Release branches MUST be split from the following branches. +- MAJOR release branches MUST be based on master. +- MINOR release branches MUST be based on master. +- PATCH release branches MUST be split from the relevant MINOR release branch’s most current PATCH + +Security releases follow the same process but MUST be prepared in secret. Security releases MUST NOT include changes which are not related to the security fix. Normal release processes MUST accommodate the security release process. SECURITY.md MUST be followed. + +Releases follow the following cadence +- MAJOR: Yearly +- MINOR: Every 4-6 weeks +- PATCH: As needed + +Releases SHOULD NOT be delayed by pending changes. + +Releases MUST be coordinated with the relevant subsystem maintainers. diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 0000000..b73e800 --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/dist/index.d.ts", + "bundledPackages": [], + "compiler": {}, + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": true, + "apiJsonFilePath": "/../../reports/docs/.api.json" + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": {}, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + }, + "ae-internal-missing-underscore": { + "logLevel": "none", + "addToApiReportFile": false + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/build.go b/build.go new file mode 100644 index 0000000..4fcb08e --- /dev/null +++ b/build.go @@ -0,0 +1,482 @@ +// +build ignore + +package main + +import ( + "bytes" + "crypto/md5" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "go/build" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + windows = "windows" + linux = "linux" +) + +var ( + //versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) + goarch string + goos string + gocc string + cgo bool + libc string + pkgArch string + version string = "v1" + buildTags []string + // deb & rpm does not support semver so have to handle their version a little differently + linuxPackageVersion string = "v1" + linuxPackageIteration string = "" + race bool + workingDir string + includeBuildId bool = true + buildId string = "0" + serverBinary string = "grafana-server" + cliBinary string = "grafana-cli" + binaries []string = []string{serverBinary, cliBinary} + isDev bool = false + enterprise bool = false + skipRpmGen bool = false + skipDebGen bool = false + printGenVersion bool = false +) + +func main() { + log.SetOutput(os.Stdout) + log.SetFlags(0) + + var buildIdRaw string + var buildTagsRaw string + + flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") + flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") + flag.StringVar(&gocc, "cc", "", "CC") + flag.StringVar(&libc, "libc", "", "LIBC") + flag.StringVar(&buildTagsRaw, "build-tags", "", "Sets custom build tags") + flag.BoolVar(&cgo, "cgo-enabled", cgo, "Enable cgo") + flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") + flag.BoolVar(&race, "race", race, "Use race detector") + flag.BoolVar(&includeBuildId, "includeBuildId", includeBuildId, "IncludeBuildId in package name") + flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana") + flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system") + flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") + flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)") + flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)") + flag.BoolVar(&printGenVersion, "gen-version", printGenVersion, "generate Grafana version and output (default: false)") + flag.Parse() + + buildId = shortenBuildId(buildIdRaw) + + readVersionFromPackageJson() + + if pkgArch == "" { + pkgArch = goarch + } + + if printGenVersion { + printGeneratedVersion() + return + } + + if len(buildTagsRaw) > 0 { + buildTags = strings.Split(buildTagsRaw, ",") + } + + log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) + + if flag.NArg() == 0 { + log.Println("Usage: go run build.go build") + return + } + + workingDir, _ = os.Getwd() + + for _, cmd := range flag.Args() { + switch cmd { + case "setup": + setup() + + case "build-srv", "build-server": + clean() + doBuild("grafana-server", "./pkg/cmd/grafana-server", buildTags) + + case "build-cli": + clean() + doBuild("grafana-cli", "./pkg/cmd/grafana-cli", buildTags) + + case "build": + //clean() + for _, binary := range binaries { + doBuild(binary, "./pkg/cmd/"+binary, buildTags) + } + + case "build-frontend": + yarn("build") + + case "sha-dist": + shaFilesInDist() + + case "latest": + makeLatestDistCopies() + + case "clean": + clean() + + default: + log.Fatalf("Unknown command %q", cmd) + } + } +} + +func makeLatestDistCopies() { + files, err := ioutil.ReadDir("dist") + if err != nil { + log.Fatalf("failed to create latest copies. Cannot read from /dist") + } + + latestMapping := map[string]string{ + "_amd64.deb": "dist/grafana_latest_amd64.deb", + ".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm", + ".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz", + ".linux-amd64-musl.tar.gz": "dist/grafana-latest.linux-x64-musl.tar.gz", + ".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz", + ".linux-armv7-musl.tar.gz": "dist/grafana-latest.linux-armv7-musl.tar.gz", + ".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz", + ".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz", + ".linux-arm64-musl.tar.gz": "dist/grafana-latest.linux-arm64-musl.tar.gz", + } + + for _, file := range files { + for extension, fullName := range latestMapping { + if strings.HasSuffix(file.Name(), extension) { + runError("cp", path.Join("dist", file.Name()), fullName) + } + } + } +} + +func readVersionFromPackageJson() { + reader, err := os.Open("package.json") + if err != nil { + log.Fatal("Failed to open package.json") + return + } + defer reader.Close() + + jsonObj := map[string]interface{}{} + jsonParser := json.NewDecoder(reader) + + if err := jsonParser.Decode(&jsonObj); err != nil { + log.Fatal("Failed to decode package.json") + } + + version = jsonObj["version"].(string) + linuxPackageVersion = version + linuxPackageIteration = "" + + // handle pre version stuff (deb / rpm does not support semver) + parts := strings.Split(version, "-") + + if len(parts) > 1 { + linuxPackageVersion = parts[0] + linuxPackageIteration = parts[1] + } + + // add timestamp to iteration + if includeBuildId { + if buildId != "0" { + linuxPackageIteration = fmt.Sprintf("%s%s", buildId, linuxPackageIteration) + } else { + linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + } + } +} + +func yarn(params ...string) { + runPrint(`yarn run`, params...) +} + +func genPackageVersion() string { + if includeBuildId { + return fmt.Sprintf("%v-%v", linuxPackageVersion, linuxPackageIteration) + } else { + return version + } +} + +func setup() { + args := []string{"install", "-v"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, "./pkg/cmd/grafana-server") + runPrint("go", args...) +} + +func printGeneratedVersion() { + fmt.Print(genPackageVersion()) +} + +func test(pkg string) { + setBuildEnv() + args := []string{"test", "-short", "-timeout", "60s"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, pkg) + runPrint("go", args...) +} + +func doBuild(binaryName, pkg string, tags []string) { + libcPart := "" + if libc != "" { + libcPart = fmt.Sprintf("-%s", libc) + } + binary := fmt.Sprintf("./bin/%s-%s%s/%s", goos, goarch, libcPart, binaryName) + if isDev { + //don't include os/arch/libc in output path in dev environment + binary = fmt.Sprintf("./bin/%s", binaryName) + } + + if goos == windows { + binary += ".exe" + } + + if !isDev { + rmr(binary, binary+".md5") + } + args := []string{"build", "-ldflags", ldflags()} + if goos == windows { + // Work around a linking error on Windows: "export ordinal too large" + args = append(args, "-buildmode=exe") + } + if len(tags) > 0 { + args = append(args, "-tags", strings.Join(tags, ",")) + } + if race { + args = append(args, "-race") + } + + args = append(args, "-o", binary) + args = append(args, pkg) + + if !isDev { + setBuildEnv() + runPrint("go", "version") + libcPart := "" + if libc != "" { + libcPart = fmt.Sprintf("/%s", libc) + } + fmt.Printf("Targeting %s/%s%s\n", goos, goarch, libcPart) + } + + runPrint("go", args...) + + if !isDev { + // Create an md5 checksum of the binary, to be included in the archive for + // automatic upgrades. + err := md5File(binary) + if err != nil { + log.Fatal(err) + } + } +} + +func ldflags() string { + var b bytes.Buffer + b.WriteString("-w") + b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) + b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) + b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) + b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch())) + if v := os.Getenv("LDFLAGS"); v != "" { + b.WriteString(fmt.Sprintf(" -extldflags \"%s\"", v)) + } + return b.String() +} + +func rmr(paths ...string) { + for _, path := range paths { + log.Println("rm -r", path) + os.RemoveAll(path) + } +} + +func clean() { + if isDev { + return + } + + rmr("dist") + rmr("tmp") + rmr(filepath.Join(build.Default.GOPATH, fmt.Sprintf("pkg/%s_%s/github.com/grafana", goos, goarch))) +} + +func setBuildEnv() { + os.Setenv("GOOS", goos) + if goos == windows { + // require windows >=7 + os.Setenv("CGO_CFLAGS", "-D_WIN32_WINNT=0x0601") + } + if goarch != "amd64" || goos != linux { + // needed for all other archs + cgo = true + } + if strings.HasPrefix(goarch, "armv") { + os.Setenv("GOARCH", "arm") + os.Setenv("GOARM", goarch[4:]) + } else { + os.Setenv("GOARCH", goarch) + } + if goarch == "386" { + os.Setenv("GO386", "387") + } + if cgo { + os.Setenv("CGO_ENABLED", "1") + } + if gocc != "" { + os.Setenv("CC", gocc) + } +} + +func getGitBranch() string { + v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "master" + } + return string(v) +} + +func getGitSha() string { + v, err := runError("git", "rev-parse", "--short", "HEAD") + if err != nil { + return "unknown-dev" + } + return string(v) +} + +func buildStamp() int64 { + // use SOURCE_DATE_EPOCH if set. + if s, _ := strconv.ParseInt(os.Getenv("SOURCE_DATE_EPOCH"), 10, 64); s > 0 { + return s + } + + bs, err := runError("git", "show", "-s", "--format=%ct") + if err != nil { + return time.Now().Unix() + } + s, _ := strconv.ParseInt(string(bs), 10, 64) + return s +} + +func runError(cmd string, args ...string) ([]byte, error) { + ecmd := exec.Command(cmd, args...) + bs, err := ecmd.CombinedOutput() + if err != nil { + return nil, err + } + + return bytes.TrimSpace(bs), nil +} + +func runPrint(cmd string, args ...string) { + log.Println(cmd, strings.Join(args, " ")) + ecmd := exec.Command(cmd, args...) + ecmd.Env = append(os.Environ(), "GO111MODULE=on") + ecmd.Stdout = os.Stdout + ecmd.Stderr = os.Stderr + err := ecmd.Run() + if err != nil { + log.Fatal(err) + } +} + +func md5File(file string) error { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + + h := md5.New() + _, err = io.Copy(h, fd) + if err != nil { + return err + } + + out, err := os.Create(file + ".md5") + if err != nil { + return err + } + + _, err = fmt.Fprintf(out, "%x\n", h.Sum(nil)) + if err != nil { + return err + } + + return out.Close() +} + +func shaFilesInDist() { + filepath.Walk("./dist", func(path string, f os.FileInfo, err error) error { + if path == "./dist" { + return nil + } + + if !strings.Contains(path, ".sha256") { + err := shaFile(path) + if err != nil { + log.Printf("Failed to create sha file. error: %v\n", err) + } + } + return nil + }) +} + +func shaFile(file string) error { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + + h := sha256.New() + _, err = io.Copy(h, fd) + if err != nil { + return err + } + + out, err := os.Create(file + ".sha256") + if err != nil { + return err + } + + _, err = fmt.Fprintf(out, "%x\n", h.Sum(nil)) + if err != nil { + return err + } + + return out.Close() +} + +func shortenBuildId(buildId string) string { + buildId = strings.Replace(buildId, "-", "", -1) + if len(buildId) < 9 { + return buildId + } + return buildId[0:8] +} diff --git a/conf/defaults.ini b/conf/defaults.ini new file mode 100644 index 0000000..66836eb --- /dev/null +++ b/conf/defaults.ini @@ -0,0 +1,928 @@ +##################### Grafana Configuration Defaults ##################### +# +# Do not modify this file in grafana installs +# + +# possible values : production, development +app_mode = production + +# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty +instance_name = ${HOSTNAME} + +#################################### Paths ############################### +[paths] +# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) +data = data + +# Temporary files in `data` directory older than given duration will be removed +temp_data_lifetime = 24h + +# Directory where grafana can store logs +logs = data/log + +# Directory where grafana will automatically scan and look for plugins +plugins = data/plugins + +# folder that contains provisioning config files that grafana will apply on startup and while running. +provisioning = conf/provisioning + +#################################### Server ############################## +[server] +# Protocol (http, https, h2, socket) +protocol = http + +# The ip address to bind to, empty will bind to all interfaces +http_addr = + +# The http port to use +http_port = 3000 + +# The public facing domain name used to access grafana from a browser +domain = localhost + +# Redirect to correct domain if host header does not match domain +# Prevents DNS rebinding attacks +enforce_domain = false + +# The full public facing url +root_url = %(protocol)s://%(domain)s:%(http_port)s/ + +# Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons. +serve_from_sub_path = false + +# Log web requests +router_logging = false + +# the path relative working path +static_root_path = public + +# enable gzip +enable_gzip = false + +# https certs & key file +cert_file = +cert_key = + +# Unix socket path +socket = /tmp/grafana.sock + +# CDN Url +cdn_url = + +# Sets the maximum time in minutes before timing out read of an incoming request and closing idle connections. +# `0` means there is no timeout for reading the request. +read_timeout = 0 + +#################################### Database ############################ +[database] +# You can configure the database connection by specifying type, host, name, user and password +# as separate properties or as on string using the url property. + +# Either "mysql", "postgres" or "sqlite3", it's your choice +type = sqlite3 +host = 127.0.0.1:3306 +name = grafana +user = root +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" +password = +# Use either URL or the previous fields to configure the database +# Example: mysql://user:secret@host:port/database +url = + +# Max idle conn setting default is 2 +max_idle_conn = 2 + +# Max conn setting default is 0 (mean not set) +max_open_conn = + +# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours) +conn_max_lifetime = 14400 + +# Set to true to log the sql calls and execution times. +log_queries = + +# For "postgres", use either "disable", "require" or "verify-full" +# For "mysql", use either "true", "false", or "skip-verify". +ssl_mode = disable + +ca_cert_path = +client_key_path = +client_cert_path = +server_cert_name = + +# For "sqlite3" only, path relative to data_path setting +path = grafana.db + +# For "sqlite3" only. cache mode setting used for connecting to the database +cache_mode = private + +#################################### Cache server ############################# +[remote_cache] +# Either "redis", "memcached" or "database" default is "database" +type = database + +# cache connectionstring options +# database: will use Grafana primary database. +# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'. +# memcache: 127.0.0.1:11211 +connstr = + +#################################### Data proxy ########################### +[dataproxy] + +# This enables data proxy logging, default is false +logging = false + +# How long the data proxy waits before timing out, default is 30 seconds. +# This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set. +timeout = 30 + +# How many seconds the data proxy waits before sending a keepalive request. +keep_alive_seconds = 30 + +# How many seconds the data proxy waits for a successful TLS Handshake before timing out. +tls_handshake_timeout_seconds = 10 + +# How many seconds the data proxy will wait for a server's first response headers after +# fully writing the request headers if the request has an "Expect: 100-continue" +# header. A value of 0 will result in the body being sent immediately, without +# waiting for the server to approve. +expect_continue_timeout_seconds = 1 + +# The maximum number of idle connections that Grafana will keep alive. +max_idle_connections = 100 + +# How many seconds the data proxy keeps an idle connection open before timing out. +idle_conn_timeout_seconds = 90 + +# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request. +send_user_header = false + +#################################### Analytics ########################### +[analytics] +# Server reporting, sends usage counters to stats.grafana.org every 24 hours. +# No ip addresses are being tracked, only simple counters to track +# running instances, dashboard and error counts. It is very helpful to us. +# Change this option to false to disable reporting. +reporting_enabled = true + +# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs +reporting_distributor = grafana-labs + +# Set to false to disable all checks to https://grafana.com +# for new versions (grafana itself and plugins), check is used +# in some UI views to notify that grafana or plugin update exists +# This option does not cause any auto updates, nor send any information +# only a GET request to https://grafana.com to get latest versions +check_for_updates = true + +# Google Analytics universal tracking code, only enabled if you specify an id here +google_analytics_ua_id = + +# Google Tag Manager ID, only enabled if you specify an id here +google_tag_manager_id = + +#################################### Security ############################ +[security] +# disable creation of admin user on first start of grafana +disable_initial_admin_creation = false + +# default admin user, created on startup +admin_user = admin + +# default admin password, can be changed before first start of grafana, or in profile settings +admin_password = admin + +# used for signing +secret_key = SW2YcwTIb9zpOOhoPsMm + +# disable gravatar profile images +disable_gravatar = false + +# data source proxy whitelist (ip_or_domain:port separated by spaces) +data_source_proxy_whitelist = + +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + +# set to true if you host Grafana behind HTTPS. default is false. +cookie_secure = false + +# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled" +cookie_samesite = lax + +# set to true if you want to allow browsers to render Grafana in a , +``` + +The result is an interactive Grafana graph embedded in an iframe: + + diff --git a/docs/sources/troubleshooting/_index.md b/docs/sources/troubleshooting/_index.md new file mode 100644 index 0000000..c72de3f --- /dev/null +++ b/docs/sources/troubleshooting/_index.md @@ -0,0 +1,43 @@ ++++ +title = "Troubleshooting" +description = "Guide to troubleshooting Grafana problems" +keywords = ["grafana", "troubleshooting", "documentation", "guide"] +weight = 180 ++++ + +# Troubleshooting + +This page lists some tools and advice to help troubleshoot common Grafana issues. + +## Troubleshoot with logs + +If you encounter an error or problem, then you can check the Grafana server log. Usually located at `/var/log/grafana/grafana.log` on Unix systems or in `/data/log` on other platforms and manual installations. + +You can enable more logging by changing log level in the Grafana configuration file. + +For more information, refer to [Enable debug logging in Grafana CLI]({{< relref "../administration/cli.md#enable-debug-logging" >}}) and the [log section in Configuration]({{< relref "../administration/configuration.md#log" >}}). + +## Troubleshoot transformations + +Order of transformations matters. If the final data output from multiple transformations looks wrong, try changing the transformation order. Each transformation transforms data returned by the previous transformation, not the original raw data. + +For more information, refer to [Debug transformations]({{< relref "../panels/transformations/apply-transformations.md" >}}). + +## Text missing with server-side image rendering (RPM-based Linux) + +Server-side image (png) rendering is a feature that is optional but very useful when sharing visualizations, for example in alert notifications. + +If the image is missing text, then make sure you have font packages installed. + +```bash +sudo yum install fontconfig +sudo yum install freetype* +sudo yum install urw-fonts +``` + +## FAQs + +Check out the [FAQ section](https://community.grafana.com/c/howto/faq) on the Grafana Community page for answers to frequently +asked questions. + + diff --git a/docs/sources/troubleshooting/diagnostics.md b/docs/sources/troubleshooting/diagnostics.md new file mode 100644 index 0000000..01363f8 --- /dev/null +++ b/docs/sources/troubleshooting/diagnostics.md @@ -0,0 +1,55 @@ ++++ +title = "Enable diagnostics" +weight = 200 ++++ + +# Enable diagnostics + +You can set up the `grafana-server` process to enable certain diagnostics when it starts. This can be helpful +when investigating certain performance problems. It's *not* recommended to have these enabled by default. + +## Turn on profiling + +The `grafana-server` can be started with the arguments `-profile` to enable profiling and `-profile-port` to override +the default HTTP port (`6060`) where the `pprof` debugging endpoints are available, for example: + +```bash +./grafana-server -profile -profile-port=8080 +``` + +Note that `pprof` debugging endpoints are served on a different port than the Grafana HTTP server. + +You can configure or override profiling settings using environment variables: + +```bash +export GF_DIAGNOSTICS_PROFILING_ENABLED=true +export GF_DIAGNOSTICS_PROFILING_PORT=8080 +``` + +Refer to [Go command pprof](https://golang.org/cmd/pprof/) for more information about how to collect and analyze profiling data. + +## Use tracing + +The `grafana-server` can be started with the arguments `-tracing` to enable tracing and `-tracing-file` to override the default trace file (`trace.out`) where trace result is written to. For example: + +```bash +./grafana-server -tracing -tracing-file=/tmp/trace.out +``` + +You can configure or override profiling settings using environment variables: + +```bash +export GF_DIAGNOSTICS_TRACING_ENABLED=true +export GF_DIAGNOSTICS_TRACING_FILE=/tmp/trace.out +``` + +View the trace in a web browser (Go required to be installed): + +```bash +go tool trace +2019/11/24 22:20:42 Parsing trace... +2019/11/24 22:20:42 Splitting trace... +2019/11/24 22:20:42 Opening browser. Trace viewer is listening on http://127.0.0.1:39735 +``` + +See [Go command trace](https://golang.org/cmd/trace/) for more information about how to analyze trace files. \ No newline at end of file diff --git a/docs/sources/troubleshooting/troubleshoot-dashboards.md b/docs/sources/troubleshooting/troubleshoot-dashboards.md new file mode 100644 index 0000000..41c8f1d --- /dev/null +++ b/docs/sources/troubleshooting/troubleshoot-dashboards.md @@ -0,0 +1,42 @@ ++++ +title = "Troubleshoot dashboards" +description = "Guide to troubleshooting Grafana dashboards" +keywords = ["grafana", "troubleshooting", "documentation", "dashboards"] +weight = 100 ++++ + +# Troubleshoot dashboards + +This page provides information to solve common dashboard problems. + +## Dashboard is slow + +- Are you trying to render dozens (or hundreds or thousands) of time-series on a graph? This can cause the browser to lag and feel sluggish. Try using functions like `highestMax` (in Graphite) to reduce the returned series. +- Sometimes the series names can be very large. This causes larger response sizes. Try using `alias` to reduce the size of the returned series names. +- Are you querying many time-series or for a long range of time? Both of these can cause Grafana or your data source to pull in a lot of data, which may slow it down. +- It could be high load on your network infrastructure. If the slowness isn't consistent, this may be the problem. + +## Dashboard refresh rate issues + +By default, Grafana queries your data source every 30 seconds. Setting a low refresh rate on your dashboards puts unnecessary stress on the backend. In many cases, querying this frequently makes no sense, because the data isn't being sent to the system such that changes would be seen. + +We recommend the following: + +- Do not enable auto-refreshing on dashboards, panels, or variables unless you need it. Users can refresh their browser manually, or you can set the refresh rate for a time period that makes sense (every ten minutes, every hour, and so on). +- If it is required, then set the refresh rate to once a minute. Again, users can always refresh the dashboard manually. +- If your dashboard has a longer time period (such as a week), then you really don't need automated refreshing. + +### Handling or rendering null data is wrong/confusing/weird + +Some applications publish data intermittently; for example, they only post a metric when an event occurs. By +default, Grafana graphs connect lines between the data points. This can be very deceiving. + +In the picture below we have enabled: +- Points and 3-point radius to highlight where data points are actually present. +- **Null value** is set to **connected**. + +{{< docs-imagebox img="/img/docs/troubleshooting/grafana_null_connected.png" max-width="1200px" >}} + +In this graph, we set graph to show bars instead of lines and set the **Null value** to graph **null as zero**. There is a very big different in the visuals. + +{{< docs-imagebox img="/img/docs/troubleshooting/grafana_null_zero.png" max-width="1200px" >}} diff --git a/docs/sources/troubleshooting/troubleshoot-queries.md b/docs/sources/troubleshooting/troubleshoot-queries.md new file mode 100644 index 0000000..aa02b41 --- /dev/null +++ b/docs/sources/troubleshooting/troubleshoot-queries.md @@ -0,0 +1,29 @@ ++++ +title = "Troubleshoot queries" +description = "Guide to troubleshooting Grafana queries" +keywords = ["grafana", "troubleshooting", "documentation", "guide", "queries"] +weight = 400 ++++ + +# Troubleshoot queries + +This page provides information to solve common dashboard problems. + +## I get different results when I rearrange my functions + +Function order is very important. Just like in math, the order that you place your functions can affect the result. + +## Inspect your query request and response + +The most common problems are related to the query and response from your data source. Even if it looks +like a bug or visualization issue in Grafana, it is almost always a problem with the data source query or +the data source response. Start by inspecting your panel query and response. + +For more information, refer to [Inspect a panel]({{< relref "../panels/inspect-panel.md" >}}). + +## My query is slow + +How many data points is your query returning? A query that returns lots of data points will be slow. Try this: +- In **Query options**, limit the **Max data points** returned. +- In **Query options**, increase the **Min interval** time. +- In your query, use a `group by` function. diff --git a/docs/sources/variables/_index.md b/docs/sources/variables/_index.md new file mode 100644 index 0000000..97d5828 --- /dev/null +++ b/docs/sources/variables/_index.md @@ -0,0 +1,46 @@ ++++ +title = "Templates and variables" +weight = 130 ++++ + +# Templates and variables + +A variable is a placeholder for a value. You can use variables in metric queries and in panel titles. So when you change +the value, using the dropdown at the top of the dashboard, your panel's metric queries will change to reflect the new value. + +Variables allow you to create more interactive and dynamic dashboards. Instead of hard-coding things like server, application, +and sensor names in your metric queries, you can use variables in their place. Variables are displayed as dropdown lists at the top of +the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. +{{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}} + +These can be especially useful for administrators who want to allow Grafana viewers to quickly adjust visualizations but do not want to give them full editing permissions. Grafana Viewers can use variables. + +Variables and templates also allow you to single-source dashboards. If you have multiple identical data sources or servers, you can make one dashboard and use variables to change what you are viewing. This simplifies maintenance and upkeep enormously. + +## Templates + +A _template_ is any query that contains a variable. + +For example, if you were administering a dashboard to monitor several servers, you _could_ make a dashboard for each server. Or you could create one dashboard and use panels with template queries like this one: + +``` +wmi_system_threads{instance=~"$server"} +``` + +Variable values are always synced to the URL using the syntax `var-=value`. + +## Examples of templates and variables + +To see variable and template examples, go to any of the dashboards listed in [Variable examples]({{< relref "variable-examples.md" >}}). + +Variables are listed in drop-down lists across the top of the screen. Select different variables to see how the visualizations change. + +To see variable settings, navigate to **Dashboard Settings > Variables**. Click a variable in the list to see its settings. + +Variables can be used in titles, descriptions, text panels, and queries. Queries with text that starts with `$` are templates. Not all panels will have template queries. + +## Variable best practices + +- Variable drop-down lists are displayed in the order they are listed in the variable list in Dashboard settings. +- Put the variables that you will change often at the top, so they will be shown first (far left on the dashboard). + diff --git a/docs/sources/variables/advanced-variable-format-options.md b/docs/sources/variables/advanced-variable-format-options.md new file mode 100644 index 0000000..0650e78 --- /dev/null +++ b/docs/sources/variables/advanced-variable-format-options.md @@ -0,0 +1,161 @@ ++++ +title = "Advanced variable format options" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 600 ++++ + +# Advanced variable format options + +The formatting of the variable interpolation depends on the data source, but there are some situations where you might want to change the default formatting. + +For example, the default for the MySql data source is to join multiple values as comma-separated with quotes: `'server01','server02'`. In some cases, you might want to have a comma-separated string without quotes: `server01,server02`. You can make that happen with advanced variable formatting options listed below. + +## General syntax + +Syntax: `${var_name:option}` + +Test the formatting options on the [Grafana Play site](https://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1). + +If any invalid formatting option is specified, then `glob` is the default/fallback option. + +An alternative syntax (that might be deprecated in the future) is `[[var_name:option]]`. + +## CSV + +Formats variables with multiple values as a comma-separated string. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:csv}' +Interpolation result: 'test1,test2' +``` + +## Distributed - OpenTSDB + +Formats variables with multiple values in custom format for OpenTSDB. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:distributed}' +Interpolation result: 'test1,servers=test2' +``` + +## Doublequote + +Formats single- and multi-valued variables into a comma-separated string, escapes `"` in each value by `\"` and quotes each value with `"`. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:doublequote}' +Interpolation result: '"test1","test2"' +``` + +## Glob - Graphite + +Formats variables with multiple values into a glob (for Graphite queries). + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:glob}' +Interpolation result: '{test1,test2}' +``` + +## JSON + +Formats variables with multiple values as a comma-separated string. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:json}' +Interpolation result: '["test1", "test2"]' +``` + +## Lucene - Elasticsearch + +Formats variables with multiple values in Lucene format for Elasticsearch. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:lucene}' +Interpolation result: '("test1" OR "test2")' +``` + +## Percentencode + +Formats single and multi valued variables for use in URL parameters. + +```bash +servers = ['foo()bar BAZ', 'test2'] +String to interpolate: '${servers:percentencode}' +Interpolation result: 'foo%28%29bar%20BAZ%2Ctest2' +``` + +## Pipe + +Formats variables with multiple values into a pipe-separated string. + +```bash +servers = ['test1.', 'test2'] +String to interpolate: '${servers:pipe}' +Interpolation result: 'test1.|test2' +``` + +## Raw + +Turns off data source-specific formatting, such as single quotes in an SQL query. + +```bash +servers = ['test.1', 'test2'] +String to interpolate: '${var_name:raw}' +Interpolation result: 'test.1,test2' +``` + +## Regex + +Formats variables with multiple values into a regex string. + +```bash +servers = ['test1.', 'test2'] +String to interpolate: '${servers:regex}' +Interpolation result: '(test1\.|test2)' +``` + +## Singlequote + +Formats single- and multi-valued variables into a comma-separated string, escapes `'` in each value by `\'` and quotes each value with `'`. + +```bash +servers = ['test1', 'test2'] +String to interpolate: '${servers:singlequote}' +Interpolation result: "'test1','test2'" +``` + +## Sqlstring + +Formats single- and multi-valued variables into a comma-separated string, escapes `'` in each value by `''` and quotes each value with `'`. + +```bash +servers = ["test'1", "test2"] +String to interpolate: '${servers:sqlstring}' +Interpolation result: "'test''1','test2'" +``` + +## Text + +Formats single- and multi-valued variables into their text representation. For a single variable it will just return the text representation. For multi-valued variables it will return the text representation combined with `+`. + +```bash +servers = ["test1", "test2"] +String to interpolate: '${servers:text}' +Interpolation result: "test1 + test2" +``` + +## Query parameters + +Formats single- and multi-valued variables into their query parameter representation. Example: `var-foo=value1&var-foo=value2` + +```bash +servers = ["test1", "test2"] +String to interpolate: '${servers:queryparam}' +Interpolation result: "var-servers=test1&var-servers=test2" +``` diff --git a/docs/sources/variables/filter-variables-with-regex.md b/docs/sources/variables/filter-variables-with-regex.md new file mode 100644 index 0000000..fb25635 --- /dev/null +++ b/docs/sources/variables/filter-variables-with-regex.md @@ -0,0 +1,111 @@ ++++ +title = "Filter variables with regex" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 700 ++++ + + +# Filter variables with regex + +Using the Regex Query option, you filter the list of options returned by the variable query or modify the options returned. + +This page shows how to use regex to filter/modify values in the variable dropdown. + +Using the Regex Query Option, you filter the list of options returned by the Variable query or modify the options returned. For more information, refer to the Mozilla guide on [Regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). + +Examples of filtering on the following list of options: + +```text +backend_01 +backend_02 +backend_03 +backend_04 +``` + +## Filter so that only the options that end with `01` or `02` are returned: + +Regex: + +```regex +/.*[01|02]/ +``` + +Result: + +```text +backend_01 +backend_02 +``` + +## Filter and modify the options using a regex capture group to return part of the text: + +Regex: + +```regex +/.*(01|02)/ +``` + +Result: + +```text +01 +02 +``` + +## Filter and modify - Prometheus Example + +List of options: + +```text +up{instance="demo.robustperception.io:9090",job="prometheus"} 1 1521630638000 +up{instance="demo.robustperception.io:9093",job="alertmanager"} 1 1521630638000 +up{instance="demo.robustperception.io:9100",job="node"} 1 1521630638000 +``` + +Regex: + +```regex +/.*instance="([^"]*).*/ +``` + +Result: + +```text +demo.robustperception.io:9090 +demo.robustperception.io:9093 +demo.robustperception.io:9100 +``` + +## Filter and modify using named text and value capture groups + +> **Note:** This feature is available in Grafana 7.4+. + +Using named capture groups, you can capture separate 'text' and 'value' parts from the options returned by the variable query. This allows the variable drop-down list to contain a friendly name for each value that can be selected. + +For example, when querying the `node_hwmon_chip_names` Prometheus metric, the `chip_name` is a lot friendlier that the `chip` value. So the following variable query result: + +```text +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_0",chip_name="enp216s0f0np0"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_1",chip_name="enp216s0f0np1"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_2",chip_name="enp216s0f0np2"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_3",chip_name="enp216s0f0np3"} 1 +``` + +Passed through the following Regex: + +```regex +/chip_name="(?[^"]+)|chip="(?[^"]+)/g +``` + +Would produce the following drop-down list: + +```text +Display Name Value +------------ ------------------------- +enp216s0f0np0 0000:d7:00_0_0000:d8:00_0 +enp216s0f0np1 0000:d7:00_0_0000:d8:00_1 +enp216s0f0np2 0000:d7:00_0_0000:d8:00_2 +enp216s0f0np3 0000:d7:00_0_0000:d8:00_3 +``` + +**Note:** Only `text` and `value` capture group names are supported. diff --git a/docs/sources/variables/formatting-multi-value-variables.md b/docs/sources/variables/formatting-multi-value-variables.md new file mode 100644 index 0000000..2ec4d9d --- /dev/null +++ b/docs/sources/variables/formatting-multi-value-variables.md @@ -0,0 +1,30 @@ ++++ +title = "Multi-value variables" +weight = 600 ++++ + +# Multi-value variables + +Interpolating a variable with multiple values selected is tricky as it is not straight forward how to format the multiple values into a string that is valid in the given context where the variable is used. Grafana tries to solve this by allowing each data source plugin to inform the templating interpolation engine what format to use for multiple values. + +> **Note:** The **Custom all value** option on the variable must be blank for Grafana to format all values into a single string. If leave it blank, then the Grafana concatenates (adds together) all the values in the query. Something like `value1,value2,value3`. If a custom `all` value is used, then instead the value will be something like `*` or `all`. + +## Multi-value variables with a Graphite data source + +Graphite uses glob expressions. A variable with multiple values would, in this case, be interpolated as `{host1,host2,host3}` if the current variable value was *host1*, *host2*, and *host3*. + +## Multi-value variables with a Prometheus or InfluxDB data source + +InfluxDB and Prometheus use regex expressions, so the same variable would be interpolated as `(host1|host2|host3)`. Every value would also be regex escaped. If not, a value with a regex control character would break the regex expression. + +## Multi-value variables with an Elastic data source + +Elasticsearch uses lucene query syntax, so the same variable would be formatted as `("host1" OR "host2" OR "host3")`. In this case, every value must be escaped so that the value only contains lucene control words and quotation marks. + +## Troubleshoot multi-value variables + +Automatic escaping and formatting can cause problems and it can be tricky to grasp the logic behind it. Especially for InfluxDB and Prometheus where the use of regex syntax requires that the variable is used in regex operator context. + +If you do not want Grafana to do this automatic regex escaping and formatting, then you must do one of the following: +- Turn off the **Multi-value** or **Include All option** options. +- Use the [raw variable format]({{< relref "advanced-variable-format-options.md#raw" >}}). diff --git a/docs/sources/variables/inspect-variable.md b/docs/sources/variables/inspect-variable.md new file mode 100644 index 0000000..ab421d7 --- /dev/null +++ b/docs/sources/variables/inspect-variable.md @@ -0,0 +1,22 @@ ++++ +title = "Inspect variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 125 ++++ + +# Inspect variables and their dependencies + +The variables page lets you easily identify whether a variable is being referenced (or used) in other variables or dashboard. In addition, you can also [add]({{< relref "variable-types/_index.md" >}}) variables and [manage]({{< relref "manage-variable.md" >}}) existing variables from this page. + +> **Note:** This feature is available in Grafana 7.4 and later versions. + +![Variables list](/img/docs/variables-templates/variables-list-7-4.png) + +Any variable that is referenced or used has a green check mark next to it, while unreferenced variables have a orange caution icon next to them. + +![Variables list](/img/docs/variables-templates/variable-not-referenced-7-4.png) + +In addition, all referenced variables have a dependency icon next to the green check mark. You can click on the icon to view the dependency map. The dependency map can be moved. You can zoom in out with mouse wheel or track pad equivalent. + +![Variables list](/img/docs/variables-templates/dependancy-map-7-4.png) diff --git a/docs/sources/variables/manage-variable.md b/docs/sources/variables/manage-variable.md new file mode 100644 index 0000000..b25c3a0 --- /dev/null +++ b/docs/sources/variables/manage-variable.md @@ -0,0 +1,22 @@ ++++ +title = "Manage variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 120 ++++ + +# Manage variables + +The variables page lets you [add]({{< relref "variable-types/_index.md" >}}) variables and manage existing variables. It also allows you to [inspect]({{< relref "inspect-variable.md" >}}) variables and identify whether a variable is being referenced (or used) in other variables or dashboard. + +## Move + +You can move a variable up or down the list using the up and down arrows respectively. + +## Clone + +To clone a variable, click the clone icon from the set of icons on the right. This creates a copy of the variable with the name of the original variable prefixed with `copy_of_`. + +## Delete + +To delete a variable, click the trash icon from the set of icons on the right. diff --git a/docs/sources/variables/repeat-panels-or-rows.md b/docs/sources/variables/repeat-panels-or-rows.md new file mode 100644 index 0000000..9ec9af9 --- /dev/null +++ b/docs/sources/variables/repeat-panels-or-rows.md @@ -0,0 +1,50 @@ ++++ +title = "Repeat panels or rows" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "repeat"] +weight = 800 ++++ + +# Repeat panels or rows + +Grafana lets you create dynamic dashboards using _template variables_. All variables in your queries expand to the current value of the variable before the query is sent to the database. Variables let you reuse a single dashboard for all your services. + +Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want +Grafana to dynamically create new panels or rows based on what values you have selected, you can use the _Repeat_ feature. + +## Grafana Play examples + +You can see examples in the following dashboards: + +- [Prometheus repeat](https://play.grafana.org/d/000000036/prometheus-repeat) +- [Repeated Rows Dashboard](https://play.grafana.org/d/000000153/repeat-rows) + +## Repeating panels + +If you have a variable with `Multi-value` or `Include all value` options enabled you can choose one panel and have Grafana repeat that panel +for every selected value. You find the _Repeat_ feature under the _General tab_ in panel edit mode. + +The `direction` controls how the panels will be arranged. + +By choosing `horizontal` the panels will be arranged side-by-side. Grafana will automatically adjust the width +of each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated +panel. + +Set `Max per row` to tell grafana how many panels per row you want at most. It defaults to _4_ if you don't set anything. + +By choosing `vertical` the panels will be arranged from top to bottom in a column. The width of the repeated panels will be the same as of the first panel (the original template) being repeated. + +Only make changes to the first panel (the original template). To have the changes take effect on all panels you need to trigger a dynamic dashboard re-build. +You can do this by either changing the variable value (that is the basis for the repeat) or reload the dashboard. + +> **Note:** Repeating panels require variables to have one or more items selected; you cannot repeat a panel zero times to hide it. + +## Repeating rows + +As seen above with the panels you can also repeat rows if you have variables set with `Multi-value` or +`Include all value` selection option. + +To enable this feature you need to first add a new _Row_ using the _Add Panel_ menu. Then by hovering the row title and +clicking on the cog button, you will access the `Row Options` configuration panel. You can then select the variable +you want to repeat the row for. + +It may be a good idea to use a variable in the row title as well. diff --git a/docs/sources/variables/syntax.md b/docs/sources/variables/syntax.md new file mode 100644 index 0000000..f1ffcf8 --- /dev/null +++ b/docs/sources/variables/syntax.md @@ -0,0 +1,24 @@ ++++ +title = "Variable syntax" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +aliases = ["/docs/grafana/latest/reference/templating"] +weight = 100 ++++ + +# Variable syntax + +Panel titles and metric queries can refer to variables using two different syntaxes: + +- `$varname` + This syntax is easy to read, but it does not allow you to use a variable in the middle of a word. + **Example:** apps.frontend.$server.requests.count +- `${var_name}` Use this syntax when you want to interpolate a variable in the middle of an expression. +- `${var_name:}` This format gives you more control over how Grafana interpolates values. Refer to [Advanced variable format options]({{< relref "advanced-variable-format-options.md" >}}) for more detail on all the formatting types. +- `[[varname]]` Do not use. Deprecated old syntax, will be removed in a future release. + +Before queries are sent to your data source the query is _interpolated_, meaning the variable is replaced with its current value. During +interpolation, the variable value might be _escaped_ in order to conform to the syntax of the query language and where it is used. +For example, a variable used in a regex expression in an InfluxDB or Prometheus query will be regex escaped. Read the data source specific +documentation topic for details on value escaping during interpolation. + +For advanced syntax to override data source default formatting, refer to [Advanced variable format options]({{< relref "advanced-variable-format-options.md" >}}). diff --git a/docs/sources/variables/variable-examples.md b/docs/sources/variables/variable-examples.md new file mode 100644 index 0000000..2ef6166 --- /dev/null +++ b/docs/sources/variables/variable-examples.md @@ -0,0 +1,20 @@ ++++ +title = "Variable examples" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable"] +weight = 200 ++++ + +# Variable examples + +This page contains links to dashboards in Grafana Play with examples of template variables. + +- [Elasticsearch Metrics](https://play.grafana.org/d/000000014/elasticsearch-metrics?orgId=1) - Uses ad hoc filters, global variables, and a custom variable. +- [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1) - Uses query variables, chained query variables, an interval variable, and a repeated panel. +- [Influx DB Group By Variable](https://play.grafana.org/d/000000137/influxdb-group-by-variable?orgId=1) - Query variable, panel uses the variable results to group the metric data. +- [InfluxDB Raw Query Template Var](https://play.grafana.org/d/000000083/influxdb-raw-query-template-var?orgId=1) - Uses query variables, chained query variables, and an interval variable. +- [InfluxDB Server Monitoring](https://play.grafana.org/d/AAy9r_bmk/influxdb-server-monitoring?orgId=1) - Uses query variables, chained query variables, an interval variable, and an ad hoc filter. +- [Prometheus templating](https://play.grafana.org/d/000000063/prometheus-templating?orgId=1) - Uses chained query variables. +- [Template Redux](https://play.grafana.org/d/p-k6QtkGz/template-redux?orgId=1) - Uses query variables, chained query variables, ad hoc filters, an interval variable, a text box variable, a custom variable, and a data source variable. +- [Templating, repeated panels](https://play.grafana.org/d/000000025/templating-repeated-panels?orgId=1) - Two sets of repeated panels use query variables. +- [Templating showcase](https://play.grafana.org/d/000000091/templating-showcase?orgId=1) - Uses custom, query, chained query, and data source variables. +- [Templating value groups](https://play.grafana.org/d/000000024/templating-value-groups?orgId=1) - Uses query variable with value groups. diff --git a/docs/sources/variables/variable-selection-options.md b/docs/sources/variables/variable-selection-options.md new file mode 100644 index 0000000..24ce424 --- /dev/null +++ b/docs/sources/variables/variable-selection-options.md @@ -0,0 +1,26 @@ ++++ +title = "Variable selection options" +weight = 400 ++++ + +# Configure variable selection options + +**Selection Options** are a feature you can use to manage variable option selections. All selection options are optional, and they are off by default. + +## Multi-value + +If you turn this on, then the variable dropdown list allows users to select multiple options at the same time. For more information, refer to [Formatting multi-value variables]({{< relref "formatting-multi-value-variables.md" >}}). + +## Include All option + +Grafana adds an `All` option to the variable dropdown list. If a user selects this option, then all variable options are selected. + +## Custom all value + +This option is only visible if the **Include All option** is selected. + +Enter regex, globs, or lucene syntax in the **Custom all value** field to define the value of the `All` option. + +By default the `All` value includes all options in combined expression. This can become very long and can have performance problems. Sometimes it can be better to specify a custom all value, like a wildcard regex. + +In order to have custom regex, globs, or lucene syntax in the **Custom all value** option, it is never escaped so you will have to think about what is a valid value for your data source. diff --git a/docs/sources/variables/variable-types/_index.md b/docs/sources/variables/variable-types/_index.md new file mode 100644 index 0000000..712bfec --- /dev/null +++ b/docs/sources/variables/variable-types/_index.md @@ -0,0 +1,20 @@ ++++ +title = "Add variables" +weight = 140 ++++ + +# Variables types + +Grafana uses several types of variables. + +| Variable type | Description | +|:---|:---| +| Query | Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. [Add a query variable]({{< relref "add-query-variable.md" >}}). | +| Custom | Define the variable options manually using a comma-separated list. [Add a custom variable]({{< relref "add-custom-variable.md" >}}). | +| Text box | Display a free text input field with an optional default value. [Add a text box variable]({{< relref "add-text-box-variable.md" >}}). | +| Constant | Define a hidden constant. [Add a constant variable]({{< relref "add-constant-variable.md" >}}). | +| Data source | Quickly change the data source for an entire dashboard. [Add a data source variable]({{< relref "add-data-source-variable.md" >}}). | +| Interval | Interval variables represent time spans. [Add an interval variable]({{< relref "add-interval-variable.md" >}}). | +| Ad hoc filters | Key/value filters that are automatically added to all metric queries for a data source (InfluxDB, Prometheus, and Elasticsearch only). [Add ad hoc filters]({{< relref "add-ad-hoc-filters.md" >}}). | +| Global variables | Built-in variables that can be used in expressions in the query editor. Refer to [Global variables]({{< relref "global-variables" >}}). | +| Chained variables | Variable queries can contain other variables. Refer to [Chained variables]({{< relref "chained-variables.md" >}}). | diff --git a/docs/sources/variables/variable-types/add-ad-hoc-filters.md b/docs/sources/variables/variable-types/add-ad-hoc-filters.md new file mode 100644 index 0000000..074c494 --- /dev/null +++ b/docs/sources/variables/variable-types/add-ad-hoc-filters.md @@ -0,0 +1,32 @@ ++++ +title = "Add ad hoc filters" +aliases = ["/docs/grafana/latest/variables/add-ad-hoc-filters.md"] +weight = 700 ++++ + +# Add ad hoc filters + +_Ad hoc filters_ allow you to add key/value filters that are automatically added to all metric queries that use the specified data source. Unlike other variables, you do not use ad hoc filters in queries. Instead, you use ad hoc filters to write filters for existing queries. + +> **Note:** Ad hoc filter variables only work with InfluxDB, Prometheus, and Elasticsearch data sources. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Ad hoc filters**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Options + +1. In the **Data source** list, select the target data source. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. Click **Add** to add the variable to the dashboard. + +## Create ad hoc filters + +Ad hoc filters are one of the most complex and flexible variable options available. Instead of a regular list of variable options, this variable allows you to build a dashboard-wide ad hoc query. Filters you apply in this manner are applied to all panels on the dashboard. \ No newline at end of file diff --git a/docs/sources/variables/variable-types/add-constant-variable.md b/docs/sources/variables/variable-types/add-constant-variable.md new file mode 100644 index 0000000..68c6815 --- /dev/null +++ b/docs/sources/variables/variable-types/add-constant-variable.md @@ -0,0 +1,31 @@ ++++ +title = "Add a constant variable" +aliases = ["/docs/grafana/latest/variables/add-constant-variable.md"] +weight = 400 ++++ + +# Add a constant variable + +_Constant_ variables allow you to define a hidden constant. This is useful for metric path prefixes for dashboards you want to share. When you export a dashboard, constant variables are converted to import options. + +Constant variables are _not_ flexible. Each constant variable only holds one value, and it cannot be updated unless you update the variable settings. + +Constant variables are useful when you have complex values that you need to include in queries but don't want to retype in every single query. For example, if you had a server path called `i-0b6a61efe2ab843gg`, then you could replace it with a variable called `$path_gg`. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Constant**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **Variable -** No variable dropdown is displayed on the dashboard. This is the default. + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + +## Enter Constant options + +1. In the **Value** field, enter the variable value. You can enter letters, numbers, and symbols. You can even use wildcards if you use [raw format]({{< relref "../advanced-variable-format-options.md#raw" >}}). +1. In **Preview of values**, Grafana displays the current variable value. Review it to ensure it matches what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-custom-variable.md b/docs/sources/variables/variable-types/add-custom-variable.md new file mode 100644 index 0000000..d6eb462 --- /dev/null +++ b/docs/sources/variables/variable-types/add-custom-variable.md @@ -0,0 +1,30 @@ ++++ +title = "Add a custom variable" +aliases = ["/docs/grafana/latest/variables/add-custom-variable.md"] +weight = 200 ++++ + +# Add a custom variable + +Use a _custom_ variable for values that do not change. This might be numbers, strings, or even other variables. + +For example, if you have server names or region names that never change, then you might want to create them as custom variables rather than query variables. Because they do not change, you might use them in [chained variables]({{< relref "chained-variables.md" >}}) rather than other query variables. That would reduce the number of queries Grafana must send when chained variables are updated. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Custom**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Custom Options + +1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, other variables or key/value pairs separated by a space and a colon, i.e. `key1 : value1,key2 : value2`. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-data-source-variable.md b/docs/sources/variables/variable-types/add-data-source-variable.md new file mode 100644 index 0000000..941999e --- /dev/null +++ b/docs/sources/variables/variable-types/add-data-source-variable.md @@ -0,0 +1,29 @@ ++++ +title = "Add a data source variable" +aliases = ["/docs/grafana/latest/variables/add-data-source-variable.md"] +weight = 500 ++++ + +# Add a data source variable + +_Data source_ variables allow you to quickly change the data source for an entire dashboard. They are useful if you have multiple instances of a data source, perhaps in different environments. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Datasource**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Data source options + +1. In the **Type** list, select the target data source for the variable. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. (optional) In **Instance name filter**, enter a regex filter for which data source instances to choose from in the variable value drop-down list. Leave this field empty to display all instances. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-interval-variable.md b/docs/sources/variables/variable-types/add-interval-variable.md new file mode 100644 index 0000000..ca7b7f3 --- /dev/null +++ b/docs/sources/variables/variable-types/add-interval-variable.md @@ -0,0 +1,46 @@ ++++ +title = "Add an interval variable" +aliases = ["/docs/grafana/latest/variables/add-interval-variable.md"] +weight = 600 ++++ + +# Add an interval variable + +Use an _interval_ variable to represents time spans such as `1m`,`1h`, `1d`. You can think of them as a dashboard-wide "group by time" command. Interval variables change how the data is grouped in the visualization. You can also use the Auto Option to return a set number of data points per time span. + +You can use an interval variable as a parameter to group by time (for InfluxDB), date histogram interval (for Elasticsearch), or as a summarize function parameter (for Graphite). + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Interval**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Interval Options + +1. In the **Values** field, enter the time range intervals that you want to appear in the variable drop-down list. The following time units are supported: `s (seconds)`, `m (minutes)`, `h (hours)`, `d (days)`, `w (weeks)`, `M (months)`, and `y (years)`. You can also accept or edit the default values: `1m,10m,30m,1h,6h,12h,1d,7d,14d,30d`. +1. (optional) Turn on the **Auto Option** if you want to add the `auto` option to the list. This option allows you to specify how many times the current time range should be divided to calculate the current `auto` time span. If you turn it on, then two more options appear: + - **Step count -** Select the number of times the current time range will be divided to calculate the value, similar to the **Max data points** query option. For example, if the current visible time range is 30 minutes, then the `auto` interval groups the data into 30 one-minute increments. The default value is 30 steps. + - **Min Interval -** The minimum threshold below which the step count intervals will not divide the time. To continue the 30 minute example, if the minimum interval is set to 2m, then Grafana would group the data into 15 two-minute increments. +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. + +## Interval variable examples + +Example using the template variable `myinterval` in a Graphite function: + +``` +summarize($myinterval, sum, false) +``` + +A more complex Graphite example, from the [Graphite Template Nested Requests panel](https://play.grafana.org/d/000000056/graphite-templated-nested?editPanel=2&orgId=1): + +``` +groupByNode(summarize(movingAverage(apps.$app.$server.counters.requests.count, 5), '$interval', 'sum', false), 2, 'sum') +``` \ No newline at end of file diff --git a/docs/sources/variables/variable-types/add-query-variable.md b/docs/sources/variables/variable-types/add-query-variable.md new file mode 100644 index 0000000..0bc6ac2 --- /dev/null +++ b/docs/sources/variables/variable-types/add-query-variable.md @@ -0,0 +1,44 @@ ++++ +title = "Add a query variable" +aliases = ["/docs/grafana/latest/variables/add-query-variable.md"] +weight = 100 ++++ + +# Add a query variable + +Query variables allow you to write a data source query that can return a list of metric names, tag values, or keys. For example, a query variable might return a list of server names, sensor IDs, or data centers. The variable values change as they dynamically fetch options with a data source query. + +Query expressions can contain references to other variables and in effect create linked variables. Grafana detects this and automatically refreshes a variable when one of its linked variables change. + +## Query expressions + +Query expressions are different for each data source. For more information, refer to the documentation for your [data source]({{< relref "../../datasources/_index.md" >}}). + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Query**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Query Options + +1. In the **Data source** list, select the target data source for the query. For more information about data sources, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source.md" >}}). +1. In the **Refresh** list, select when the variable should update options. + - **Never -** Variables queries are cached and values are not updated. This is fine if the values never change, but problematic if they are dynamic and change a lot. + - **On Dashboard Load -** Queries the data source every time the dashboard loads. This slows down dashboard loading, because the variable query needs to be completed before dashboard can be initialized. + - **On Time Range Change -** Queries the data source when the dashboard time range changes. Only use this option if your variable options query contains a time range filter or is dependent on the dashboard time range. +1. In the **Query** field, enter a query. + - The query field varies according to your data source. Some data sources have custom query editors. + - If you need more room in a single input field query editor, then hover your cursor over the lines in the lower right corner of the field and drag downward to expand. +1. (optional) In the **Regex** field, type a regex expression to filter or capture specific parts of the names returned by your data source query. To see examples, refer to [Filter variables with regex]({{< relref "../filter-variables-with-regex.md" >}}). +1. In the **Sort** list, select the sort order for values to be displayed in the dropdown list. The default option, **Disabled**, means that the order of options returned by your data source query will be used. +1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). +1. (optional) Enter [Value groups/tags]({{< relref "../variable-value-tags.md" >}}). +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/add-text-box-variable.md b/docs/sources/variables/variable-types/add-text-box-variable.md new file mode 100644 index 0000000..ad35046 --- /dev/null +++ b/docs/sources/variables/variable-types/add-text-box-variable.md @@ -0,0 +1,27 @@ ++++ +title = "Add a text box variable" +aliases = ["/docs/grafana/latest/variables/add-text-box-variable.md"] +weight = 300 ++++ + +# Add a text box variable + +_Text box_ variables display a free text input field with an optional default value. This is the most flexible variable, because you can enter any value. Use this type of variable if you have metrics with high cardinality or if you want to update multiple panels in a dashboard at the same time. + +## Enter General options + +1. Navigate to the dashboard you want to make a variable for and then click the **Dashboard settings** (gear) icon at the top of the page. +1. On the Variables tab, click **New**. +1. Enter a **Name** for your variable. +1. In the **Type** list, select **Text box**. +1. (optional) In **Label**, enter the display name of the variable dropdown. If you don't enter a display name, then the dropdown label will be the variable name. +1. Choose a **Hide** option: + - **No selection (blank) -** The variable dropdown displays the variable **Name** or **Label** value. This is the default. + - **Label -** The variable dropdown only displays the selected variable value and a down arrow. + - **Variable -** No variable dropdown is displayed on the dashboard. + +## Enter Text options + +1. (optional) In the **Default value** field, select the default value for the variable. If you do not enter anything in this field, then Grafana displays an empty text box for users to type text into. +1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. +1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/chained-variables.md b/docs/sources/variables/variable-types/chained-variables.md new file mode 100644 index 0000000..e94091d --- /dev/null +++ b/docs/sources/variables/variable-types/chained-variables.md @@ -0,0 +1,173 @@ ++++ +title = "Chained variables" +keywords = ["grafana", "templating", "variable", "nested", "chained", "linked"] +aliases = ["/docs/grafana/latest/variables/chained-variables.md"] +weight = 800 ++++ + +# Chained variables + +_Chained variables_, also called _linked variables_ or _nested variables_, are query variables with one or more other variables in their variable query. This page explains how chained variables work and provides links to example dashboards that use chained variables. + +Chained variable queries are different for every data source, but the premise is the same for all. You can use chained variable queries in any data source that allows them. + +Extremely complex linked templated dashboards are possible, 5 or 10 levels deep. Technically, there is no limit to how deep or complex you can go, but the more links you have, the greater the query load. + +## Grafana Play dashboard examples + +The following Grafana Play dashboards contain fairly simple chained variables, only two layers deep. To view the variables and their settings, click **Dashboard settings** (gear icon) and then click **Variables**. Both examples are expanded in the following section. + +- [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1&var-app=country&var-server=All&var-interval=1h) +- [InfluxDB Templated](https://play.grafana.org/d/000000002/influxdb-templated?orgId=1) + +## Examples explained + +Variables are useful to reuse dashboards, dynamically change what is shown in dashboards. Chained variables are especially useful to filter what you see. + +Create parent/child relationship in variable, sort of a tree structure where you can select different levels of filters. + +The following sections explain the linked examples in the dashboards above in depth and builds on them. While the examples are data source-specific, the concepts can be applied broadly. + +### Graphite example + +In this example, you have several applications. Each application has a different subset of servers. It is based on the [Graphite Templated Nested](https://play.grafana.org/d/000000056/graphite-templated-nested?orgId=1&var-app=country&var-server=All&var-interval=1h). + +Now, you could make separate variables for each metric source, but then you have to know which server goes with which app. A better solution is to use one variable to filter another. In this example, when the user changes the value of the `app` variable, it changes the dropdown options returned by the `server` variable. Both variables use the **Multi-value** option and **Include all option**, allowing users to select some or all options presented at any time. + +#### app variable + +The query for this variable basically says, "Give me all the applications that exist." + +``` +apps.* +``` + +The values returned are `backend`, `country`, `fakesite`, and `All`. + +#### server variable + +The query for this variable basically says, "Give me all servers for the currently chosen application." + +``` +apps.$app.* +``` + +If the user selects `backend`, then the query changes to: + +``` +apps.backend.* +``` + +The query returns all servers associated with `backend`, including `backend_01`, `backend_02`, and so on. + +If the user selects `fakesite`, then the query changes to: + +``` +apps.fakesite.* +``` + +The query returns all servers associated with `fakesite`, including `web_server_01`, `web_server_02`, and so on. + +#### More variables + +> **Note:** This example is theoretical. The Graphite server used in the example does not contain CPU metrics. + +The dashboard stops at two levels, but you could keep going. For example, if you wanted to get CPU metrics for selected servers, you could copy the `server` variable and extend the query so that it reads: + +``` +apps.$app.$server.cpu.* +``` + +This query basically says, "Show me the CPU metrics for the selected server." + +Depending on what variable options the user selects, you could get queries like: + +``` +apps.backend.backend_01.cpu.* +apps.{backend.backend_02,backend_03}.cpu.* +apps.fakesite.web_server_01.cpu.* +``` + +### InfluxDB example + +In this example, you have several data centers. Each data center has a different subset of hosts. It is based on the [InfluxDB Templated](https://play.grafana.org/d/000000002/influxdb-templated?orgId=1). + +In this example, when the user changes the value of the `datacenter` variable, it changes the dropdown options returned by the `host` variable. The `host` variable uses the **Multi-value** option and **Include all option**, allowing users to select some or all options presented at any time. The `datacenter` does not use either option, so you can only select one data center at a time. + +#### datacenter variable + +The query for this variable basically says, "Give me all the data centers that exist." + +``` +SHOW TAG VALUES WITH KEY = "datacenter" +``` + +The values returned are `America`, `Africa`, `Asia`, and `Europe`. + +#### host variable + +The query for this variable basically says, "Give me all hosts for the currently chosen data center." + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^$datacenter$/ +``` + +If the user selects `America`, then the query changes to: + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^America/ +``` + +The query returns all servers associated with `America`, including `server1`, `server2`, and so on. + +If the user selects `Europe`, then the query changes to: + +``` +SHOW TAG VALUES WITH KEY = "hostname" WHERE "datacenter" =~ /^Europe/ +``` + +The query returns all servers associated with `Europe`, including `server3`, `server4`, and so on. + +#### More variables + +> **Note:** This example is theoretical. The InfluxDB server used in the example does not contain CPU metrics. + +The dashboard stops at two levels, but you could keep going. For example, if you wanted to get CPU metrics for selected hosts, you could copy the `host` variable and extend the query so that it reads: + +``` +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^$datacenter$/ AND "host" =~ /^$host$/ +``` + +This query basically says, "Show me the CPU metrics for the selected host." + +Depending on what variable options the user selects, you could get queries like: + +```bash +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^America/ AND "host" =~ /^server2/ +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^Africa/ AND "host" =~ /^server/7/ +SHOW TAG VALUES WITH KEY = "cpu" WHERE "datacenter" =~ /^Europe/ AND "host" =~ /^server3+server4/ +``` + +## Best practices and tips + +The following practices will make your dashboards and variables easier to use. + +### Creating new linked variables + +- Chaining variables create parent/child dependencies. You can envision them as a ladder or a tree. +- The easiest way to create a new chained variable is to copy the variable that you want to base the new one on. In the variable list, click the **Duplicate variable** icon to the right of the variable entry to create a copy. You can then add on to the query for the parent variable. +- New variables created this way appear at the bottom of the list. You might need to drag it to a different position in the list to get it into a logical order. + +### Variable order + +You can change the orders of variables in the dashboard variable list by clicking the up and down arrows on the right side of each entry. Grafana lists variable dropdowns left to right according to this list, with the variable at the top on the far left. + +- List variables that do not have dependencies at the top, before their child variables. +- Each variable should follow the one it is dependent on. +- Remember there is no indication in the UI of which variables have dependency relationships. List the variables in a logical order to make it easy on other users (and yourself). + +### Complexity consideration + +The more layers of dependency you have in variables, the longer it will take to update dashboards after you change variables. + +For example, if you have a series of four linked variables (country, region, server, metric) and you change a root variable value (country), then Grafana must run queries for all the dependent variables before it updates the visualizations in the dashboard. diff --git a/docs/sources/variables/variable-types/global-variables.md b/docs/sources/variables/variable-types/global-variables.md new file mode 100644 index 0000000..c81e27d --- /dev/null +++ b/docs/sources/variables/variable-types/global-variables.md @@ -0,0 +1,84 @@ ++++ +title = "Global variables" +keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "global", "standard"] +aliases = ["/docs/grafana/latest/variables/global-variables.md"] +weight = 900 ++++ + +# Global variables + +Grafana has global built-in variables that can be used in expressions in the query editor. This topic lists them in alphabetical order and defines them. These variables are useful in queries, dashboard links, panel links, and data links. + +## $__dashboard + +> Only available in Grafana v6.7+. In Grafana 7.1, the variable changed from showing the UID of the current dashboard to the name of the current dashboard. + +This variable is the name of the current dashboard. + +## $__from and $__to + +Grafana has two built in time range variables: `$__from` and `$__to`. They are currently always interpolated as epoch milliseconds by default but you can control date formatting. + +> This special formatting syntax is only available in Grafan a 7.1.2+ + +| Syntax | Example result | Description | +| ------------------------ | ------------------------ | ----------- | +| `${__from}` | 1594671549254 | Unix millisecond epoch | +| `${__from:date}` | 2020-07-13T20:19:09.254Z | No args, defaults to ISO 8601/RFC 3339 | +| `${__from:date:iso}` | 2020-07-13T20:19:09.254Z | ISO 8601/RFC 3339 | +| `${__from:date:seconds}` | 1594671549 | Unix seconds epoch | +| `${__from:date:YYYY-MM}` | 2020-07 | Any custom [date format](https://momentjs.com/docs/#/displaying/) that does not include the `:` character | + +The above syntax works with `${__to}` as well. + +You can use this variable in URLs as well. For example, send a user to a dashboard that shows a time range from six hours ago until now: https://play.grafana.org/d/000000012/grafana-play-home?viewPanel=2&orgId=1?from=now-6h&to=now + +## $__interval + +You can use the `$__interval` variable as a parameter to group by time (for InfluxDB, MySQL, Postgres, MSSQL), Date histogram interval (for Elasticsearch), or as a _summarize_ function parameter (for Graphite). + +Grafana automatically calculates an interval that can be used to group by time in queries. When there are more data points than can be shown on a graph then queries can be made more efficient by grouping by a larger interval. It is more efficient to group by 1 day than by 10s when looking at 3 months of data and the graph will look the same and the query will be faster. The `$__interval` is calculated using the time range and the width of the graph (the number of pixels). + +Approximate Calculation: `(from - to) / resolution` + +For example, when the time range is 1 hour and the graph is full screen, then the interval might be calculated to `2m` - points are grouped in 2 minute intervals. If the time range is 6 months and the graph is full screen, then the interval might be `1d` (1 day) - points are grouped by day. + +In the InfluxDB data source, the legacy variable `$interval` is the same variable. `$__interval` should be used instead. + +The InfluxDB and Elasticsearch data sources have `Group by time interval` fields that are used to hard code the interval or to set the minimum limit for the `$__interval` variable (by using the `>` syntax -> `>10m`). + +## $__interval_ms + +This variable is the `$__interval` variable in milliseconds, not a time interval formatted string. For example, if the `$__interval` is `20m` then the `$__interval_ms` is `1200000`. + +## $__name + +This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias. + +## $__org + +This variable is the ID of the current organization. +`${__org.name}` is the name of the current organization. + +## $__user + +> Only available in Grafana v7.1+ + +`${__user.id}` is the ID of the current user. +`${__user.login}` is the login handle of the current user. +`${__user.email}` is the email for the current user. + +## $__range + +Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond and a second representation called `$__range_ms` and `$__range_s`. + +## $timeFilter or $__timeFilter + +The `$timeFilter` variable returns the currently selected time range as an expression. For example, the time range interval `Last 7 days` expression is `time > now() - 7d`. + +This is used in several places, including: + +- The WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor mode. You can add it manually in Text Editor mode: `WHERE $timeFilter`. +- Log Analytics queries in the Azure Monitor data source. +- SQL queries in MySQL, Postgres, and MSSQL. +- The `$__timeFilter` variable is used in the MySQL data source. diff --git a/docs/sources/variables/variable-value-tags.md b/docs/sources/variables/variable-value-tags.md new file mode 100644 index 0000000..8030cc3 --- /dev/null +++ b/docs/sources/variables/variable-value-tags.md @@ -0,0 +1,34 @@ ++++ +title = "Variable value group tags" +weight = 500 ++++ + +# Configure variable value group tags + +> **Note:** This is an experimental feature that will be deprecated in Grafana v8. + +Value groups/tags are a feature you can use to organize variable options. If you have many options in the dropdown for a multi-value variable, then you can use this feature to group the values into selectable tags. + +{{< docs-imagebox img="/img/docs/v50/variable_dropdown_tags.png" max-width="300px" >}} + +This feature is off by default. Click **Enabled** to turn the feature on. + +To see an example, check out [Templating value groups](https://play.grafana.org/d/000000024/templating-value-groups?orgId=1). + +## Tags query + +Enter a data source query that should return a list of tags. The tags query returns a list of tags that each represents a group, and the tag values query returns a list of group members. + +For example, the tags query could be a list of regions (Europe, Asia, Americas), and then if the user selects the Europe tag, then the tag values query would return a list of countries -- Sweden, Germany, France, and so on. + +If you have a variable with a lot of values (say all the countries in the world), then this allows you to easily select a group of them. If the user selects the tag Europe, all the countries in Europe would be selected. + +In this [example dashboard](https://play.grafana.org/d/ZUPhFVGGk/graphite-with-experimental-tags?orgId=1), the server variable has tags enabled. + +## Tag values query + +Enter a data source query that should return a list of values for a specified tag key. Use `$tag` in the query to refer to the currently selected tag. + +The `$tag` variable will have the value of the tag that the user chooses. + +For example, if you have a Graphite query for tags, `regions.*`, that returns a list of regions. The values query could be `regions.$tag.*`, which if the user chooses Europe would be interpolated to `regions.Europe.*`. diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md new file mode 100644 index 0000000..219b0c5 --- /dev/null +++ b/docs/sources/whatsnew/_index.md @@ -0,0 +1,36 @@ ++++ +title = "What's new" +aliases = ["/docs/grafana/latest/guides/"] +weight = 1 ++++ + +# What's new Grafana + +Grafana is changing all the time. For release highlights checkout links below, if you want a complete list of every change, as well +as info on deprecations, breaking changes and plugin development read the [release notes]({{< relref "../release-notes" >}}). + +## Grafana 7 + +- [What's new in 7.5]({{< relref "whats-new-in-v7-5" >}}) +- [What's new in 7.4]({{< relref "whats-new-in-v7-4" >}}) +- [What's new in 7.3]({{< relref "whats-new-in-v7-3" >}}) +- [What's new in 7.2]({{< relref "whats-new-in-v7-2" >}}) +- [What's new in 7.1]({{< relref "whats-new-in-v7-1" >}}) +- [What's new in 7.0]({{< relref "whats-new-in-v7-0" >}}) + +## Grafana 6 +- [What's new in 6.7]({{< relref "whats-new-in-v6-7" >}}) +- [What's new in 6.6]({{< relref "whats-new-in-v6-6" >}}) +- [What's new in 6.5]({{< relref "whats-new-in-v6-5" >}}) +- [What's new in 6.4]({{< relref "whats-new-in-v6-4" >}}) +- [What's new in 6.3]({{< relref "whats-new-in-v6-3" >}}) +- [What's new in 6.2]({{< relref "whats-new-in-v6-2" >}}) +- [What's new in 6.1]({{< relref "whats-new-in-v6-1" >}}) +- [What's new in 6.0]({{< relref "whats-new-in-v6-0" >}}) + +## Grafana 5 +- [What's new in 5.4]({{< relref "whats-new-in-v5-4" >}}) +- [What's new in 5.3]({{< relref "whats-new-in-v5-3" >}}) +- [What's new in 5.2]({{< relref "whats-new-in-v5-2" >}}) +- [What's new in 5.1]({{< relref "whats-new-in-v5-1" >}}) +- [What's new in 5.0]({{< relref "whats-new-in-v5-0" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v2-0.md b/docs/sources/whatsnew/whats-new-in-v2-0.md new file mode 100644 index 0000000..7c037b7 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-0.md @@ -0,0 +1,178 @@ ++++ +title = "What's new in Grafana v2.0" +description = "Feature and improvement highlights for Grafana v2.0" +keywords = ["grafana", "new", "documentation", "2.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2/"] +weight = -1 +[_build] +list = false ++++ + +# What's new in Grafana v2.0 + +Grafana 2.0 represents months of work by the Grafana team and the community. We are pleased to be able to +release the Grafana 2.0 beta. This is a guide that describes some of changes and new features that can +be found in Grafana V2.0. + +If you are interested in how to migrate from Grafana V1.x to V2.0, please read our [Migration Guide](../installation/migrating_to2.md) + +## New backend server + +Grafana now ships with its own required backend server. Also completely open-source, it's written in Go and has a full HTTP API. + +In addition to new features, the backend server makes it much easier to set up and enjoy Grafana. Grafana 2.0 now ships as cross platform binaries with no dependencies. Authentication is built in, and Grafana is now capable of proxying connections to Data Sources. There are no longer any CORS (Cross Origin Resource Sharing) issues requiring messy workarounds. Elasticsearch is no longer required just to store dashboards. + +## User and Organization permissions + +All Dashboards and Data Sources are linked to an Organization (not to a User). Users are linked to +Organizations via a role. That role can be: + +- `Viewer`: Can only view dashboards, not save / create them. +- `Editor`: Can view, update and create dashboards. +- `Admin`: Everything an Editor can plus edit and add data sources and organization users. + +> **Note:** A `Viewer` can still view all metrics exposed through a data source, not only +> the metrics used in already existing dashboards. That is because there are not +> per series permissions in Graphite, InfluxDB or OpenTSDB. + +There are currently no permissions on individual dashboards. + +Read more about Grafana's new user model on the [Admin section](../reference/admin/) + +## Dashboard Snapshot sharing + +A Dashboard Snapshot is an easy way to create and share a URL for a stripped down, point-in-time version of any Dashboard. +You can give this URL to anyone or everyone, and they can view the Snapshot even if they're not a User of your Grafana instance. + +You can set an expiration time for any Snapshots you create. When you create a Snapshot, we strip sensitive data, like +panel metric queries, annotation and template queries and panel links. The data points displayed on +screen for that specific time period in your Dashboard is saved in the JSON of the Snapshot itself. + +Sharing a Snapshot is similar to sharing a link to a screenshot of your dashboard, only way better (they'll look great at any screen resolution, you can hover over series, +even zoom in). Also they are fast to load as they aren't actually connected to any live Data Sources in any way. + +They're a great way to communicate about a particular incident with specific people who aren't users of your Grafana instance. You can also use them to show off your dashboards over the Internet. + +![](/img/docs/v2/dashboard_snapshot_dialog.png) + +### Publish snapshots + +You can publish snapshots locally or to [snapshot.raintank.io](http://snapshot.raintank.io). snapshot.raintank is a free service provided by [raintank](http://raintank.io) for hosting external Grafana snapshots. + +Either way, anyone with the link (and access to your Grafana instance for local snapshots) can view it. + +## Panel time overrides and timeshift + +In Grafana v2.x you can now override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. You can also add a time shift to individual panels. This allows you to show metrics from different time periods or days at the same time. + +![](/img/docs/v2/panel_time_override.jpg) + +You control these overrides in panel editor mode and the new tab `Time Range`. + +![](/img/docs/v2/time_range_tab.jpg) + +When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override however is always active, even when the dashboard time is absolute. + +The `Hide time override info` option allows you to hide the override info text that is by default shown in the +upper right of a panel when overridden time range options. + +Currently you can only override the dashboard time with relative time ranges, not absolute time ranges. + +## Panel iframe embedding + +You can embed a single panel on another web page or your own application using the panel share dialog. + +Below you should see an iframe with a graph panel (taken from a Dashboard snapshot at [snapshot.raintank.io](http://snapshot.raintank.io). + +Try hovering or zooming on the panel below! + + + +This feature makes it easy to include interactive visualizations from your Grafana instance anywhere you want. + +## New dashboard top header + +The top header has gotten a major streamlining in Grafana V2.0. + + + +1. `Side menubar toggle` Toggle the side menubar on or off. This allows you to focus on the data presented on the Dashboard. The side menubar provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. +1. `Dashboard dropdown` The main dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage the Playlist. +1. `Star Dashboard`: Star (or un-star) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. +1. `Share Dashboard`: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. +1. `Save dashboard`: Save the current Dashboard with the current name. +1. `Settings`: Manage Dashboard settings and features such as Templating, Annotations and the name. + +> **Note:** In Grafana v2.0 when you change the title of a dashboard and then save it, it will no +> longer create a new Dashboard. It will just change the name for the current Dashboard. +> To change name and create a new Dashboard use the `Save As...` menu option + +### New Side menubar + +The new side menubar provides access to features such as User Preferences, Organizations, and Data Sources. + +If you have multiple Organizations, you can easily switch between them here. + +The side menubar will become more useful as we build out additional functionality in Grafana 2.x + +You can easily collapse or re-open the side menubar at any time by clicking the Grafana icon in the top left. We never want to get in the way of the data. + +## New search view and starring dashboards + +![](/img/docs/v2/dashboard_search.jpg) + +The dashboard search view has gotten a big overhaul. You can now see and filter by which dashboard you have personally starred. + +## Logarithmic scale + +The Graph panel now supports 3 logarithmic scales, `log base 10`, `log base 32`, `log base 1024`. Logarithmic y-axis scales are very useful when rendering many series of different order of magnitude on the same scale (eg. +latency, network traffic, and storage) + +![](/img/docs/v2/graph_logbase10_ms.png) + +## Dashlist panel + +![](/img/docs/v2/dashlist_starred.png) + +The dashlist is a new panel in Grafana v2.0. It allows you to show your personal starred dashboards, as well as do custom searches based on search strings or tags. + +dashlist is used on the new Grafana Home screen. It is included as a reference Panel and is useful to provide basic linking between Dashboards. + +## Data Source proxy and admin views + +Data sources in Grafana v2.0 are no longer defined in a config file. Instead, they are added through the UI or the HTTP API. + +The backend can now proxy data from Data Sources, which means that it is a lot easier to get started using Grafana with Graphite or OpenTSDB without having to spend time with CORS (Cross origin resource sharing) work-arounds. + +In addition, connections to Data Sources can be better controlled and secured, and authentication information no longer needs to be exposed to the browser. + +## Dashboard "now delay" + +A commonly reported problem has been graphs dipping to zero at the end, because metric data for the last interval has yet to be written to the Data Source. These graphs then "self correct" once the data comes in, but can look deceiving or alarming at times. + +You can avoid this problem by adding a `now delay` in `Dashboard Settings` > `Time Picker` tab. This new feature will cause Grafana to ignore the most recent data up to the set delay. +![](/img/docs/v2/timepicker_now_delay.jpg) + +The delay that may be necessary depends on how much latency you have in your collection pipeline. + +## Dashboard overwrite protection + +Grafana v2.0 protects Users from accidentally overwriting each others Dashboard changes. Similar protections are in place if you try to create a new Dashboard with the same name as an existing one. + +![](/img/docs/v2/overwrite_protection.jpg) + +These protections are only the first step; we will be building out additional capabilities around dashboard versioning and management in future versions of Grafana. + +## User preferences + +If you open side menu (by clicking on the Grafana icon in the top header) you can access your Profile Page. + +Here you can update your user details, UI Theme, and change your password. + +## Server-side Panel rendering + +Grafana now supports server-side PNG rendering. From the Panel share dialog you now have access to a link that will render a particular Panel to a PNG image. + +> **Note:** This requires that your Data Source is accessible from your Grafana instance. + +![](/img/docs/v2/share_dialog_image_highlight.jpg) diff --git a/docs/sources/whatsnew/whats-new-in-v2-1.md b/docs/sources/whatsnew/whats-new-in-v2-1.md new file mode 100644 index 0000000..b4699b5 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-1.md @@ -0,0 +1,133 @@ ++++ +title = "What's new in Grafana v2.1" +description = "Feature and improvement highlights for Grafana v2.1" +keywords = ["grafana", "new", "documentation", "2.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-1/"] +weight = -2 +[_build] +list = false ++++ + +# What's new in Grafana v2.1 +Grafana 2.1 brings improvements in three core areas: dashboarding, authentication, and data sources. +As with every Grafana release, there is a whole slew of new features, enhancements, and bug fixes. + +## More Dynamic Dashboards +The Templating system is one of the most powerful and well-used features of Grafana. +The 2.1 release brings numerous improvements that make dashboards more dynamic than ever before. + +### Multi-Value Template Variables +A template variable with Multi-Value enabled allows for the selection of multiple values at the same time. +These variables can then be used in any Panel to make them more dynamic, and to give you the perfect view of your data. +Multi-Value variables are also enabling the new `row repeat` and `panel repeat` feature described below. + +![Multi-Value Select](/img/docs/v2/multi-select.gif "Multi-Value Select") +

+ +### Repeating Rows and Panels +It’s now possible to create a dashboard that automatically adds (or removes) both rows and panels based +on selected variable values. Any row or any panel can be configured to repeat (duplicate itself) based +on a multi-value template variable.

+ +![Repeating Rows and Panels](/img/docs/v2/panel-row-repeat.gif "Repeating Rows and Panels") +

+ +### Dashboard Links and Navigation +To support better navigation between dashboards, it's now possible to create custom and dynamic links from individual +panels to appropriate Dashboards. You also have the ability to create flexible top-level links on any +given dashboard thanks to the new dashboard navigation bar feature. + +![Dashboard Links](/img/docs/v2/dash_links.png "Dashboard Links") + +Dashboard links can be added under dashboard settings. Either defined as static URLs with a custom icon or as dynamic +dashboard links or dropdowns based on custom dashboard search query. These links appear in the same +row under the top menu where template variables appear. + +- - - + +### Better local Dashboard support +Grafana can now index Dashboards saved locally as JSON from a given directory. These file based dashboards +will appear in the regular dashboard search along regular DB dashboards. + +> **Note:** Saving local dashboards back the folder is not supported; this feature is meant for statically generated JSON dashboards. + +- - - + +## New Authentication Options +New authentication methods add numerous options to manage users, roles and organizations. + +### LDAP support +This highly requested feature now allows your Grafana users to login with their LDAP credentials. +You can also specify mappings between LDAP group memberships and Grafana Organization user roles. + +### Basic Auth Support +You can now authenticate against the Grafana API utilizing a simple username and password with basic HTTP authentication. + +> **Note:** This can be useful for provisioning and configuring management systems that need +> to utilize the API without having to create an API key. + + +### Auth Proxy Support +You can now authenticate utilizing a header (eg. X-Authenticated-User, or X-WEBAUTH-USER) + +> **Note:** this can be useful in situations with reverse proxies. + + +### New “Read-only Editor” User Role +There is a new User role available in this version of Grafana: “Read-only Editor”. This role behaves just +like the Viewer role does in Grafana 2.0. That is you can edit graphs and queries but not save dashboards. +The Viewer role has been modified in Grafana 2.1 so that users assigned this role can no longer edit panels. + +- - - + +## Data source Improvements + +### InfluxDB 0.9 Support +Grafana 2.1 now comes with full support for InfluxDB 0.9. There is a new query editor designed from scratch +for the new features InfluxDB 0.9 enables. + +![InfluxDB Editor](/img/docs/v2/influx_09_editor_anim.gif "InfluxDB Editor") + +
+ +### OpenTSDB Improvements +Grafana OpenTSDB data source now supports template variable values queries. This means you can create +template variables that fetches the values from OpenTSDB (for example metric names, tag names, or tag values). +The query editor is also enhanced to limiting tags by metric. + +> **Note:** OpenTSDB config option tsd.core.meta.enable_realtime_ts must enabled for OpenTSDB lookup API) + +### New Data Source: KairosDB +The Cassandra backed time series database KairosDB is now supported in Grafana out of the box. Thank you to +masaori335 for his hard work in getting it to this point. + +- - - + +## Panel Improvements + +Grafana 2.1 gives you even more flexibility customizing how individual panels render. +Overriding the colors of specific series using regular expressions, changing how series stack, +and allowing string values will help you better understand your data at a glance. + +### Graph Panel +Define series color using regex rule. This is useful when you have templated graphs with series names +that change depending selected template variables. Using a regex style override rule you could +for example make all series that contain the word **CPU** `red` and assigned to the second y axis. + +![Define series color using regex rule](/img/docs/v2/regex_color_override.png "Define series color using regex rule") + +New series style override, negative-y transform and stack groups. Negative y transform is +very useful if you want to plot a series on the negative y scale without affecting the legend values like min or max or +the values shown in the hover tooltip. + +![Negative-y Transform](/img/docs/v2/negative-y.png "Negative-y Transform") + +![Negative-y Transform](/img/docs/v2/negative-y-form.png "Negative-y Transform") + +### Singlestat Panel +Now support string values. Useful for time series database like InfluxDB that supports +string values. + +### Changelog +For a detailed list and link to github issues for everything included in the 2.1 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v2-5.md b/docs/sources/whatsnew/whats-new-in-v2-5.md new file mode 100644 index 0000000..70d1fc6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-5.md @@ -0,0 +1,108 @@ ++++ +title = "What's new in Grafana v2.5" +description = "Feature and improvement highlights for Grafana v2.5" +keywords = ["grafana", "new", "documentation", "2.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-5/"] +weight = -3 +[_build] +list = false ++++ + +# What's new in Grafana v2.5 + +## Release highlights +This is an exciting release, and we want to share some of the highlights. The release includes many +fixes and enhancements to all areas of Grafana, like new Data Sources, a new and improved timepicker, user invites, panel +resize handles and improved InfluxDB and OpenTSDB support. + +### New time range controls +New Time picker + +A new timepicker with room for more quick ranges as well as new types of relative ranges, like `Today`, +`The day so far` and `This day last week`. Also an improved time and calendar picker that now works +correctly in UTC mode. + +### Elasticsearch + +Elasticsearch example +
+ +This release brings a fully featured query editor for Elasticsearch. You will now be able to visualize +logs or any kind of data stored in Elasticsearch. The query editor allows you to build both simple +and complex queries for logs or metrics. + +- Compute metrics from your documents, supported Elasticsearch aggregations: + - Count, Avg, Min, Max, Sum + - Percentiles, Std Dev, etc. +- Group by multiple terms or filters + - Specify group by options like Top 5 based on Avg @value +- Auto completion for field names +- Query only relevant indices based on time pattern +- Alias patterns for short readable series names + +Try the new Elasticsearch query editor on the [play.grafana.org](https://play.grafana.org/dashboard/db/elasticsearch-metrics) site. + +### CloudWatch + +Cloudwatch editor + +Grafana 2.5 ships with a new CloudWatch data source that will allow you to query and visualize CloudWatch +metrics directly from Grafana. + +- Rich editor with auto completion for metric names, namespaces and dimensions +- Templating queries for generic dashboards +- Alias patterns for short readable series names + +### Prometheus + +Prometheus editor + +Grafana 2.5 ships with a new Prometheus data source that will allow you to query and visualize data +stored in Prometheus. + + +### Mix different data sources +Mix data sources in the same dashboard or in the same graph! + +In previous releases you have been able to mix different data sources on the same dashboard. In v2.5 you +will be able to mix then on the same graph! You can enable this by selecting the built in `-- Mixed --` data source. +When selected this will allow you to specify data source on a per query basis. This will, for example, allow you +to plot metrics from different Graphite servers on the same Graph or plot data from Elasticsearch alongside +data from Prometheus. Mixing different data sources on the same graph works for any data source, even custom ones. + +### Panel Resize handles + + +This release adds resize handles to the bottom right corners of panels making it easy to resize both width and height. + +### User invites + + +This version also brings some new features for user management. + +- Organization admins can now invite new users (via email or manually via invite link) +- Users can signup using invite link and get automatically added to invited organization +- User signup workflow can (if enabled) contain an email verification step. +- Check out [#2353](https://github.com/grafana/grafana/issues/2353) for more info. + +### Miscellaneous improvements + +- InfluxDB query editor now supports math and AS expressions +- InfluxDB query editor now supports custom group by interval +- Panel drilldown link is easier to reach +- LDAP improvements (can now search for group membership if your LDAP server does not support memberOf attribute) +- More units for graph and singlestat panel (Length, Volume, Temperature, Pressure, Currency) +- Admin page for all organizations (remove / edit) + +### Breaking changes +There have been some changes to the data source plugin API. If you are using a custom plugin check that there is an update for it before you upgrade. Also +the new time picker does not currently support custom quick ranges like the last one did. This will likely be added in a +future release. + +### Changelog +For a detailed list and link to github issues for everything included in the 2.5 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. + +- - - + +### Download Grafana 2.5 now diff --git a/docs/sources/whatsnew/whats-new-in-v2-6.md b/docs/sources/whatsnew/whats-new-in-v2-6.md new file mode 100644 index 0000000..b9b27e8 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v2-6.md @@ -0,0 +1,127 @@ ++++ +title = "What's new in Grafana v2.6" +description = "Feature and improvement highlights for Grafana v2.6" +keywords = ["grafana", "new", "documentation", "2.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v2-6/"] +weight = -4 +[_build] +list = false ++++ + +# What's new in Grafana v2.6 + +## Release highlights +The release includes a new Table panel, a new InfluxDB query editor, support for Elasticsearch Pipeline Metrics and +support for multiple Cloudwatch credentials. + +## Table Panel + + +The new table panel is very flexible, supporting both multiple modes for time series as well as for +table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. + +### Time series to rows + +In the most simple mode you can turn time series to rows. This means you get a `Time`, `Metric` and a `Value` column. +Where `Metric` is the name of the time series. + + + +### Table Transform +Above you see the options tab for the **Table Panel**. The most important option is the `To Table Transform`. +This option controls how the result of the metric/data query is turned into a table. + +### Column Styles +The column styles allow you control how dates and numbers are formatted. + +### Time series to columns +This transform allows you to take multiple time series and group them by time. Which will result in a `Time` column +and a column for each time series. + + + +In the screenshot above you can see how the same time series query as in the previous example can be transformed into +a different table by changing the `To Table Transform` to `Time series to columns`. + +### Time series to aggregations +This transform works very similar to the legend values in the Graph panel. Each series gets its own row. In the Options +tab you can select which aggregations you want using the plus button the Columns section. + + + +You have to think about how accurate the aggregations will be. It depends on what aggregation is used in the time series query, +how many data points are fetched, etc. The time series aggregations are calculated by Grafana after aggregation is performed +by the time series database. + +### Raw logs queries + +If you want to show documents from Elasticsearch pick `Raw Document` as the first metric. + + + +This in combination with the `JSON Data` table transform will allow you to pick which fields in the document +you want to show in the table. + + + +### Elasticsearch aggregations + +You can also make Elasticsearch aggregation queries without a `Date Histogram`. This allows you to +use Elasticsearch metric aggregations to get accurate aggregations for the selected time range. + + + +### Annotations + +The table can also show any annotations you have enabled in the dashboard. + + + +## The New InfluxDB Editor +The new InfluxDB editor is a lot more flexible and powerful. It supports nested functions, like `derivative`. +It also uses the same technique as the Graphite query editor in that it presents nested functions as chain of function +transformations. It tries to simplify and unify the complicated nature of InfluxDB's query language. + + + +In the `SELECT` row you can specify what fields and functions you want to use. If you have a +group by time you need an aggregation function. Some functions like derivative require an aggregation function. + +The editor tries simplify and unify this part of the query. For example: + +![](/img/docs/influxdb/select_editor.png) + +The above will generate the following InfluxDB `SELECT` clause: + +```sql +SELECT derivative(mean("value"), 10s) /10 AS "REQ/s" FROM .... +``` + +### Select multiple fields +Use the plus button and select Field > field to add another SELECT clause. You can also +specify an asterix `*` to select all fields. + +### Group By +To group by a tag click the plus icon at the end of the GROUP BY row. Pick a tag from the dropdown that appears. +You can remove the group by by clicking on the `tag` and then click on the x icon. + +The new editor also allows you to remove group by time and select `raw` table data. Which is very useful +in combination with the new Table panel to show raw log data stored in InfluxDB. + + + +## Pipeline metrics + +If you have Elasticsearch 2.x and Grafana 2.6 or above then you can use pipeline metric aggregations like +**Moving Average** and **Derivative**. Elasticsearch pipeline metrics require another metric to be based on. Use the eye icon next to the metric +to hide metrics from appearing in the graph. + +![](/img/docs/elasticsearch/pipeline_metrics_editor.png) + +## Changelog +For a detailed list and link to github issues for everything included in the 2.6 release please +view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. + +- - - + +Download Grafana 2.6 now diff --git a/docs/sources/whatsnew/whats-new-in-v3-0.md b/docs/sources/whatsnew/whats-new-in-v3-0.md new file mode 100644 index 0000000..cf6a518 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v3-0.md @@ -0,0 +1,225 @@ ++++ +title = "What's new in Grafana v3.0" +description = "Feature and improvement highlights for Grafana v3.0" +keywords = ["grafana", "new", "documentation", "3.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v3/"] +weight = -5 +[_build] +list = false ++++ + +# What's new in Grafana v3.0 + +## Commercial Support + +Commercial Support subscriptions for Grafana are now [generally available](https://grafana.com/support/plans/). + +Raintank is committed to a 100% open-source strategy for Grafana. We +do not want to go down the “open core” route. If your organization +finds Grafana valuable, please consider purchasing a subscription. Get +direct support, bug fixes, and training from the core Grafana team. + +## Plugins + +With the popularity of Grafana continuing to accelerate, it has been +challenging to keep up with all the requests for new features, new +panels, new data sources, and new functionality. Saying “no” so often +has been frustrating, especially for an open source project with such +a vibrant community. + +The team felt that it was time to dramatically improve extensibility +through plugin support. Grafana 3.0 comes with a completely revamped +plugin SDK / API. + +We’ve refactored our **Data Source** plugin architecture and added +two new plugin types: + +- **Panel** plugins let you add new panel types for your Dashboards. +- **App** plugins bundle **Panels** plugins, **Data Sources** plugins, +Dashboards, and Grafana **Pages**. Apps are a great way to provide an +entire experience right within Grafana. + +## Grafana.com + + + +[Grafana.com](https://grafana.com) offers a central repository where the community can come together to discover, create and +share plugins (data sources, panels, apps) and dashboards. + +We are also working on a hosted Graphite-compatible data source that will be optimized for use with Grafana. +It’ll be easy to combine your existing data source(s) with this OpenSaaS option. Finally, Grafana.com can +also be a hub to manage all your Grafana instances. You’ll be able to monitor their health and availability, +perform dashboard backups, and more. + +We are also working on a hosted Graphite-compatible Data Source that +will be optimized for use with Grafana. It’ll be easy to combine your +existing Data Source(s) with this OpenSaaS option. + +Finally, Grafana.com will also be a hub to manage all your Grafana +instances. You’ll be able to monitor their health and availability, +perform Dashboard backups, and more. + +Grafana.net will officially launch along with the stable version of +Grafana 3.0, but go to and check out the preview +and sign up for an account in the meantime. + +## grafana-cli + +Grafana 3.0 comes with a new command line tool called grafana-cli. You +can easily install plugins from Grafana.net with it. For +example: + +``` +grafana-cli install grafana-pie-chart-panel +``` + +## Personalization and Preferences + +The home dashboard, timezone and theme can now be customized on Organization +and user Profile level. Grafana can also track recently viewed dashboards, which +can then be displayed in the dashboard list panel. + +## Improved Playlists + +You can now save Playlists, and start them by using a Playlist URL. If +you update a running Playlist, it will update after its next cycle. + +This is powerful as it allows you to remote control Grafana. If you +have a big TV display showing Grafana in your company lobby, create a +playlist named Lobby, and start it on the computer connected to the +Lobby TV. + +You can now change the Lobby playlist and have the dashboards shown in +the Lobby update accordingly, automatically. + +The playlist does not even have to contain multiple Dashboards; you +can use this feature to reload the whole Dashboard (and Grafana) +periodically and remotely. + +You can also make Playlists dynamic by using Dashboard **tags** to +define the Playlist. + + + +## Improved UI + +We’ve always tried to focus on a good looking, usable, and responsive +UI. We’ve continued to pay a lot of attention to these areas in this +release. + +Grafana 3.0 has a dramatically updated UI that not only looks better +but also has a number of usability improvements. The side menu now +works as a dropdown that you can pin to the side. The Organization / +Profile / Sign out side menu links have been combined into an on hover +slide out menu. + +In addition, all the forms and the layouts of all pages have been +updated to look and flow better, and be much more consistent. There +are literally hundreds of UI improvements and refinements. + +Here’s the new side menu in action: + + + +And here's the new look for Dashboard settings: + + + +Check out the Play +Site to get a feel for some of the UI changes. + +## Improved Annotations + +It is now possible to define a link in each annotation. You can hover +over the link and click the annotation text. This feature is very +useful for linking to particular commits or tickets where more +detailed information can be presented to the user. + + + +## Data source variables + +This has been a top requested feature for very long we are excited to finally provide +this feature. You can now add a new `Data source` type variable. That will +automatically be filled with instance names of your data sources. + + + +You can then use this variable as the panel data source: + + + +This will allow you to quickly change data source server and reuse the +same dashboard for different instances of your metrics backend. For example +you might have Graphite running in multiple data centers or environments. + +## Prometheus, InfluxDB, and OpenTSDB improvements + +All three of these popular included Data Sources have seen a variety +of improvements in this release. Here are some highlights: + +### Prometheus + +The Prometheus Data Source now supports annotations. + +### InfluxDB + +You can now select the InfluxDB policy from the query editor. + + +Grafana 3.0 also comes with support for InfluxDB 0.11 and InfluxDB 0.12. + +### OpenTSDB + +OpenTSDB 2.2 is better supported and now supports millisecond precision. + +## Breaking changes + +Dashboards from v2.6 are compatible; no manual updates should be necessary. There could +be some edge case scenarios where dashboards using templating could stop working. +If that is the case just enter the edit view for the template variable and hit Update button. +This is due to a simplification of the variable format system where template variables are +now stored without any formatting (glob/regex/etc), this is done on the fly when the +variable is interpolated. + +- Plugin API: The plugin API has changed so if you are using a custom +data source (or panel) they need to be updated as well. + +- InfluxDB 0.8: This data source is no longer included in releases, +you can still install manually from [Grafana.com](https://grafana.com) + +- KairosDB: This data source has also no longer shipped with Grafana, +you can install it manually from [Grafana.com](https://grafana.com) + +## Plugin showcase + +Discovering and installing plugins is very quick and easy with Grafana 3.0 and [Grafana.com](https://grafana.com). Here +are a couple that I encourage you try! + +#### [Clock Panel](https://grafana.com/plugins/grafana-clock-panel) +Support's both current time and count down mode. + + +#### [Pie Chart Panel](https://grafana.com/plugins/grafana-piechart-panel) +A simple pie chart panel is now available as an external plugin. + + +#### [WorldPing App](https://grafana.com/plugins/raintank-worldping-app) +This is full blown Grafana App that adds new panels, data sources and pages to give +feature rich global performance monitoring directly from your on-prem Grafana. + + + +#### [Zabbix App](https://grafana.com/plugins/alexanderzobnin-zabbix-app) +This app contains the already very pouplar Zabbix data source plugin, 2 dashboards and a triggers panel. It is +created and maintained by [Alexander Zobnin](https://github.com/alexanderzobnin/grafana-zabbix). + + + +Check out the full list of plugins on [Grafana.com](https://grafana.com/plugins) + +## CHANGELOG + +For a detailed list and link to github issues for everything included +in the 3.0 release please view the +[CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v3-1.md b/docs/sources/whatsnew/whats-new-in-v3-1.md new file mode 100644 index 0000000..32fe80d --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v3-1.md @@ -0,0 +1,60 @@ ++++ +title = "What's new in Grafana v3.1" +description = "Feature and improvement highlights for Grafana v3.1" +keywords = ["grafana", "new", "documentation", "3.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v3-1/"] +weight = -6 +[_build] +list = false ++++ + +# What's new in Grafana v3.1 + +## Dashboard Export and Import + +The export feature is now accessed from the share menu. + + + +Dashboards exported from Grafana 3.1 are now more portable and easier for others to import than before. The export process extracts information data source types used by panels and adds these to a new `inputs` section in the dashboard json. So when you or another person tries to import the dashboard they will be asked to select data source and optional metric prefix options. + + + +The above screenshot shows the new import modal that gives you 3 options for how to import a dashboard. One notable new addition here is the ability to import directly from Dashboards shared on [Grafana.com](https://grafana.com). + +The next step in the import process: + + + +Here you can change the name of the dashboard and also pick what data sources you want the dashboard to use. The above screenshot shows a CollectD dashboard for Graphite that requires a metric prefix be specified. + +## Discover Dashboards + +On [Grafana.com](https://grafana.com) you can now browse and search for dashboards. We have already added a few but more are being uploaded every day. To import a dashboard just copy the dashboard URL and head back to Grafana, then Dashboard Search -> Import -> Paste Grafana.com Dashboard URL. + + + +## Constant template variables + +We added a new template variable named constant that makes it easier to share and export dashboard that have custom prefixes. + +## Dashboard URLs + +Having current time range and template variable value always sync with the URL makes it possible to always copy your current Grafana URL to share with a colleague without having to use the Share modal. + +## Internal metrics + +Do you want metrics about viewing metrics? Of course you do! In this release we added support for sending metrics about Grafana to graphite. You can configure interval and server in the config file. + +## Logging + +Switched logging framework to log15 to enable key value per logging and filtering based on different log levels. It's now possible to configure different log levels for different modules. + +### Breaking changes +- **Logging** format have been changed to improve log filtering. +- **Graphite PNG** Graphite PNG support dropped from Graph panel (use Grafana native PNG instead). +- **Migration** No longer possible to migrate dashboards from 1.x (Stored in ES or Influx 0.8). + +## CHANGELOG + +For a detailed list and link to github issues for everything included in the 3.1 release please view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. diff --git a/docs/sources/whatsnew/whats-new-in-v4-0.md b/docs/sources/whatsnew/whats-new-in-v4-0.md new file mode 100644 index 0000000..e0c23e9 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-0.md @@ -0,0 +1,176 @@ ++++ +title = "What's new in Grafana v4.0" +description = "Feature and improvement highlights for Grafana v4.0" +keywords = ["grafana", "new", "documentation", "4.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4/"] +weight = -7 +[_build] +list = false ++++ + +# What's new in Grafana v4.0 + +As usual this release contains a ton of minor new features, fixes and improved UX. But on top of the usual new goodies +is a core new feature: Alerting! Read on below for a detailed description of what's new in v4.0. + +## Alerting + +{{< imgbox max-width="40%" img="/img/docs/v4/drag_handles_gif.gif" caption="Alerting overview" >}} + +Alerting is a really revolutionary feature for Grafana. It transforms Grafana from a +visualization tool into a truly mission critical monitoring tool. The alert rules are very easy to +configure using your existing graph panels and threshold levels can be set simply by dragging handles to +the right side of the graph. The rules will continually be evaluated by grafana-server and +notifications will be sent out when the rule conditions are met. + +This feature has been worked on for over a year with many iterations and rewrites +just to make sure the foundations are really solid. We are really proud to finally release it! +Since the alerting execution is processed in the backend not all data source plugins are supported. +Right now Graphite, Prometheus, InfluxDB and OpenTSDB are supported. Elasticsearch is being worked +on but will be not ready for v4 release. + +
+ +### Rules + +{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} + +The rule configuration allows you to specify a name, how often the rule should be evaluated and a series +of conditions that all need to be true for the alert to fire. + +Currently the only condition type that exists is a `Query` condition that allows you to +specify a query letter, time range and an aggregation function. The letter refers to +a query you already have added in the **Metrics** tab. The result from the +query and the aggregation function is a single value that is then used in the threshold check. + +We plan to add other condition types in the future, like `Other Alert`, where you can include the state +of another alert in your conditions, and `Time Of Day`. + +### Notifications + +{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} + +Alerting would not be very useful if there was no way to send notifications when rules trigger and change state. You +can set up notifications of different types. We currently have `Slack`, `PagerDuty`, `Email` and `Webhook` with more in the +pipe that will be added during beta period. The notifications can then be added to your alert rules. +If you have configured an external image store in the grafana.ini config file (s3, webdav, and azure_blob options available) +you can get very rich notifications with an image of the graph and the metric +values all included in the notification. + +### Annotations + +Alert state changes are recorded in a new annotation store that is built into Grafana. This store +currently only supports storing annotations in Grafana's own internal database (mysql, postgres or sqlite). +The Grafana annotation storage is currently only used for alert state changes but we hope to add the ability for users +to add graph comments in the form of annotations directly from within Grafana in a future release. + +### Alert List Panel + +{{< imgbox max-width="30%" img="/img/docs/v4/alert_list_panel.png" caption="Alert List Panel" >}} + +This new panel allows you to show alert rules or a history of alert rule state changes. You can filter based on states you are +interested in. This panel is very useful for overview style dashboards. + +
+ +## Ad-hoc filter variable + +{{< imgbox max-width="30%" img="/img/docs/v4/adhoc_filters.gif" caption="Ad-hoc filters variable" >}} + +This is a new and very different type of template variable. It will allow you to create new key/value filters on the fly +with autocomplete for both key and values. The filter condition will be automatically applied to all +queries that use that data source. This feature opens up more exploratory dashboards. In the gif animation to the right +you have a dashboard for Elasticsearch log data. It uses one query variable that allow you to quickly change how the data +is grouped, and an interval variable for controlling the granularity of the time buckets. What was missing +was a way to dynamically apply filters to the log query. With the `Ad-Hoc Filters` variable you can +dynamically add filters to any log property! + +## UX Improvements + +We always try to bring some UX/UI refinements and polish in every release. + +### TV-mode and Kiosk mode + +
+
+

+ Grafana is so often used on wall mounted TVs that we figured a clean TV mode would be + really nice. In TV mode the top navbar, row and panel controls will all fade to transparent. +

+ +

+ This happens automatically after one minute of user inactivity but can also be toggled manually + with the d v sequence shortcut. Any mouse movement or keyboard action will + restore navbar and controls. +

+ +

+ Another feature is the kiosk mode. This can be enabled with d k + shortcut or by adding &kiosk to the URL when you load a dashboard. + In kiosk mode the navbar is completely hidden/removed from view. +

+
+
+ {{< lightboxhelper max-width="100%" img="/img/docs/v4/tvmode.png" caption="TV mode" >}} + +
+
+ +### New row menu and add panel experience + +{{< imgbox max-width="50%" img="/img/docs/v4/add_panel.gif" caption="Add Panel flow" >}} + +We spent a lot of time improving the dashboard building experience to make it both +more efficient and easier for beginners. After many good but not great experiments +with a `build mode` we eventually decided to just improve the green row menu and +continue work on a `build mode` for a future release. + +The new row menu automatically slides out when you mouse over the edge of the row. You no longer need +to hover over the small green icon and then click it to expand the row menu. + +There are some minor improvements to drag and drop behavior. Now when dragging a panel from one row +to another you will insert the panel and Grafana will automatically make room for it. +When you drag a panel within a row you will simply reorder the panels. + +If you look at the animation to the right you can see that you can drag and drop a new panel. This is not +required, you can also just click the panel type and it will be inserted at the end of the row +automatically. Dragging a new panel has an advantage in that you can insert a new panel where ever you want +not just at the end of the row. + +We plan to further improve dashboard building in the future with a more rich grid and layout system. + +### Keyboard shortcuts + +{{< imgbox max-width="40%" img="/img/docs/v4/shortcuts.png" caption="Shortcuts" >}} + +Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel +by hovering over it with your mouse. With a panel focused you can simply hit `e` to toggle panel +edit mode, or `v` to toggle fullscreen mode. `p r` removes the panel. `p s` opens share +modal. + +Some nice navigation shortcuts are: + +- `g h` for go to home dashboard +- `s s` open search with starred pre-selected +- `s t` open search in tags list view + +
+ +## Upgrade and Breaking changes + +There are no breaking changes. Old dashboards and features should work the same. Grafana-server will automatically upgrade its db +schema on restart. It's advisable to do a backup of Grafana's database before updating. + +If you are using plugins make sure to update your plugins as some might not work perfectly v4. + +You can update plugins using grafana-cli + + grafana-cli plugins update-all + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v4-1.md b/docs/sources/whatsnew/whats-new-in-v4-1.md new file mode 100644 index 0000000..ab9f5cd --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-1.md @@ -0,0 +1,66 @@ ++++ +title = "What's new in Grafana v4.1" +description = "Feature and improvement highlights for Grafana v4.1" +keywords = ["grafana", "new", "documentation", "4.1.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-1/"] +weight = -8 +[_build] +list = false ++++ + +## What's new in Grafana v4.1 +- **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) +- **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) +- **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source configuration page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) +- **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### Shared tooltip + +{{< imgbox max-width="60%" img="/img/docs/v41/shared_tooltip.gif" caption="Shared tooltip" >}} + +Showing the tooltip on all panels at the same time has been a long standing request in Grafana and we are really happy to finally be able to release it. +You can enable/disable the shared tooltip from the dashboard settings menu or cycle between default, shared tooltip and shared crosshair by pressing Ctrl/Cmd+O. + +
+ +### Help text for panel + +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_for_panel_settings.png" caption="Hovering help text" >}} + +You can set a help text in the general tab on any panel. The help text is using Markdown to enable better formatting and linking to other sites that can provide more information. + +
+ +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_hover.png" caption="Hovering help text" >}} + +Panels with a help text available have a little indicator in the top left corner. You can show the help text by hovering the icon. +
+ +### Easier Cloudwatch configuration + +{{< imgbox max-width="60%" img="/img/docs/v41/cloudwatch_settings.png" caption="Cloudwatch configuration" >}} + +In Grafana 4.1.0 you can configure your Cloudwatch data source with `access key` and `secret key` directly in the data source configuration page. +This enables people to use the Cloudwatch data source without having access to the filesystem where Grafana is running. + +Once the `access key` and `secret key` have been saved the user will no longer be able to view them. +
+ +## Upgrade and Breaking changes + +Elasticsearch 1.x is no longer supported. Please upgrade to Elasticsearch 2.x or 5.x. Otherwise Grafana 4.1.0 contains no breaking changes. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.1 download page](/download/4_1_0/) for download links and instructions. + +## Thanks +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports and feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-2.md b/docs/sources/whatsnew/whats-new-in-v4-2.md new file mode 100644 index 0000000..46d33af --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-2.md @@ -0,0 +1,86 @@ ++++ +title = "What's new in Grafana v4.2" +description = "Feature and improvement highlights for Grafana v4.2" +keywords = ["grafana", "new", "documentation", "4.2.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-2/"] +weight = -9 +[_build] +list = false ++++ + +## What's new in Grafana v4.2 + +Grafana v4.2 Beta is now [available for download](https://grafana.com/grafana/download/4.2.0). +Just like the last release this one contains lots bug fixes and minor improvements. +We are very happy to say that 27 of 40 issues was closed by pull requests from the community. +Big thumbs up! + +## Release Highlights + +- **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +- **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +- **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +- **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any data source (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +- **Alerting**: Adds deduping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +- **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +- **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#6948](https://github.com/grafana/grafana/issues/6948) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### New alert notification channels + +This release adds **five** new alert notifications channels, all of them contributed by the community. + +- Hipchat +- Telegram +- LINE +- Pushover +- Threema + +### Templating + +We added two new global built in variables in grafana. `$__interval` and `$__interval_ms` are now reserved template names in grafana and can be used by any data source. +We might add more global built in variables in the future and if we do we will prefix them with `$__`. So please avoid using that in your template variables. + +### Dedupe alert notifications when running multiple servers + +In this release we will dedupe alert notifications when you are running multiple servers. +This makes it possible to run alerting on multiple servers and only get one notification. + +We currently solve this with sql transactions which puts some limitations for how many servers you can use to execute the same rules. +3-5 servers should not be a problem but as always, it depends on how many alerts you have and how frequently they execute. + +Next up for a better HA situation is to add support for workload balancing between Grafana servers. + +### Alerting more info + +You can now see the reason why an alert triggered in the alert history. Its also easier to detect when an alert is set to `alerting` due to the `no_data` option. + +### Improved support for multi-org setup + +When loading dashboards we now set an query parameter called orgId. So we can detect from which org an user shared a dashboard. +This makes it possible for users to share dashboards between orgs without changing org first. + +We aim to introduce [dashboard groups](https://github.com/grafana/grafana/issues/1611) sometime in the future which will introduce access control and user groups within one org. +Making it possible to have users in multiple groups and have detailed access control. + +## Upgrade and Breaking changes + +If you're using HTTPS in grafana we now force you to use TLS 1.2 and the most secure ciphers. +We think its better to be secure by default rather then making it configurable. +If you want to run HTTPS with lower versions of TLS we suggest you put a reserve proxy in front of grafana. + +If you have template variables name `$__interval` or `$__interval_ms` they will no longer work since these keywords +are reserved as global built in variables. We might add more global built in variables in the future and if we do, we will prefix them with `$__`. So please avoid using that in your template variables. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.2-beta download page](/download/4_2_0/) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports and feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-3.md b/docs/sources/whatsnew/whats-new-in-v4-3.md new file mode 100644 index 0000000..deb62c6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-3.md @@ -0,0 +1,102 @@ ++++ +title = "What's new in Grafana v4.3" +description = "Feature and improvement highlights for Grafana v4.3" +keywords = ["grafana", "new", "documentation", "4.3.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-3/"] +weight = -10 +[_build] +list = false ++++ + +## What's new in Grafana v4.3 + +Grafana v4.3 Beta is now [available for download](https://grafana.com/grafana/download/4.3.0-beta1). + +## Release Highlights + +- New [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/) +- Graph Panel Histogram Mode +- Elasticsearch Histogram Aggregation +- Prometheus Table data format +- New [MySQL Data Source](http://docs.grafana.org/features/datasources/mysql/) (alpha version to get some early feedback) +- 60+ small fixes and improvements, most of them contributed by our fantastic community! + +Check out the [New Features in v4.3 Dashboard](https://play.grafana.org/dashboard/db/new-features-in-v4-3?orgId=1) on the Grafana Play site for a showcase of these new features. + +## Histogram Support + +A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. + +The Graph Panel now supports Histograms. + +![](/img/docs/v43/heatmap_histogram.png) + +## Histogram Aggregation Support for Elasticsearch + +Elasticsearch is the only supported data source that can return pre-bucketed data (data that is already grouped into ranges). With other data sources there is a risk of returning inaccurate data in a histogram due to using already aggregated data rather than raw data. This release adds support for Elasticsearch pre-bucketed data that can be visualized with the new [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/). + +## Heatmap Panel + +The Histogram support in the Graph Panel does not show changes over time - it aggregates all the data together for the chosen time range. To visualize a histogram over time, we have built a new [Heatmap Panel](http://docs.grafana.org/features/panels/heatmap/). + +Every column in a Heatmap is a histogram snapshot. Instead of visualizing higher values with higher bars, a heatmap visualizes higher values with color. The histogram shown above is equivalent to one column in the heatmap shown below. + +![](/img/docs/v43/heatmap_histogram_over_time.png) + +The Heatmap panel also works with Elasticsearch Histogram Aggregations for more accurate server side bucketing. + +![](/assets/img/blog/v4/elastic_heatmap.jpg) + +## MySQL Data Source (alpha) + +This release includes a [new core data source for MySQL](http://docs.grafana.org/features/datasources/mysql/). You can write any possible MySQL query and format it as either Time Series or Table Data allowing it be used with the Graph Panel, Table Panel and SingleStat Panel. + +We are still working on the MySQL data source. As it's missing some important features, like templating and macros and future changes could be breaking, we are +labeling the state of the data source as Alpha. Instead of holding up the release of v4.3 we are including it in its current shape to get some early feedback. So please try it out and let us know what you think on [twitter](https://twitter.com/intent/tweet?text=.%40grafana&source=4_3_beta_blog&related=blog) or on our [community forum](https://community.grafana.com/c/releases). Is this a feature that you would use? How can we make it better? + +**The query editor can show the generated and interpolated SQL that is sent to the MySQL server.** + +![](/img/docs/v43/mysql_table_query.png) + +**The query editor will also show any errors that resulted from running the query (very useful when you have a syntax error!).** + +![](/img/docs/v43/mysql_query_error.png) + +## Health Check Endpoint + +Now you can monitor the monitoring with the Health Check Endpoint! The new `/api/health` endpoint returns HTTP 200 OK if everything is up and HTTP 503 Error if the Grafana database cannot be pinged. + +## Lazy Load Panels + +Grafana now delays loading panels until they become visible (scrolled into view). This means panels out of view are not sending requests thereby reducing the load on your time series database. + +## Prometheus - Table Data (column per label) + +The Prometheus data source now supports the Table Data format by automatically assigning a column to a label. This makes it really easy to browse data in the table panel. + +![](/img/docs/v43/prom_table_cols_as_labels.png) + +## Other Highlights From The Changelog + +Changes: + +- **Table**: Support to change column header text [#3551](https://github.com/grafana/grafana/issues/3551) +- **InfluxDB**: influxdb query builder support for ORDER BY and LIMIT (allows TOPN queries) [#6065](https://github.com/grafana/grafana/issues/6065) Support influxdb's SLIMIT Feature [#7232](https://github.com/grafana/grafana/issues/7232) thx [@thuck](https://github.com/thuck) +- **Graph**: Support auto grid min/max when using log scale [#3090](https://github.com/grafana/grafana/issues/3090), thx [@bigbenhur](https://github.com/bigbenhur) +- **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778) +- **Server**: Support listening on a Unix socket [#4030](https://github.com/grafana/grafana/issues/4030), thx [@mitjaziv](https://github.com/mitjaziv) + +Fixes: + +- **MySQL**: 4-byte UTF8 not supported when using MySQL database (allows Emojis in Dashboard Names) [#7958](https://github.com/grafana/grafana/issues/7958) +- **Dashboard**: Description tooltip is not fully displayed [#7970](https://github.com/grafana/grafana/issues/7970) + +Lots more enhancements and fixes can be found in the [Changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Download + +Head to the [v4.3 download page](https://grafana.com/grafana/download) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-4.md b/docs/sources/whatsnew/whats-new-in-v4-4.md new file mode 100644 index 0000000..6d55ba1 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-4.md @@ -0,0 +1,47 @@ ++++ +title = "What's new in Grafana v4.4" +description = "Feature and improvement highlights for Grafana v4.4" +keywords = ["grafana", "new", "documentation", "4.4.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-4/"] +weight = -11 +[_build] +list = false ++++ + +## What's new in Grafana v4.4 + +Grafana v4.4 is now [available for download](https://grafana.com/grafana/download/4.4.0). + +**Highlights**: + +- Dashboard History - version control for dashboards. + +## New Features + +**Dashboard History**: View dashboard version history, compare any two versions (summary and json diffs), restore to old version. This big feature +was contributed by **Walmart Labs**. Big thanks to them for this massive contribution! +Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638) +Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) + +## Enhancements +- **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk) +- **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller) +- **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo) +- **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617) +- **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang) + +## Minor Enhancements + +- **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia) + +## Bug Fixes + +- **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628) + +## Download + +Head to the [v4.4 download page](https://grafana.com/grafana/download) for download links and instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback! diff --git a/docs/sources/whatsnew/whats-new-in-v4-5.md b/docs/sources/whatsnew/whats-new-in-v4-5.md new file mode 100644 index 0000000..50e9384 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-5.md @@ -0,0 +1,68 @@ ++++ +title = "What's new in Grafana v4.5" +description = "Feature and improvement highlights for Grafana v4.5" +keywords = ["grafana", "new", "documentation", "4.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-5/"] +weight = -12 +[_build] +list = false ++++ + +# What's new in Grafana v4.5 + +## Highlights + +### New prometheus query editor + +The new query editor has full syntax highlighting. As well as auto complete for metrics, functions, and range vectors. There are also integrated function docs right from the query editor! + +{{< docs-imagebox img="/img/docs/v45/prometheus_query_editor_still.png" class="docs-image--block" animated-gif="/img/docs/v45/prometheus_query_editor.gif" >}} + +### Elasticsearch: Add ad-hoc filters from the table panel + +{{< docs-imagebox img="/img/docs/v45/elastic_ad_hoc_filters.png" class="docs-image--block" >}} + +### Table cell links! +Create column styles that turn cells into links that use the value in the cell (or other row values) to generate a URL to another dashboard or system: +![](/img/docs/v45/table_links.jpg) + +### Query Inspector +Query Inspector is a new feature that shows query requests and responses. This can be helpful if a graph is not shown or shows something very different than what you expected. +For more information about query inspector, refer to [using grafanas query inspector to troubleshoot issues](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). +![](/img/docs/v45/query_inspector.png) + +## Changelog + +### New Features + +- **Table panel**: Render cell values as links that can have an URL template that uses variables from current table row. [#3754](https://github.com/grafana/grafana/issues/3754) +- **Elasticsearch**: Add ad hoc filters directly by clicking values in table panel [#8052](https://github.com/grafana/grafana/issues/8052). +- **MySQL**: New rich query editor with syntax highlighting +- **Prometheus**: New rich query editor with syntax highlighting, metric and range auto complete and integrated function docs. [#5117](https://github.com/grafana/grafana/issues/5117) + +### Enhancements + +- **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) +- **Graphite**: Calls to Graphite API /metrics/find now include panel or dashboard time range (from and until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +- **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) +- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). + +### Breaking change + +- **InfluxDB/Elasticsearch**: The panel and data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. + +This option is now renamed (and moved to Options sub section above your queries): +![image|519x120](upload://ySjHOVpavV6yk9LHQxL9nq2HIsT.png) + +Data source selection and options and help are now above your metric queries. +![image|690x179](upload://5kNDxKgMz1BycOKgG3iWYLsEVXv.png) + +### Minor Changes + +- **InfluxDB**: Change time range filter for absolute time ranges to be inclusive instead of exclusive [#8319](https://github.com/grafana/grafana/issues/8319), thx [@Oxydros](https://github.com/Oxydros) +- **InfluxDB**: Added parenthesis around tag filters in queries [#9131](https://github.com/grafana/grafana/pull/9131) + +## Bug Fixes + +- **Modals**: Maintain scroll position after opening/leaving modal [#8800](https://github.com/grafana/grafana/issues/8800) +- **Templating**: You cannot select data source variables as data source for other template variables [#7510](https://github.com/grafana/grafana/issues/7510) diff --git a/docs/sources/whatsnew/whats-new-in-v4-6.md b/docs/sources/whatsnew/whats-new-in-v4-6.md new file mode 100644 index 0000000..4e04c61 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v4-6.md @@ -0,0 +1,72 @@ ++++ +title = "What's new in Grafana v4.6" +description = "Feature and improvement highlights for Grafana v4.6" +keywords = ["grafana", "new", "documentation", "4.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v4-6/"] +weight = -13 +[_build] +list = false ++++ + +# What's new in Grafana v4.6 + +Grafana v4.6 brings many enhancements to Annotations, Cloudwatch and Prometheus. It also adds support for Postgres as metric and table data source! + +### Annotations + +{{< docs-imagebox img="/img/docs/v46/add_annotation_region.png" max-width= "800px" >}} + +You can now add annotation events and regions right from the graph panel! Just hold Ctrl/Cmd+Click or drag region to open the **Add Annotation** view. The +[Annotations]({{< relref "../dashboards/annotations.md" >}}) documentation is updated to include details on this new exciting feature. + +### Cloudwatch + +Cloudwatch now supports alerting. Set up alert rules for any Cloudwatch metric! + +{{< docs-imagebox img="/img/docs/v46/cloudwatch_alerting.png" max-width= "800px" >}} + +### Postgres + +Grafana v4.6 now ships with a built-in data source plugin for Postgres. Have logs or metric data in Postgres? You can now visualize that data and +define alert rules on it like any of our other data sources. + +{{< docs-imagebox img="/img/docs/v46/postgres_table_query.png" max-width= "800px" >}} + +### Prometheus + +New enhancements include support for **instant queries** and improvements to query editor in the form of autocomplete for label names and label values. +This makes exploring and filtering Prometheus data much easier. + +## Changelog + +### New Features + +- **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +- **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +- **Graph**: Add support for local formatting in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +- **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +- **Unit types**: New date and time unit types added, useful in singlestat to show dates and times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +- **CLI**: Make it possible to install plugins from any URL [#5873](https://github.com/grafana/grafana/issues/5873) +- **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +- **Cloudwatch**: Add support for alerting using the cloudwatch data source [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +- **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +- **Timezone**: Time ranges like Today and Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +- **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +- **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +- **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +- **Data sources**: closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) + +### Minor Changes + +- **SMTP**: Make it possible to set specific EHLO for SMTP client. [#9319](https://github.com/grafana/grafana/issues/9319) +- **Dataproxy**: Allow Grafana to renegotiate TLS connection [#9250](https://github.com/grafana/grafana/issues/9250) +- **HTTP**: set net.Dialer.DualStack to true for all HTTP clients [#9367](https://github.com/grafana/grafana/pull/9367) +- **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +- **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +- **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +- **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +- **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +- **Postgres**: modify group by time macro so it can be used in select clause [#9527](https://github.com/grafana/grafana/pull/9527), thanks [@svenklemm](https://github.com/svenklemm) + +### Tech +- **Go**: Grafana is now built using golang 1.9 diff --git a/docs/sources/whatsnew/whats-new-in-v5-0.md b/docs/sources/whatsnew/whats-new-in-v5-0.md new file mode 100644 index 0000000..8765585 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-0.md @@ -0,0 +1,148 @@ ++++ +title = "What's new in Grafana v5.0" +description = "Feature and improvement highlights for Grafana v5.0" +keywords = ["grafana", "new", "documentation", "5.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5/"] +weight = -14 +[_build] +list = false ++++ + +# What's new in Grafana v5.0 + +This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements. + +- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts. +- [New UX]({{< relref "#new-ux-layout-engine" >}}). The UI has big improvements in both look and function. +- [New Light Theme]({{< relref "#new-light-theme" >}}) is now looking really nice. +- [Dashboard Folders]({{< relref "#dashboard-folders" >}}) helps you keep your dashboards organized. +- [Permissions]({{< relref "#dashboard-folders" >}}) on folders and dashboards helps manage larger Grafana installations. +- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system. +- [Data source provisioning]({{< relref "#data-sources" >}}) makes it possible to set up data sources via config files. +- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to set up dashboards via config files. +- [Persistent dashboard URL's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links. +- [Graphite Tags and Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}). + +### Video showing new features + + +
+ +## New Dashboard Layout Engine + +{{< docs-imagebox img="/img/docs/v50/new_grid.png" max-width="1000px" class="docs-image--right">}} + +The new dashboard layout engine allows for much easier movement and sizing of panels, as other panels now move out of the way in +a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens +up many new types of layouts where panels of different heights can be aligned easily. Check out the new grid in the video +above or on the [play site](https://play.grafana.org). All your existing dashboards will automatically migrate to the +new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible +with older versions of Grafana. + +
+ +## New UX + +{{< docs-imagebox img="/img/docs/v50/new_ux_nav.png" max-width="1000px" class="docs-image--right" >}} + +Almost every page has seen significant UX improvements. All pages (except dashboard pages) have a new tab-based layout that improves navigation between pages. The side menu has also changed quite a bit. You can still hide the side menu completely if you click on the Grafana logo. + +
+ +## Dashboard Settings + +{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}} +Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard +settings views have been combined with a side nav which allows you to easily move between different setting categories. + +
+ +## New Light Theme + +{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}} + +This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result. + +
+ +## Dashboard Folders + +{{< docs-imagebox img="/img/docs/v50/new_search.png" max-width="1000px" class="docs-image--right" >}} + +The big new feature that comes with Grafana v5.0 is dashboard folders. Now you can organize your dashboards in folders, +which is very useful if you have a lot of dashboards or multiple teams. + +- New search design adds expandable sections for each folder, starred and recently viewed dashboards. +- New manage dashboard pages enable batch actions and views for folder settings and permissions. +- Set permissions on folders and have dashboards inherit the permissions. + +## Teams + +A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams. +We hope to do more with teams in future releases like integration with LDAP and a team landing page. + +## Permissions + +{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}} + +You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and +replaced with specific teams or users enabling more control over what a user can see and edit. + +Dashboard permissions only limits what dashboards and folders a user can view and edit not which +data sources a user can access nor what queries a user can issue. + +
+ +## Provisioning from configuration + +In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards. +But that required the service to be running before you started creating dashboards and you also needed to +set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active +provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can +be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs +and alerts as well. + +### Data sources + +Data sources can now be set up using config files. These data sources are by default not editable from the Grafana GUI. +It's also possible to update and delete data sources from the config file. More info in the [data source provisioning docs](/administration/provisioning/#datasources). + +### Dashboards + +We also deprecated the `[dashboard.json]` in favor of our new dashboard provisioner that keeps dashboards on disk +in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old +`[dashboard.json]` feature. Instead of storing the dashboard in memory we now insert the dashboard into the database, +which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that +expects the dashboards to exist in the database. More info in the [dashboard provisioning docs]({{< relref "../administration/provisioning.md" >}}) + + +## Graphite Tags and Integrated Function Docs + +{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}} + +The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds +many new functions and support for querying by tags. You can now also view function documentation right in the query editor! + +Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags). + +
+ +## Dashboard model, persistent URLs and API changes + +We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically +generated if not provided when creating a dashboard and will have a length of 9-12 characters. + +The unique identifier allows having persistent URLs for accessing dashboards, sharing them +between instances and when using [dashboard provisioning]((/administration/provisioning/#reusable-dashboard-urls)). This means that dashboard can +be renamed without breaking any links. We're changing the URL format for dashboards +from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based URLs for dashboards +and redirects to the new one for backward compatibility. Please note that the old slug-based URLs +have been deprecated and will be removed in a future release. + +Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough). +This might seem like a small change, but we are incredibly excited about it since it will make it +much easier to manage, collaborate and navigate between dashboards. + +### API changes +New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards. +The corresponding slug-based routes have been deprecated and will be removed in a future release. diff --git a/docs/sources/whatsnew/whats-new-in-v5-1.md b/docs/sources/whatsnew/whats-new-in-v5-1.md new file mode 100644 index 0000000..e4bbbe6 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-1.md @@ -0,0 +1,123 @@ ++++ +title = "What's new in Grafana v5.1" +description = "Feature and improvement highlights for Grafana v5.1" +keywords = ["grafana", "new", "documentation", "5.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-1/"] +weight = -15 +[_build] +list = false ++++ + +# What's new in Grafana v5.1 + +Grafana v5.1 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Improved scrolling experience]({{< relref "#improved-scrolling-experience" >}}) +- [Improved docker image]({{< relref "#improved-docker-image-breaking-change" >}}) with a breaking change! +- [Heatmap support for Prometheus]({{< relref "#prometheus" >}}) +- [Microsoft SQL Server]({{< relref "#microsoft-sql-server" >}}) as metric and table data source! +- [Dashboards and Panels]({{< relref "#dashboards-panels" >}}) Improved adding panels to dashboards and enhancements to Graph and Table panels. +- [New variable interpolation syntax]({{< relref "#new-variable-interpolation-syntax" >}}) +- [Improved workflow for provisioned dashboards]({{< relref "#improved-workflow-for-provisioned-dashboards" >}}) + +## Improved scrolling experience + +In Grafana v5.0 we introduced a new scrollbar component. Unfortunately this introduced a lot of issues and in some scenarios removed +the native scrolling functionality. Grafana v5.1 ships with a native scrollbar for all pages together with a scrollbar component for +the dashboard grid and panels that's not overriding the native scrolling functionality. We hope that these changes and improvements should +make the Grafana user experience much better! + +## Improved Docker image (breaking change) + +Grafana v5.1 brings an improved official docker image which should make it easier to run and use the Grafana docker image and at the same time give more control to the user how to use/run it. + +We've switched the id of the grafana user running Grafana inside a docker container. Unfortunately this means that files created prior to 5.1 won't have the correct permissions for later versions and thereby this introduces a breaking change. +We made this change so that it would be easier for you to control what user Grafana is executed as (see examples below). + +Version | User | User ID +--------|---------|--------- +< 5.1 | grafana | 104 +>= 5.1 | grafana | 472 + +Please read the [updated documentation](/installation/docker/#migrate-to-v51-or-later) which includes migration instructions and more information. + +## Prometheus + +{{< docs-imagebox img="/img/docs/v51/prometheus_heatmap.png" max-width="800px" class="docs-image--right" >}} + +The Prometheus data source now support transforming Prometheus histograms to the heatmap panel. Prometheus histogram is a powerful feature, and we're +really happy to finally allow our users to render those as heatmaps. Please read [Heatmap panel documentation](/features/panels/heatmap/#pre-bucketed-data) +for more information on how to use it. + +Prometheus query editor also got support for autocomplete of template variables. More information in the [Prometheus data source documentation]({{< relref "../datasources/prometheus/" >}}). + +
+ +## Microsoft SQL Server + +{{< docs-imagebox img="/img/docs/v51/mssql_query_editor_showcase.png" max-width= "800px" class="docs-image--right" >}} + +Grafana v5.1 now ships with a built-in Microsoft SQL Server (MSSQL) data source plugin that allows you to query and visualize data from any +Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. Do you have metric or log data in MSSQL? You can now visualize +that data and define alert rules on it like with any of Grafana's other core data sources. + +Please read [Using Microsoft SQL Server in Grafana documentation]({{< relref "../datasources/mssql/" >}}) for more detailed information on how to get started and use it. + +
+ +## Dashboards and Panels + +### Adding new panels to dashboards + +{{< docs-imagebox img="/img/docs/v51/dashboard_add_panel.png" max-width= "800px" class="docs-image--right" >}} + +The control for adding new panels to dashboards have got some enhancements and now includes functionality to search for the type of panel +you want to add. Further, the control has tabs separating functionality for adding new panels and pasting +copied panels. + +By copying a panel in a dashboard it will be displayed in the `Paste` tab in *any* dashboard and allows you to paste the +copied panel into the current dashboard. + +{{< docs-imagebox img="/img/docs/v51/dashboard_panel_copy.png" max-width= "300px" >}} + +
+ +### Graph Panel + +New enhancements include support for multiple series stacking in histogram mode, thresholds for right Y axis, aligning left and right Y-axes to one level and additional units. More information in the [Graph panel documentation]({{< relref "../panels/visualizations/graph-panel.md" >}}). + +### Table Panel + +New enhancements include support for mapping a numeric value/range to text and additional units. More information in the [Table panel documentation](/features/panels/table_panel/#string). + +## New variable interpolation syntax + +We now support a new option for rendering variables that gives the user full control of how the value(s) should be rendered. +In the table below you can see some examples and you can find all different options in the [Variables documentation](http://docs.grafana.org/variables/templates-and-variables/#advanced-formatting-options). + +Filter Option | Example | Raw | Interpolated | Description +------------ | ------------- | ------------- | ------------- | ------------- +`glob` | ${servers:glob} | `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob +`regex` | ${servers:regex} | `'test.', 'test2'` | (test\.|test2) | Formats multi-value variable into a regex string +`pipe` | ${servers:pipe} | `'test.', 'test2'` | test.|test2 | Formats multi-value variable into a pipe-separated string +`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string + +## Improved workflow for provisioned dashboards + +{{< docs-imagebox img="/img/docs/v51/provisioning_cannot_save_dashboard.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.1 brings an improved workflow for provisioned dashboards: + +- A populated `id` property in JSON is now automatically removed when provisioning dashboards. +- When making changes to a provisioned dashboard you can `Save` the dashboard which now will bring up a *Cannot save provisioned dashboard* dialog like seen in the screenshot to the right. + + +Available options in the dialog will let you `Copy JSON to Clipboard` and/or `Save JSON to file` which can help you synchronize your dashboard changes back to the provisioning source. +More information in the [Provisioning documentation](/administration/provisioning/). + +
+ +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-2.md b/docs/sources/whatsnew/whats-new-in-v5-2.md new file mode 100644 index 0000000..a4f8833 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-2.md @@ -0,0 +1,99 @@ ++++ +title = "What's new in Grafana v5.2" +description = "Feature and improvement highlights for Grafana v5.2" +keywords = ["grafana", "new", "documentation", "5.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-2/"] +weight = -16 +[_build] +list = false ++++ + +# What's new in Grafana v5.2 + +Grafana v5.2 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here! +- [Native builds for ARM]({{< relref "#native-builds-for-arm" >}}) native builds of Grafana for many more platforms! +- [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets +- [Security]({{< relref "#security" >}}) make your Grafana instance more secure +- [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements +- [InfluxDB]({{< relref "#influxdb" >}}) now supports the `mode` function +- [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord +- [Dashboards and Panels]({{< relref "#dashboards-panels" >}}) with save and import enhancements + +## Elasticsearch alerting + +{{< docs-imagebox img="/img/docs/v52/elasticsearch_alerting.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.2 ships with an updated Elasticsearch data source with support for alerting. Alerting support for Elasticsearch has been one of +the most requested features by our community and now it's finally here. Please try it out and let us know what you think. + +
+ +## Native builds for ARM + +Grafana v5.2 brings an improved build pipeline with cross-platform support. This enables native builds of Grafana for ARMv7 (x32) and ARM64 (x64). +We've been longing for native ARM build support for ages. With the help from our amazing community this is now finally available. +Please try it out and let us know what you think. + +Another great addition with the improved build pipeline is that binaries for macOS/Darwin (x64) and Windows (x64) are now automatically built and +published for both stable and nightly builds. + +## Improved Docker image + +The Grafana docker image adds support for Docker secrets which enables you to supply Grafana with configuration through files. More +information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets). + +## Security + +{{< docs-imagebox img="/img/docs/v52/login_change_password.png" max-width="800px" class="docs-image--right" >}} + +Starting from Grafana v5.2, when you login with the administrator account using the default password you'll be presented with a form to change the password. +We hope this encourages users to follow Grafana's best practices and change the default administrator password. + +
+ +## Prometheus + +The Prometheus data source now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate* +functions get consistent results, and thus avoids graphs jumping around on reload. + +## InfluxDB + +The InfluxDB data source now includes support for the *mode* function which returns the most frequent value in a list of field values. + +## Alerting + +By popular demand Grafana now includes support for an alert notification channel type for [Discord](https://discordapp.com/). + +## Dashboards and Panels + +### Modified time range and variables are no longer saved by default + +{{< docs-imagebox img="/img/docs/v52/dashboard_save_modal.png" max-width="800px" class="docs-image--right" >}} + +Starting from Grafana v5.2, a modified time range or variable are no longer saved by default. To save a modified +time range or variable, you'll need to actively select that when saving a dashboard, see screenshot. +This should hopefully make it easier to have same defaults for time and variables in dashboards and make it more explicit +when you actually want to overwrite those settings. + +
+ +### Import dashboard enhancements + +{{< docs-imagebox img="/img/docs/v52/dashboard_import.png" max-width="800px" class="docs-image--right" >}} + +Grafana v5.2 adds support for specifying an existing folder or creating a new one when importing a dashboard - a long-awaited feature since +Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page has also got some general improvements +and should now make it more clear if a possible import will overwrite an existing dashboard, or not. + +This release also adds some improvements for those users only having editor or admin permissions in certain folders. The links to +*Create Dashboard* and *Import Dashboard* are now available in the side navigation, in dashboard search and on the manage dashboards/folder page for a +user that has editor role in an organization or the edit permission in at least one folder. + +
+ +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-3.md b/docs/sources/whatsnew/whats-new-in-v5-3.md new file mode 100644 index 0000000..2befd1e --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-3.md @@ -0,0 +1,90 @@ ++++ +title = "What's new in Grafana v5.3" +description = "Feature and improvement highlights for Grafana v5.3" +keywords = ["grafana", "new", "documentation", "5.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-3/"] +weight = -17 +[_build] +list = false ++++ + +# What's new in Grafana v5.3 + +Grafana v5.3 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) as a core data source! +- [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible +- [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders +- [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder! +- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for GitLab is improved +- [Annotations]({{< relref "#annotations" >}}) with template variable filtering +- [Variables]({{< relref "#variables" >}}) with free text support + +## Google Stackdriver + +{{< docs-imagebox img="/img/docs/v53/stackdriver-with-heatmap.png" max-width= "600px" class="docs-image--no-shadow docs-image--right" >}} + +Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana. + +Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver data source configuration page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables. + +If you're already accustomed to the Stackdriver Metrics Explorer UI, you'll notice that there are a lot of similarities to the query editor in Grafana. It is possible to add filters using wildcards and regular expressions. You can do Group By, Primary Aggregation and Alignment. + +Alias By allows you to format the legend the way you want, and it's a feature that is not yet present in the Metrics Explorer. Two other features that are only supported in the Grafana plugin are the abilities to manually set the Alignment Period in the query editor and to add Annotations queries. + +The Grafana Stackdriver plugin comes with support for automatic unit detection. Grafana will try to map the Stackdriver unit type to a corresponding unit type in Grafana, and if successful the panel Y-axes will be updated accordingly to display the correct unit of measure. This is the first core plugin to provide support for unit detection, and it is our intention to provide support for this in other core plugins in the near future. + +The data source is still in the `beta` phase, meaning it's currently in active development and is still missing one important feature - templating queries. +Please try it out, but be aware of that it might be subject to changes and possible bugs. We would love to hear your feedback. + +Refer to [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md" >}}) for more detailed information on how to get started and use it. + +## TV and Kiosk Mode + +{{< docs-imagebox img="/img/docs/v53/tv_mode_still.png" max-width="600px" class="docs-image--no-shadow docs-image--right" animated-gif="/img/docs/v53/tv_mode.gif" >}} + +We've improved the TV and kiosk mode to make it easier to use. There's now an icon in the top bar that will let you cycle through the different view modes. + +1. In the first view mode, the sidebar and most of the buttons in the top bar will be hidden. +1. In the second view mode, the top bar is completely hidden so that only the dashboard itself is shown. +1. Hit the escape key to go back to the default view mode. + +When switching view modes, the URL will be updated to reflect the view mode selected. This allows a dashboard to be opened with a +certain view mode enabled. Additionally, this also enables [playlists](/dashboards/playlist) to be started with a certain view mode enabled. + +
+ +## Notification Reminders + +Do you use Grafana alerting and have some notifications that are more important than others? Then it's possible to set reminders so that you continue to be alerted until the problem is fixed. This is done on the notification channel itself and will affect all alerts that use that channel. +For additional examples of why reminders might be useful for you, see [multiple series](/alerting/alerts-overview/#multiple-series). + +For more information about how to enable and configure reminders, refer to [alerting reminders](/alerting/notifications/#send-reminders). + +## Postgres Query Builder + +Grafana 5.3 comes with a new graphical query builder for Postgres. This brings Postgres integration more in line with some of the other data sources and makes it easier for both advanced users and beginners to work with timeseries in Postgres. For more information about Postgres graphical query builder, refer to [query editor]({{< relref "../datasources/postgres/#query-editor" >}}). + +{{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}} + +## Improved OAuth Support for GitLab + +Grafana 5.3 comes with a new OAuth integration for GitLab that enables configuration to only allow users that are a member of certain GitLab groups to authenticate. This makes it possible to use GitLab OAuth with Grafana in a shared environment without giving everyone access to Grafana. +For more information about how to enable and configure OAuth, refer to [Gitlab OAuth](/auth/gitlab/). + +## Annotations + +Grafana 5.3 brings improved support for [native annotations](/dashboards/annotations/#native-annotations) and makes it possible to use template variables when filtering by tags. +For more information about native annotation, refer to [query by tag](/dashboards/annotations/#query-by-tag). + +{{< docs-imagebox img="/img/docs/v53/annotation_tag_filter_variable.png" max-width="600px" >}} + +## Variables + +Grafana 5.3 ships with a brand new variable type named `Text box` which makes it easier and more convenient to provide free text input to a variable. +This new variable type will display as a free text input field with an optional prefilled default value. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v5-4.md b/docs/sources/whatsnew/whats-new-in-v5-4.md new file mode 100644 index 0000000..96312c5 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v5-4.md @@ -0,0 +1,81 @@ ++++ +title = "What's new in Grafana v5.4" +description = "Feature and improvement highlights for Grafana v5.4" +keywords = ["grafana", "new", "documentation", "5.4", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v5-4/"] +weight = -18 +[_build] +list = false ++++ + +# What's new in Grafana v5.4 + +Grafana v5.4 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Alerting]({{< relref "#alerting" >}}) Limit false positives with the new `For` setting +- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) Now with support for templating queries +- [MySQL]({{< relref "#mysql-query-builder" >}}) gets a new query builder! +- [Graph Panel]({{< relref "#graph-panel-enhancements" >}}) Highlight time regions and more +- [Team Preferences]({{< relref "#team-preferences" >}}) Give your teams their own home dashboard + +## Alerting + +{{< docs-imagebox img="/img/docs/v54/alerting-for-dark-theme.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.4 ships with a new alert rule setting named `For` which is great for removing false positives. If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. + +In the screenshot you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. [Learn more](/alerting/alerts-overview/#for). + +Additionally, there's now support for disable the sending of `OK` alert notifications. [Learn more](/alerting/notifications/#disable-resolve-message). + +
+ +## Google Stackdriver + +{{< docs-imagebox img="/img/docs/v54/stackdriver_template_query.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.3 included built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) which enables you to visualize your Stackdriver metrics in Grafana. +One important feature missing was support for templating queries. This is now included together with a brand new templating query editor for Stackdriver. + +The Stackdriver templating query editor lets you choose from a set of different Query Types. This will in turn reveal additional drop downs to help you +find, filter and select the templating values you're interested in, see screenshot for details. The templating query editor also supports chaining multiple variables +making it easy to define variables that's dependent on other variables. + +Stackdriver is the first data source which has support for a custom templating query editor. But starting from Grafana v5.4 it's now possible for all data sources, including plugin data sources, to +create their very own templating query editor. + +Additionally, if Grafana is running on a Google Compute Engine (GCE) virtual machine, it is now possible for Grafana to automatically retrieve default credentials from the metadata server. +This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. [Learn more]({{< relref "../datasources/google-cloud-monitoring/_index.md/#using-gce-default-service-account" >}}). + +Please read [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}) for more detailed information on how to get started and use it. + +
+ +## MySQL Query Builder + +Grafana v5.4 comes with a new graphical query builder for MySQL. This brings MySQL integration more in line with some of the other data sources and makes it easier for both advanced users and beginners to work with timeseries in MySQL. For more information about MySQL graphical query builder, refer to [query editor]({{< relref "../datasources/mysql/#query-editor" >}}). + +{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" animated-gif="/img/docs/v54/mysql_query.gif" >}} + +## Graph Panel Enhancements + +Grafana v5.4 adds support for highlighting weekdays and/or certain timespans in the graph panel. This should make it easier to compare for example weekends, business hours and/or off work hours. + +{{< docs-imagebox img="/img/docs/v54/graph_time_regions.png" max-width= "800px" >}} + +Additionally, when rendering series as lines in the graph panel, should there be only one data point available for one series so that a connecting line cannot be established, a point will +automatically be rendered for that data point. This should make it easier to understand what's going on when only receiving a single data point. + +{{< docs-imagebox img="/img/docs/v54/graph_dot_single_point.png" max-width= "800px" >}} + +## Team Preferences + +Grafana v5.4 adds support for customizing home dashboard, timezone and theme for teams, in addition to the existing customization on Organization and user Profile level. + +1. Specifying a preference on User Profile level will override preference on Team and/or Organization level +1. Specifying a preference on Team level will override preference on Organization level. + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-0.md b/docs/sources/whatsnew/whats-new-in-v6-0.md new file mode 100644 index 0000000..671b27c --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-0.md @@ -0,0 +1,174 @@ ++++ +title = "What's new in Grafana v6.0" +description = "Feature and improvement highlights for Grafana v6.0" +keywords = ["grafana", "new", "documentation", "6.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-0/"] +weight = -19 +[_build] +list = false ++++ + +# What's new in Grafana v6.0 + +This update to Grafana introduces a new way of exploring your data, support for log data, and tons of other features. + +The main highlights are: + +- [Explore]({{< relref "#explore" >}}) - A new query focused workflow for ad-hoc data exploration and troubleshooting. +- [Grafana Loki]({{< relref "#explore-and-grafana-loki" >}}) - Integration with the new open source log aggregation system from Grafana Labs. +- [Gauge Panel]({{< relref "#gauge-panel" >}}) - A new standalone panel for gauges. +- [New Panel Editor UX]({{< relref "#new-panel-editor" >}}) improves panel editing + and enables easy switching between different visualizations. +- [Google Stackdriver data source]({{< relref "#google-stackdriver-data-source" >}}) is out of beta and is officially released. +- [Azure Monitor]({{< relref "#azure-monitor-data-source" >}}) plugin is ported from being an external plugin to be a core data source +- [React Plugin]({{< relref "#react-panels-query-editors" >}}) support enables an easier way to build plugins. +- [Named Colors]({{< relref "#named-colors" >}}) in our new improved color picker. +- [Removal of user session storage]({{< relref "#easier-to-deploy-improved-security" >}}) makes Grafana easier to deploy and improves security. + +## Explore + +{{< docs-imagebox img="/img/docs/v60/explore_prometheus.png" max-width="800px" class="docs-image--right" caption="Screenshot of the new Explore option in the panel menu" >}} + +Grafana's dashboard UI is all about building dashboards for visualization. **Explore** strips away all the dashboard and panel options so that you can focus on the query and metric exploration. Iterate until you have a working query and then think about building a dashboard. You can also jump from a dashboard panel into **Explore** and from there do some ad-hoc query exploration with the panel queries as a starting point. + +For infrastructure monitoring and incident response, you no longer need to switch to other tools to debug what went wrong. **Explore** allows you to dig deeper into your metrics and logs to find the cause. Grafana's new logging data source, [Loki](https://github.com/grafana/loki) is tightly integrated into Explore and allows you to correlate metrics and logs by viewing them side-by-side. + +**Explore** is a new paradigm for Grafana. It creates a new interactive debugging workflow that integrates two pillars +of observability—metrics and logs. Explore works with every data source but for Prometheus we have customized the +query editor and the experience to provide the best possible exploration UX. + +### Explore and Prometheus + +Explore features a new [Prometheus query editor](/explore/#prometheus-specific-features). This new editor has improved autocomplete, metric tree selector, +integrations with the Explore table view for easy label filtering, and useful query hints that can automatically apply +functions to your query. There is also integration between Prometheus and Grafana Loki (see more about Loki below) that +enabled jumping between metrics query and logs query with preserved label filters. + +### Explore splits + +Explore supports splitting the view so you can compare different queries, different data sources and metrics and logs side by side! + +{{< docs-imagebox img="/img/docs/v60/explore_split.png" max-width="800px" caption="Screenshot of the new Explore option in the panel menu" >}} + +
+ +### Explore and Grafana Loki + +The log exploration and visualization features in Explore are available to any data source but are currently only implemented by the new open source log +aggregation system from Grafana Lab called [Grafana Loki](https://github.com/grafana/loki). + +Loki is a horizontally-scalable, highly-available, multi-tenant log aggregation system inspired by Prometheus. It is designed to be very cost effective, as it does not index the contents of the logs, but rather a set of labels for each log stream. The logs from Loki are queried in a similar way to querying with label selectors in Prometheus. It uses labels to group log streams which can be made to match up with your Prometheus labels. + +For more information about Grafana Loki, refer to [Github Grafana Loki](https://github.com/grafana/loki) or [Grafana Labs hosted Loki](https://grafana.com/loki). + +The Explore feature allows you to query logs and features a new log panel. In the near future, we will be adding support +for other log sources to Explore and the next planned integration is Elasticsearch. + +
+ +
+ +
+ +## New Panel Editor + +Grafana v6.0 has a completely redesigned UX around editing panels. You can now resize the visualization area if you want +more space for queries/options and vice versa. You can now also change visualization (panel type) from within the new +panel edit mode. No need to add a new panel to try out different visualizations! Check out the +video below to see the new Panel Editor in action. + +
+ +
+ +
+ +### Gauge Panel + +We have created a new separate Gauge panel as we felt having this visualization be a hidden option in the Singlestat panel +was not ideal. When it supports 100% of the Singlestat Gauge features, we plan to add a migration so all +singlestats that use it become Gauge panels instead. This new panel contains a new **Threshold** editor that we will +continue to refine and start using in other panels. + +{{< docs-imagebox img="/img/docs/v60/gauge_panel.png" max-width="600px" caption="Gauge Panel" >}} + +
+ +### React Panels and Query Editors + +A major part of all the work that has gone into Grafana v6.0 has been on the migration to React. This investment +is part of the future-proofing of Grafana's code base and ecosystem. Starting in v6.0 **Panels** and **Data +source** plugins can be written in React using our published `@grafana/ui` sdk library. More information on this +will be shared soon. + +{{< docs-imagebox img="/img/docs/v60/react_panels.png" max-width="600px" caption="React Panel" >}} +
+ +## Google Stackdriver data source + +Built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) is officially released in Grafana 6.0. Beta support was added in Grafana 5.3 and we have added lots of improvements since then. + +To get started read the guide: [Using Google Stackdriver in Grafana]({{< relref "../datasources/google-cloud-monitoring/_index.md/" >}}). + +## Azure Monitor data source + +One of the goals of the Grafana v6.0 release is to add support for the three major clouds. Amazon CloudWatch has been a core data source for years and Google Stackdriver is also now supported. We developed an external plugin for Azure Monitor last year and for this release the [plugin](https://grafana.com/plugins/grafana-azure-monitor-datasource) is being moved into Grafana to be one of the built-in data sources. For users of the external plugin, Grafana will automatically start using the built-in version. As a core data source, the Azure Monitor data source is able to get alerting support, in the 6.0 release alerting is supported for the Azure Monitor service, with the rest to follow. + +The Azure Monitor data source integrates four Azure services with Grafana - Azure Monitor, Azure Log Analytics, Azure Application Insights and Azure Application Insights Analytics. + +Please read [Using Azure Monitor in Grafana documentation]({{< relref "../datasources/azuremonitor/" >}}) for more detailed information on how to get started and use it. + +## Provisioning support for alert notifiers + +Grafana now has support for provisioning alert notifiers from configuration files, allowing operators to provision notifiers without using the UI or the API. A new field called `uid` has been introduced which is a string identifier that the administrator can set themselves. This is the same kind of identifier used for dashboards since v5.0. This feature makes it possible to use the same notifier configuration in multiple environments and refer to notifiers in dashboard json by a string identifier instead of the numeric id which depends on insert order and how many notifiers exist in the instance. + +## Easier to deploy and improved security + +Grafana 6.0 removes the need to configure and set up additional storage for [user sessions](/tutorials/ha_setup/#user-sessions). This should make it easier to deploy and operate Grafana in a +high availability setup and/or if you're using a stateless user session store like Redis, Memcache, Postgres or MySQL. + +Instead of user sessions, we've implemented a solution based on short-lived tokens that are rotated frequently. This also replaces the old "remember me cookie" +solution, which allowed a user to be logged in between browser sessions and which have been subject to several security holes throughout the years. +For more information about the short-lived token solution and how to configure it, refer to [short lived token](/auth/overview/#login-and-short-lived-tokens). + +> Please note that due to these changes, all users will be required to login upon next visit after upgrade. + +Besides these changes we have also made security improvements regarding Cross-Site Request Forgery (CSRF) and Cross-site Scripting (XSS) vulnerabilities: + +- Cookies are per default using the [SameSite](/administration/configuration/#cookie-samesite) attribute to protect against CSRF attacks +- Script tags in text panels are per default [disabled](/administration/configuration/#disable-sanitize-html) to protect against XSS attacks + +> **Note:** If you're using [Auth Proxy Authentication](/auth/auth-proxy/) you still need to have user sessions set up and configured +but our goal is to remove this requirement in the near future. + +## Named Colors + +{{< docs-imagebox img="/img/docs/v60/named_colors.png" max-width="400px" class="docs-image--right" caption="Named Colors" >}} + +We have updated the color picker to show named colors and primary colors. We hope this will improve accessibility and +helps making colors more consistent across dashboards. We hope to do more in this color picker in the future, like showing +colors used in the dashboard. + +Named colors also enables Grafana to adapt colors to the current theme. + +
+ +## Other features + +- The ElasticSearch data source now supports [bucket script pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html). This gives the ability to do per-bucket computations like the difference or ratio between two metrics. +- Support for Google Hangouts Chat alert notifications +- New built in template variables for the current time range in `$__from` and `$__to` + +## Upgrading + +See [upgrade notes](/installation/upgrading/#upgrading-to-v6-0). + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-1.md b/docs/sources/whatsnew/whats-new-in-v6-1.md new file mode 100644 index 0000000..07bf51a --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-1.md @@ -0,0 +1,57 @@ ++++ +title = "What's new in Grafana v6.1" +description = "Feature and improvement highlights for Grafana v6.1" +keywords = ["grafana", "new", "documentation", "6.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-1/"] +weight = -20 +[_build] +list = false ++++ + +# What's new in Grafana v6.1 + +## Highlights + +### Ad hoc Filtering for Prometheus + +{{< imgbox max-width="30%" img="/img/docs/v61/prometheus-ad-hoc.gif" caption="Ad-hoc filters variable for Prometheus" >}} + +The ad hoc filter feature allows you to create new key/value filters on the fly with autocomplete for both key and values. The filter condition is then automatically applied to all queries on the dashboard. This makes it easier to explore your data in a dashboard without changing queries and without having to add new template variables. + +Other timeseries databases with label-based query languages have had this feature for a while. Recently Prometheus added support for fetching label names from their API and thanks to [Mitsuhiro Tanda](https://github.com/mtanda) implementing it in Grafana, the Prometheus data source finally supports ad hoc filtering. + +Support for fetching a list of label names was released in Prometheus v2.6.0 so that is a requirement for this feature to work in Grafana. + +### Permissions: Editors can own dashboards, folders and teams they create + +When the dashboard folders feature and permissions system was released in Grafana 5.0, users with the editor role were not allowed to administrate dashboards, folders or teams. In the 6.1 release, we have added a configuration option that can change the default permissions so that editors are admins for any Dashboard, Folder or Team they create. + +This feature also adds a new Team permission that can be assigned to any user with the editor or viewer role and enables that user to add other users to the Team. + +We believe that this is more in line with the Grafana philosophy, as it will allow teams to be more self-organizing. This option will be made permanent if it gets positive feedback from the community so let us know what you think in the [issue on GitHub](https://github.com/grafana/grafana/issues/15590). + +To turn this feature on add the following [configuration option](/administration/configuration/#editors-can-admin) to your Grafana ini file in the `users` section and then restart the Grafana server: + +```ini +[users] +editors_can_admin = true +``` + +### List and revoke of user auth tokens in the API + +As the first step of a feature to be able to list a user's signed in devices/sessions and to be able log out those devices from the Grafana UI, support has been added to the [API to list and revoke user authentication tokens](/http_api/admin/#auth-tokens-for-user). + +### Minor Features and Fixes + +This release contains a lot of small features and fixes: + +- A new keyboard shortcut `d l` toggles all Graph legends in a dashboard. +- A small bug fix for Elasticsearch - template variables in the alias field now work properly. +- Some new capabilities have been added for data source plugins that will be of interest to plugin authors: + - a new OAuth pass-through option. + - it is now possible to add user details to requests sent to the dataproxy. +- Heatmap and Explore fixes. + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + +A huge thanks to our community for all the reported issues, bug fixes and feedback. diff --git a/docs/sources/whatsnew/whats-new-in-v6-2.md b/docs/sources/whatsnew/whats-new-in-v6-2.md new file mode 100644 index 0000000..5673761 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-2.md @@ -0,0 +1,94 @@ ++++ +title = "What's new in Grafana v6.2" +description = "Feature and improvement highlights for Grafana v6.2" +keywords = ["grafana", "new", "documentation", "6.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-2/"] +weight = -21 +[_build] +list = false ++++ + +# What's new in Grafana v6.2 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +If you use a password for your data sources please read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2). + +Check out the [demo dashboard](https://play.grafana.org/d/ZvPm55mWk/new-features-in-v6-2?orgId=1) of some the new features in v6.2. + +## Improved security + +Data sources now store passwords and basic auth passwords in `secureJsonData` encrypted by default. Existing data source with unencrypted passwords will keep working. +Read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2) on how to migrate existing data sources to use encrypted storage. + +To mitigate the risk of [Clickjacking](https://www.owasp.org/index.php/Clickjacking), embedding Grafana is no longer allowed per default. +Read the [upgrade notes](/installation/upgrading/#upgrading-to-v6-2) for further details of how this may affect you. + +To mitigate the risk of sensitive information being cached in browser after a user has logged out, browser caching is now disabled for full page requests. + +## Provisioning + +- Environment variables support, see [Using environment variables](/administration/provisioning/#using-environment-variables) for more information. +- Reload provisioning configs, see [Admin HTTP API](/http_api/admin/#reload-provisioning-configurations) for more information. +- Do not allow deletion of provisioned dashboards +- When trying to delete or save provisioned dashboard, relative file path to the file is shown in the dialog. + +## Official support for Elasticsearch 7 + +Grafana v6.2 ships with official support for Elasticsearch v7, see [Using Elasticsearch in Grafana]({{< relref "../datasources/elasticsearch/#elasticsearch-version" >}}) for more information. + +## Bar Gauge Panel + +Grafana v6.2 ships with a new exciting panel! This new panel, named Bar Gauge, is very similar to the current +Gauge panel and shares almost all it's options. The main difference is that the Bar Gauge uses both horizontal and +vertical space much better and can be more efficiently stacked both vertically and horizontally. The Bar Gauge also +comes with 3 unique display modes, Basic, Gradient, and Retro LED. Read the +[preview article](https://grafana.com/blog/2019/04/11/sneak-preview-of-new-visualizations-coming-to-grafana/) to learn +more about the design and features of this new panel. + +Retro LED display mode +{{< docs-imagebox img="/assets/img/blog/bargauge/bar_gauge_retro_led.jpg" max-width="800px" caption="Bar Gauge LED mode" >}} + +Gradient mode +{{< docs-imagebox img="/assets/img/blog/bargauge/gradient.jpg" max-width="800px" caption="Bar Gauge Gradient mode" >}} + +## Improved table data support + +We have been working on improving table support in our new react panels (Gauge and Bar Gauge) and this is ongoing work +that will eventually come to the new Graph and Singlestat and Table panels we are working on. But you can see it already in +the Gauge and Bar Gauge panels. Without any config, you can visualize any number of columns or choose to visualize each +row as its own gauge. + +## Lazy loading of panels out of view + +This has been one of the most requested features for many years and is now finally here! Lazy loading of panels means +Grafana will not issue any data queries for panels that are not visible. This will greatly reduce the load +on your data source backends when loading dashboards with many panels. + +## Panels without title + +Sometimes your panels do not need a title and having that panel header still take up space makes singlestats and +other panels look strange and have bad vertical centering. In v6.2 Grafana will allow panel content (visualizations) +to use the full panel height in case there is no panel title. + +{{< docs-imagebox img="/img/docs/v62/panels_with_no_title.jpg" max-width="800px" caption="Bar Gauge Gradient mode" >}} + +## Minor Features and Fixes + +This release contains a lot of small features and fixes: + +- Explore - Adds user time zone support, reconnect for failing data sources and a fix that prevents killing Prometheus instances when Histogram metrics are loaded. +- Alerting - Adds support for configuring timeout durations and retries, see [configuration](/administration/configuration/#evaluation-timeout-seconds) for more information. +- Azure Monitor - Adds support for multiple subscriptions per data source. +- Elasticsearch - A small bug fix to properly display percentiles metrics in table panel. +- InfluxDB - Support for POST HTTP verb. +- CloudWatch - Important fix for default alias disappearing in v6.1. +- Search - Works in a scope of dashboard's folder by default when viewing dashboard. + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + +A huge thanks to our community for all the reported issues, bug fixes and feedback. + +## Upgrading + +Read important [upgrade notes](/installation/upgrading/#upgrading-to-v6-2). diff --git a/docs/sources/whatsnew/whats-new-in-v6-3.md b/docs/sources/whatsnew/whats-new-in-v6-3.md new file mode 100644 index 0000000..20821cc --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-3.md @@ -0,0 +1,144 @@ ++++ +title = "What's new in Grafana v6.3" +description = "Feature and improvement highlights for Grafana v6.3" +keywords = ["grafana", "new", "documentation", "6.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-3/"] +weight = -22 +[_build] +list = false ++++ + +# What's new in Grafana v6.3 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +- New Explore features + - [Loki Live Streaming]({{< relref "#loki-live-streaming" >}}) + - [Loki Context Queries]({{< relref "#loki-context-queries" >}}) + - [Elasticsearch Logs Support]({{< relref "#elasticsearch-logs-support" >}}) + - [InfluxDB Logs Support]({{< relref "#influxdb-logs-support" >}}) +- [Data links]({{< relref "#data-links" >}}) +- [New Time Picker]({{< relref "#new-time-picker" >}}) +- [Graph Area Gradients]({{< relref "#graph-gradients" >}}) - A new graph display option! +- Grafana Enterprise + - [LDAP Active Sync]({{< relref "#ldap-active-sync" >}}) - LDAP Active Sync + - [SAML Authentication]({{< relref "#saml-authentication" >}}) - SAML Authentication + +## Explore improvements + +This release adds a ton of enhancements to Explore. Both in terms of new general enhancements but also in +new data source specific features. + +### Loki live streaming + +For log queries using the Loki data source you can now stream logs live directly to the Explore UI. + +### Loki context queries + +After finding a log line through the heavy use of query filters it can then be useful to +see the log lines surrounding the line your searched for. The `show context` feature +allows you to view lines before and after the line of interest. + +### Elasticsearch logs support + +This release adds support for searching and visualizing logs stored in Elasticsearch in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/elasticsearch_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using Elasticsearch in Grafana]({{< relref "../datasources/elasticsearch/#elasticsearch-version" >}}) for more detailed information on how to get started and use it. + +### InfluxDB logs support + +This release adds support for searching and visualizing logs stored in InfluxDB in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/influxdb_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using InfluxDB in Grafana]({{< relref "../datasources/influxdb/#querying-logs-beta" >}}) for more detailed information on how to get started and use it. + +## Data Links + +We have simplified the UI for defining panel drilldown links (and renamed them to Panel links). We have also added a +new type of link named `Data link`. The reason to have two different types is to make it clear how they are used +and what variables you can use in the link. Panel links are only shown in the top left corner of +the panel and you cannot reference series name or any data field. + +While `Data links` are used by the actual visualization and can reference data fields. + +Example: +```url +http://my-grafana.com/d/bPCI6VSZz/other-dashboard?var-server=${__series_name} +``` + +You have access to these variables: + +Name | Description +------------ | ------------- +*${__series_name}* | The name of the time series (or table) +*${__value_time}* | The time of the point your clicking on (in millisecond epoch) +*${__url_time_range}* | Interpolates as the full time range (i.e. from=21312323412&to=21312312312) +*${__all_variables}* | Adds all current variables (and current values) to the URL + +You can then click on point in the Graph. + +{{< docs-imagebox img="/img/docs/v63/graph_datalink.png" max-width="400px" caption="New Time Picker" >}} + +For now only the Graph panel supports `Data links` but we hope to add these to many visualizations. + +## New Time Picker + +The time picker has been re-designed and with a more basic design that makes accessing quick ranges more easy. + +{{< docs-imagebox img="/img/docs/v63/time_picker.png" max-width="400px" caption="New Time Picker" >}} + +## Graph Gradients + +Want more eye candy in your graphs? Then the fill gradient option might be for you! Works really well for +graphs with only a single series. + +{{< docs-imagebox img="/img/docs/v63/graph_gradient_area.jpeg" max-width="800px" caption="Graph Gradient Area" >}} + +Looks really nice in light theme as well. + +{{< docs-imagebox img="/img/docs/v63/graph_gradients_white.png" max-width="800px" caption="Graph Gradient Area" >}} + +## Grafana Enterprise + +Substantial refactoring and improvements to the external auth systems has gone in to this release making the features +listed below possible as well as laying a foundation for future enhancements. + +### LDAP Active Sync + +This is a new Enterprise feature that enables background syncing of user information, org role and teams memberships. +This syncing is otherwise only done at login time. With this feature you can schedule how often this user synchronization should +occur. + +For example, lets say a user is removed from an LDAP group. In previous versions of Grafana an admin would have to +wait for the user to logout or the session to expire for the Grafana permissions to update, a process that can take days. + +With active sync the user would be automatically removed from the corresponding team in Grafana or even logged out and disabled if no longer +belonging to an LDAP group that gives them access to Grafana. + +[Read more](/auth/enhanced_ldap/#active-ldap-synchronization). + +### SAML Authentication + +Built-in support for SAML is now available in Grafana Enterprise. + +[See docs]({{< relref "../auth/saml.md" >}}) + +### Team Sync for GitHub OAuth + +When setting up OAuth with GitHub it's now possible to sync GitHub teams with Teams in Grafana. + +[See docs]({{< relref "../auth/github.md" >}}) + +### Team Sync for Auth Proxy + +We've added support for enriching the Auth Proxy headers with Teams information, which makes it possible +to use Team Sync with Auth Proxy. + +[See docs](/auth/auth-proxy/#auth-proxy-authentication). diff --git a/docs/sources/whatsnew/whats-new-in-v6-4.md b/docs/sources/whatsnew/whats-new-in-v6-4.md new file mode 100644 index 0000000..a720b81 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-4.md @@ -0,0 +1,146 @@ ++++ +title = "What's new in Grafana v6.4" +description = "Feature and improvement highlights for Grafana v6.4" +keywords = ["grafana", "new", "documentation", "6.4", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-4/"] +weight = -23 +[_build] +list = false ++++ + +# What's new in Grafana v6.4 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query execution that is going to enable powerful future capabilities. +Some of those new capabilities can already be seen in this release, like sharing query results between panels. + +- [**Explore:** Go back to dashboard (with query changes)]({{< relref "#go-back-to-dashboard-from-explore" >}}) +- [**Explore:** Live tailing improvements]({{< relref "#live-tailing-improvements" >}}) +- **Loki:** Show logs as annotations in dashboard graphs +- **Loki:** Use Loki in dashboard panels +- [**Panels:** New logs panel]({{< relref "#new-logs-panel" >}}) +- [**Panels:** Data links improvements]({{< relref "#data-links-improvements" >}}) +- [**Graph:** Series override to turn constant (point) into a line]({{< relref "#series-override-to turn-constant-into-a-line" >}}) +- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results-between-panels" >}}) +- [**Plugins:** Alpha version of grafana-toolkit]({{< relref "#alpha-version-of-grafana-toolkit" >}}) +- [**Image Rendering:** PhantomJS deprecation]({{< relref "#phantomjs-deprecation" >}}) +- [**Docker:** Alpine based docker image]({{< relref "#alpine-based-docker-image" >}}) +- [**LDAP:** Debug UI]({{< relref "#ldap-debug-ui" >}}) +- [**Enterprise**: Reporting]({{< relref "#reporting" >}}) +- [**Enterprise**: GitLab OAuth Team Sync support]({{< relref "#gitlab-oauth-team-sync-support" >}}) +- [**Enterprise**: Teams and LDAP Improvements]({{< relref "#ldap-teams" >}}) + + +### Go back to dashboard from Explore + +To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, we've added the ability to return to the origin dashboard +after navigating to Explore from the panel's dropdown. + +{{< docs-imagebox img="/img/docs/v60/explore_panel_menu.png" caption="Screenshot of the new Explore Icon" >}} + +After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. + + + +Simply clicking the button will return you to the origin dashboard, or, if you'd like to bring changes you make in Explore back to the dashboard, simply click +the arrow next to the button to reveal a "Return to panel with changes" menu item. + + + +### Live tailing improvements + +With 6.4 version you can now pause the live tail view to see the last 1000 lines of logs without being interrupted by new logs coming in. You can either pause manually with pause button or the live tailing will automatically pause when you scroll up to see older logs. To resume you just hit the resume button to continue live tailing. + +We also introduced some performance optimizations to allow live tailing of higher throughput log streams and various UI fixes and improvements like more consistent styling and fresh logs highlighting. + + + +### New Logs Panel + +The logs panel shows log lines from datasources that support logs, e.g., Elastic, Influx, and Loki. Typically you would use this panel next to a graph panel to display the log output of a related process. + + + +Limitations: Even though Live tailing can be enabled on logs panels in dashboards, we recommend using Live tailing in Explore. On dashboards, the refresher at the top of the page should be used instead to keep the data of all panels in sync. Note that the logs panel is still beta and we're looking to get feedback. + +## Data Links improvements + +With Grafana 6.3 we introduced a new way of creating [Data Links](https://grafana.com/blog/2019/08/27/new-in-grafana-6.3-easy-to-use-data-links/). +Grafana 6.4 improves Data Links and adds them to the Gauge and Bar Gauge and panels. + +With Data Links you can define dynamic links to other dashboards and systems. The link can now reference template variables and query results like series name and labels, field name, value and time. + +For more information about Data Links, refer to [data link](https://grafana.com/docs/features/panels/graph/#data-link) + +## Series override to turn constant into a line + +Some graph query results are made up only of one datapoint per series but can be shown in the graph panel with the help of [series overrides](/features/panels/graph/#series-overrides). +To show a horizontal line through the Y-value of the datapoint across the whole graph, add a series override and select `Transform > constant`. + + + +## Share query results between panels + +Grafana 6.4 continues the work started in 6.3 of creating a data model and query execution lifecycle that can support robust analytics and streaming. These changes are mostly structural and lay the foundation for powerful features in future releases. + +The first new feature all these changes have enabled is the ability to share query results between panels. So for example if you have an expensive query you can visualize the same results in a graph, table and singlestat panel. To reuse another panel’s query result select the data source named `-- Dashboard --` and then select the panel. + +To make the sharing of query results even more powerful we are introducing a transformation step as well that allows you to select specific parts of the query result and transform it. This new transformation feature is in [alpha](https://grafana.com/docs/administration/configuration/#enable-alpha) state and has to be enabled in the config file. + +DataFrame, our primary data model, has now a [columnar](https://en.wikipedia.org/wiki/Column-oriented_DBMS) layout. This +will support easier frontend processing. The DataSource query interface has been updated to better support streaming. +The result can now either return a `Promise` or `Observable`. Be on the lookout for more on live data +streaming in the future! + +## Alpha version of grafana-toolkit + +[grafana-toolkit](https://www.npmjs.com/package/@grafana/toolkit/v/6.4.0-beta.1) is our attempt to simplify the life of plugin developers. It’s a CLI that helps them focus on the core value of their plugin rather than the ceremony around setting up the environment, configs, tests and builds. It’s available as an NPM package under `next` tag. + +You can read more about the grafana-toolkit [in the Readme](https://github.com/grafana/grafana/blob/master/packages/grafana-toolkit/README.md) and play with it by trying out our [react panel](https://github.com/grafana/simple-react-panel) or [angular panel](https://github.com/grafana/simple-angular-panel) templates. + +## PhantomJS deprecation + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, have been deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +## Alpine-based Docker image + +Grafana’s Docker image is now based on Alpine 3.10 and should from now on report zero vulnerabilities when scanning the image for security vulnerabilities. + +## LDAP Debug UI + +After listening to customer feedback, we have been working at improving the experience to set up authentication and synchronization with LDAP. We're happy to present the new LDAP Debug View. + +You'll be able to see how a user authenticating with LDAP would be mapped and whether your LDAP integration is working correctly. Furthermore, it provides a simpler method to test your integration with LDAP server(s) and have a clear view of how attributes are mapped between both systems. + +The feature is currently limited to Grafana Server Admins. + +For more information on how to use this new feature, follow the [guide]({{< relref "../auth/ldap.md#ldap-debug-view" >}}). + +## Grafana Enterprise + +### Reporting + +A common request from Enterprise users have been to be able to set up reporting for Grafana, and now it’s here. A report is simply a PDF of a Grafana dashboard, outside of just generating a PDF you can set up a schedule so that you can get the report emailed to yourself (or whoever is interested) whenever it suits you. + +This feature is currently limited to Organization Admins. + +{{< docs-imagebox img="/img/docs/v64/reports.jpeg" max-width="500px" caption="Reporting" >}} + +### GitLab OAuth Team Sync support + +GitLab OAuth gets support for Team Sync, making it possible to synchronize your GitLab Groups with Teams in Grafana. + +[Read more about Team Sync](https://grafana.com/docs/auth/team-sync/). + +## Upgrading + +See [upgrade notes](/installation/upgrading/#upgrading-to-v6-4). + +## Changelog + +Check out the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-5.md b/docs/sources/whatsnew/whats-new-in-v6-5.md new file mode 100644 index 0000000..43412b3 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-5.md @@ -0,0 +1,209 @@ ++++ +title = "What's new in Grafana v6.5" +description = "Feature and improvement highlights for Grafana v6.5" +keywords = ["grafana", "new", "documentation", "6.5", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-5/"] +weight = -24 +[_build] +list = false ++++ + +# What's new in Grafana v6.5 + +For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.5 comes with a lot of new features and enhancements: + +- [**Docker:** Ubuntu-based images and more]({{< relref "#ubuntu-based-docker-images" >}}) +- [**CloudWatch:** Major rewrite and lots of enhancements]({{< relref "#cloudwatch-data-source-improvements" >}}) +- [**Templating:** Dynamic typeahead queries using $__searchFilter]({{< relref "#dynamic-typeahead-support-in-query-variables" >}}) +- [**Graphite:** Support for additional Metrictank functionality]({{< relref "#graphite-support-for-additional-metrictank-functionality" >}}) +- [**Explore:** New log row details view]({{< relref "#explore-logs-log-row-details" >}}) +- [**Explore:** Turn parts of log message into a link using derived fields]({{< relref "#loki-explore-derived-fields" >}}) +- [**Explore:** Time-sync of split views]({{< relref "#time-sync-of-split-views-in-explore" >}}) +- [**Explore**: Hover/tooltip support in graphs]({{< relref "#explore-metrics-graph-hover-tooltip" >}}) +- [**Azure Monitor**: Alerting support for Azure Application Insights]({{< relref "#alerting-support-for-azure-application-insights" >}}) +- [**Provisioning**: Allow saving of provisioned dashboards from UI]({{< relref "#allow-saving-of-provisioned-dashboards-from-ui" >}}) +- [**Auth Proxy:** Mix auth proxy with Grafana login token and session cookie]({{< relref "#mix-auth-proxy-with-grafana-login-token-and-session-cookie" >}}) +- [**OAuth:** Generic OAuth now supports role mapping]({{< relref "#generic-oauth-role-mapping" >}}) +- [**Image Rendering:** Quick update since Grafana 6.4]({{< relref "#image-renderer-plugin" >}}) + +### Ubuntu-based Docker images + +In Grafana [v6.4]({{< relref "whats-new-in-v6-4/#alpine-based-docker-image" >}}), we switched the Grafana Docker image from Ubuntu to Alpine. This change provides a more secure and lightweight Docker image. + +This change has received both negative and positive feedback as well as some bug reports. We learned that switching to an Alpine-based Docker image was a big breaking change for a lot of users. We should have more clearly highlighted this in blog post, release notes, changelog, and the [Docker Hub readme](https://hub.docker.com/r/grafana/grafana). + +We also broke the Docker images for ARM, but this is fixed in Grafana v6.5. + +Grafana Docker images should be as secure as possible by default and that’s why the Alpine-based Docker images will continue to be the Grafana default (`grafana/grafana:`). With that said, it’s good to give users options, and that’s why starting from Grafana v6.5, Ubuntu-based Docker images are also (`grafana/grafana:-ubuntu`) available. + +Read more about [Installing using Docker]({{< relref "../installation/docker/" >}}). + +### CloudWatch data source improvements + +In this release, several feature improvements and additions were made in the CloudWatch data source. This work has been done in collaboration with the Amazon CloudWatch team. + +#### GetMetricData API + +For Grafana version 6.5 or higher, all API requests to GetMetricStatistics have been replaced with calls to GetMetricData, following Amazon’s [best practice to use the GetMetricData API](https://aws.amazon.com/premiumsupport/knowledge-center/cloudwatch-getmetricdata-api) instead of GetMetricStatistics, because data can be retrieved faster at scale with GetMetricData. This change provides better support for CloudWatch metric math and enables the use of automatic search expressions. + +While GetMetricStatistics qualified for the CloudWatch API free tier, this is not the case for GetMetricData calls. For more information, please refer to the [CloudWatch pricing page](https://aws.amazon.com/cloudwatch/pricing/). + +#### Dynamic queries using dimension wildcards + +In Grafana 6.5 or higher, you can monitor a dynamic list of metrics by using the asterisk (\*) wildcard for one or more dimension values. + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-dimension-wildcard.png" max-width="800px" class="docs-image--right" caption="CloudWatch dimension wildcard" >}} + +The example queries all metrics in the namespace `AWS/EC2` with a metric name of `CPUUtilization` and _any_ value for the `InstanceId` dimension. This can help you monitor metrics for AWS resources, like EC2 instances or containers. For example, when new instances get created as part of an auto scaling event, they automatically appear in the graph without you having to track new instance IDs. You can click `Show Query Preview` to see the search expression that is automatically built to support wildcards. To learn more about search expressions, visit the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html). + +By default, the search expression is defined in such a way that the queried metrics must match the defined dimension names exactly. This means that in the example it only returns metrics with exactly one dimension with name ‘InstanceId’. + +You can untoggle `Match Exact` to include metrics that have other dimensions defined. Turning off `Match Exact` also creates a search expression even if you don’t use wildcards. We simply search for any metric that match at least the namespace, metric name, and all defined dimensions. + +#### Deep linking from Grafana panels to the CloudWatch console + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch deep linking" >}} + +Left-clicking a time series in the panel displays a context menu with a link to `View in CloudWatch console`. Clicking that link opens the CloudWatch console and displays all the metrics for that query. If you are not currently logged in to the CloudWatch console, then the link opens the login page. The link is valid for any account, but it only displays the right metrics if you are logged in to the account that corresponds to the selected data source in Grafana. + +This feature is not available for metrics based on math expressions. + +#### Improved feedback when throttling occurs + +If the [limit of the GetMetricData API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html) is reached, either the transactions per second limit or the data points per second limit, then a throttling error will be returned by the CloudWatch API. Throttling limits are defined per account and region, so the alert modal indicates which data source got throttled in which region. A link to request a limit increase for the affected region is provided, but you will have to log in to the correct account. For example, for us-east-1, a limit increase can be requested on [AWS console](https://console.aws.amazon.com/servicequotas/home?region=us-east-1#!/services/monitoring/quotas/L-5E141212). + +#### Multi-value template variables now use search expressions + +When defining dimension values based on multi-valued template variables, we now use search expressions to query for the matching metrics. This enables the use of multiple template variables in one query and also allows you to use template variables for queries that have the `Match Exact` option disabled. + +Search expressions are currently limited to 1024 characters, so your query may fail if you have a long list of values. We recommend using the asterisk (\*) wildcard instead of the `All` option if you want to query all metrics that have any value for a certain dimension name. + +The use of multi-valued template variables is only supported for dimension values. Using multi-valued template variables for `Region`, `Namespace`, or `Metric Name` is not supported. + +#### Curated Dashboards + +The updated CloudWatch data source is shipped with pre-configured dashboards for five of the most popular AWS services: + +- Amazon Elastic Compute Cloud `Amazon EC2` +- Amazon Elastic Block Store `Amazon EBS` +- AWS Lambda `AWS Lambda` +- Amazon CloudWatch Logs `Amazon CloudWatch Logs` +- Amazon Relational Database Service `Amazon RDS` + +To import the pre-configured dashboards, go to the configuration page of your CloudWatch data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use. To customize the dashboard, we recommend to save the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +{{< docs-imagebox img="/img/docs/v65/cloudwatch-dashboard-import.png" max-width="600px" caption="CloudWatch dashboard import" >}} + +### Dynamic typeahead support in query variables + +If you have a query variable that has many thousands of values it can be quite slow to search for a specific value in the dropdown. This is due to the fact that all that search filtering is happening in the browser. + +Using `__searchFilter` in the template variable query field you can filter the query results based on what the user types in the variable dropdown input. When nothing has been entered by the user the default value for `__searchFilter` is `*` , `.*` or `%` depending on data source and formatting option. + +The example below shows how to use `__searchFilter` as part of the query field to enable searching for `server` while the user types in the dropdown select box. + +Query + +```bash +apps.$app.servers.$__searchFilter +``` + +TagValues + +```bash +tag_values(server, server=~${__searchFilter:regex}) +``` + +This feature is currently only supported by [Graphite]({{< relref "../datasources/graphite/#using-searchfilter-to-filter-results-in-query-variable" >}}), [MySQL]({{< relref "../datasources/mysql/#using-searchfilter-to-filter-results-in-query-variable" >}}) and [Postgres]({{< relref "../datasources/postgres/#using-searchfilter-to-filter-results-in-query-variable" >}}) data sources. + +### Graphite: Support for additional Metrictank functionality + +The Graphite data source now has an option to enable extra functionality when using [Metrictank](https://grafana.com/oss/metrictank/) as a Graphite datastore. +In the Datasource configuration for Graphite, you can change the type to Metrictank. +Metrictank returns 2 kinds of additional metadata along its responses: + +- **Performance information:** Time spent querying index, fetching data, running processing functions, the number of series and points fetched, cache hits/misses, etc. This can be useful for optimizing queries or tuning the chunk cache. +- **Lineage information about the returned series:** Which archive was fetched from (raw or rollup), which (if any) runtime consolidation was applied (using which processing function), etc. This is very useful information for anyone trying to understand how their data was generated and why it may not look as expected. + +To see the metadata response from Metrictank you can inspect the response using the Query Inspector found in the panel queries tab. +Grafana 6.5 includes a new `Panel Inspector` in alpha/preview where you also can see the metadata response from Metrictank. +You can try it out by enabling a feature flag in the Grafana configuration file: + +```bash +[feature_toggles] +enable = inspect +``` + +{{< docs-imagebox img="/img/docs/v65/panel-inspector.png" max-width="400px" caption="New Panel Inspector modal" >}} + +In Grafana 6.6, this will have a more user friendly display. In the future, additional Metrictank functionality will become available when the Graphite datasource option is set to the `Metrictank` type. + +### Explore/Metrics: Graph hover/tooltip + +We finally got around to implementing the series hover that shows values of the timeseries you hover over. This has been a requested feature ever since Explore was released. The graph component has been rewritten from scratch, making it more composable for future interactions with the graph data. + +{{< docs-imagebox img="/img/docs/v65/explore_tooltip.png" max-width="500px" caption="Explore graph tooltip/hover" >}} + +### Explore/Logs: Log row details + +We have massively simplified the way we display both log row labels/fields as well as parsed fields by putting them into an extendable area in each row. + +So far labels had been squashed into their own column, making long label values difficult to read or interact with. Similarly, the parsed fields (available for logfmt and JSON structured logs) were too fiddly for mouse interaction. To solve this we took both and put them into a collapsed area below each row for more robust interaction. We have also added the ability to filter out labels, i.e., turn them into a negative filter on click (in addition to a positive filter). + +{{< docs-imagebox img="/img/docs/v65/explore_log_details.gif" caption="Explore Log row details" >}} + +### Loki/Explore: Derived fields + +Derived fields allow any part of a log message to be turned into a link. Leaning on the concept of data links for graphs, we've extended the log result viewer in Explore to turn certain parsed fields into a link, based on a pattern to match. + +This allows you to turn an occurrence of e.g., `traceId=624f706351956b81` in your log line, into a link to your distributed tracing system to view that trace. The configuration for the patterns to match can be found in the datasource settings. + +This release starts with support for Loki, but we will bring this concept to other data sources soon. + +### Time-sync of split views in Explore + +In the Explore split view, you can now link the two timepickers so that if you change one, the other gets changed as well. This helps with keeping start and end times of the split view queries in sync and will ensure that you're looking at the same time interval in both split panes. + +{{< docs-imagebox img="/img/docs/v65/explore_time_sync.gif" caption="Time-sync of split views in Explore" >}} + +### Alerting support for Azure Application Insights + +The [Azure Monitor]({{< relref "../datasources/azuremonitor/" >}}) data source supports multiple services in the Azure cloud. Before Grafana v6.5, only the Azure Monitor service had support for [Grafana Alerting]({{< relref "../alerting" >}}). In Grafana 6.5, alerting support has been implemented for the [Application Insights service]({{< relref "../datasources/azuremonitor/#querying-the-application-insights-service" >}}). + +### Allow saving of provisioned dashboards from UI + +Historically it has been possible to make changes to a provisioned dashboard in the Grafana UI. However, it hasn't been possible to save the changes without manual intervention. In Grafana 6.5 we introduce a new dashboard provisioning setting named `allowUiUpdates`. If `allowUiUpdates` is set to `true` and you make changes to a provisioned dashboard, you can save the dashboard and the changes will be persisted to the Grafana database. + +Read more about this new feature in [Provisioning Grafana]({{< relref "../administration/provisioning/#making-changes-to-a-provisioned-dashboard" >}}). + +### Mix auth proxy with Grafana login token and session cookie + +With the new setting, `enable_login_token`, set to true Grafana will, after successful auth proxy header validation, assign the user a login token and cookie. You only have to configure your auth proxy to provide headers for the /login route. Requests via other routes will be authenticated using the cookie. + +Read more about this new feature in [Auth Proxy Authentication]({{< relref "../auth/auth-proxy/#login-token-and-session-cookie" >}}) + +### Generic OAuth role mapping + +Grafana 6.5 makes it possible to configure Generic OAuth to map a certain response from OAuth provider to a certain Grafana organization role, similar to the existing [LDAP Group Mappings]({{< relref "../auth/ldap/#group-mappings" >}}) feature. The new setting is named `role_attribute_path` and expects a [JMESPath](http://jmespath.org/) expression. + +Read more about this new feature in [Generic OAuth Authentication]({{< relref "../auth/generic-oauth/" >}}) and make sure to check out the [JMESPath examples]({{< relref "../auth/generic-oauth/#jmespath-examples" >}}). + +### Image renderer plugin + +Since we announced the deprecation of PhantomJS and the new [Image Renderer Plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) in Grafana [6.4]({{< relref "whats-new-in-v6-4/#phantomjs-deprecation" >}}), we’ve received bug reports and valuable feedback. + +In Grafana 6.5 we’ve updated documentation to make it easier to understand how to install and troubleshoot possible problems. Read more about [Image Rendering]({{< relref "../administration/image_rendering/" >}}). + +Please try the [Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) and let us know what you think. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v6-5" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v6-6.md b/docs/sources/whatsnew/whats-new-in-v6-6.md new file mode 100644 index 0000000..b3bd7b8 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-6.md @@ -0,0 +1,218 @@ ++++ +title = "What's new in Grafana v6.6" +description = "Feature and improvement highlights for Grafana v6.6" +keywords = ["grafana", "new", "documentation", "6.6", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-6/"] +weight = -25 +[_build] +list = false ++++ + +# What's new in Grafana v6.6 + +For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Highlights + +Grafana 6.6 comes with a lot of new features and enhancements: + +- [**Panels:** New stat panel]({{< relref "#new-stat-panel" >}}) +- [**Panels:** Auto min/max for Bar Gauge/Gauge/Stat]({{< relref "#auto-min-max" >}}) +- [**Panels:** News panel]({{< relref "#news-panel" >}}) +- [**Panels:** Custom data units]({{< relref "#custom-data-units" >}}) +- [**Panels:** Bar Gauge unfilled option]({{< relref "#bar-gauge-unfilled-option" >}}) +- [**TimePicker:** New design & features]({{< relref "#new-time-picker" >}}) +- [**Alerting enhancements**]({{< relref "#alerting-enhancements" >}}) +- [**Explore:** Added log message line wrapping options for logs]({{< relref "#explore-logs-panel-log-message-line-wrapping-options" >}}) +- [**Explore:** Column with unique log labels ]({{< relref "#explore-logs-panel-column-with-unique-log-labels" >}}) +- [**Explore:** Context tooltip]({{< relref "#explore-context-tooltip" >}}) +- **Explore:** Added ability to specify step with Prometheus queries +- **Graphite:** Added Metrictank dashboard to Graphite datasource +- **Loki:** Support for template variable queries +- **Postgres/MySQL/MSSQL:** Added support for region annotations +- [**Security:** Added disabled option for cookie sameSite attribute]({{< relref "#cookie-management-modifications" >}}) +- **TablePanel, GraphPanel:** Exclude hidden columns from CSV +- [**Enterprise:** White labeling]({{< relref "#enterprise-white-labeling" >}}) +- [**Enterprise:** APT and YUM repositories]({{< relref "#enterprise-apt-and-yum-repositories" >}}) +- [**Stackdriver:** Meta labels]({{< relref "#stackdriver-meta-labels" >}}) +- [**CloudWatch:** Calculate period based on time range]({{< relref "#cloudwatch-calculate-period-based-on-time-range" >}}) +- [**CloudWatch:** Display partial result in graph when max DP/call limit is reached]({{< relref "#cloudwatch-display-partial-result-in-graph-when-max-data-points-per-call-limit-is-reached" >}}) + +## New stat panel + +{{< docs-imagebox img="/img/docs/v66/stat_panel_dark2.png" max-width="1024px" caption="Stat panel" >}} + +This release adds a new panel named `Stat`. This panel is designed to replace the current `Singlestat` as the primary way to show big single number panels along with a sparkline. This panel is of course building on our new panel infrastructure and option design. So, you can use the new threshold UI and data links. It also supports the same repeating feature as the Gauge and Bar Gauge panels, meaning it will repeat a separate visualization for every series or row +in the query result. + +Key features: + +- Automatic font size handling +- Automatic layout handling based on panel size +- Colors based on thresholds that adapt to light or dark theme +- Data links support +- Repeats horizontally or vertically for every series, row, or column + +Here is how it looks in light theme: + +{{< docs-imagebox img="/img/docs/v66/stat_panel_light.png" max-width="1024px" caption="Stat panel" >}} + +## Auto min-max + +For the panels Gauge, Bar Gauge, and Stat, you can now leave the min and max settings empty. Grafana will, in that case, calculate the min and max based on all the data. + +## News panel + +This panel shows RSS feeds as news items in the default home dashboard for v6.6. Add it to your custom home dashboards to keep up-to-date with Grafana news, or switch the default RSS feed to one of your choice. + +{{< docs-imagebox img="/img/docs/v66/news_panel.png" max-width="600px" caption="News panel" >}} + +## Custom data units + +A top feature request for years is now finally here. All panels now support custom units. Type any text in the unit picker and select the `Custom: ` option. By default, the text will be used as a suffix unit. If you want a custom prefix, then type `prefix: ` to make the custom unit appear before the value. If you want a custom SI unit (with auto SI suffixes) specify `si:Ups`. A value like 1000 will be rendered as `1 kUps`. + +{{< docs-imagebox img="/img/docs/v66/custom_unit_burger1.png" max-width="600px" caption="Custom unit" >}} + +You can also paste a native emoji in the unit picker and pick it as a custom unit: + +{{< docs-imagebox img="/img/docs/v66/custom_unit_burger2.png" max-width="600px" caption="Custom unit emoji" >}} + +## Bar Gauge unfilled option + +The Bar Gauge visualization has a new display option: `Unfilled`. This new option is enabled by default, so it will change how this visualization is displayed on old dashboards. If you prefer the old default -- in which an unfilled area is not shown, and the value follows directly after -- you have to update the visualization settings. +{{< docs-imagebox img="/img/docs/v66/bar_gauge_unfilled.png" max-width="900px" caption="Bar gauge unfilled" >}} + +## New time picker + +The time picker has gotten a major design update. Key changes: + +- Quickly access the absolute from and to input fields without an extra click. +- Calendar automatically shows when from or to inputs have focus. +- A single calendar view can be used to select and show the from and to date. +- You can now select recent absolute ranges. + +{{< docs-imagebox img="/img/docs/v66/time_picker_update.png" max-width="700px" caption="New time picker" >}} + +## Alerting enhancements + +- We have introduced a new configuration for enforcing a minimal interval between evaluations to reduce load on the backend. +- The email notifier can now optionally send a single email to all recipients. +- OpsGenie, PagerDuty, Threema, and Google Chat notifiers have been updated to send additional information. + +## Cookie management modifications + +In order to align with a [change in Chrome 80](https://www.chromestatus.com/feature/5088147346030592), a breaking change has been introduced to Grafana's [`cookie_samesite` setting]({{< relref "../administration/configuration.md#cookie-samesite" >}}). Grafana now properly renders cookies with the `SameSite=None` attribute when this setting is `none`. The previous behavior of `none` was to omit the `SameSite` attribute from cookies. Grafana will use the previous behavior when `cookie_samesite` is set to `disabled`. + +Read more about this in the [upgrade notes]({{< relref "../installation/upgrading/#important-changes-regarding-samesite-cookie-attribute" >}}). + +## Explore/Logs Panel: Log message line wrapping options + +We introduced the wrap-lines option for logs because as for some of our users feel it's more efficient to see one line per log message. The wrapped-line option is set as a default; the unwrapped setting results in horizontal scrolling. + +{{< docs-imagebox img="/img/docs/v66/explore_wrap_lines.gif" max-width="600px" caption="Log message line wrapping" >}} + +## Explore/Logs Panel: Column with unique log labels + +After feedback from our community, we have decided to reintroduce a labels column. However, for better readability and usefulness, we have transformed it into a Unique labels column which includes only non-common labels. All common labels are displayed above. + +{{< docs-imagebox img="/img/docs/v66/explore_labels_column.png" max-width="600px" caption="Unique log labels column" >}} + +## Explore: Context tooltip + +Isolating a series from a big set of lines in a graph is important for drill-downs. That's why we have implemented the context tooltip in Explore, which allows you to copy data and labels from it to further refine the query. + +{{< docs-imagebox img="/img/docs/v66/explore_context_tooltip.png" max-width="600px" caption="Explore context tooltip" >}} + +## Enterprise: White labeling + +This release adds new white labeling options to the grafana.ini file (can also be set via ENV variables). + +```bash +[white_labeling] +# Set to complete URL to override login logo +login_logo = https://my.logo.url/images/logo.png + +# Set to complete css background expression to override login background +login_background = url(http://www.bhmpics.com/wallpapers/starfield-1920x1080.jpg) + +# Set to complete URL to override menu logo +menu_logo = https://my.logo.url/images/logo_icon.png + +# Set to complete URL to override fav icon (icon shown in browser tab) +fav_icon = https://my.logo.url/images/logo_icon_32px.png + +# Set to complete URL to override apple/ios icon +apple_touch_icon = https://my.logo.url/images/logo_icon_32px.png + +# Below is an example for how to replace the default footer & help links with 2 custom links +footer_links = support guides +footer_links_support_text = Support +footer_links_support_url = http://your.support.site +footer_links_guides_text = Guides +footer_links_guides_url = http://your.guides.site +``` + +Customize the login page, side menu bar, and footer links. + +{{< docs-imagebox img="/img/docs/v66/whitelabeling_1.png" max-width="700px" caption="White labeling example" >}} + +## Enterprise APT and YUM repositories + +Now you can install the enterprise edition from the APT and YUM repository. The following table shows the APT repository for each Grafana version (for instructions read the [installation notes]({{< relref "../installation/debian/#install-from-apt-repository" >}})) : + +| Grafana Version | Package | Repository | +|-----------------|---------|------------| +| Grafana OSS | grafana | `https://packages.grafana.com/oss/deb stable main` | +| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/deb beta main` | +| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/deb stable main` | +| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/deb beta main` | + +The following table shows the YUM repositories for each Grafana version (for instructions read the [installation notes]({{< relref "../installation/rpm/#install-from-yum-repository" >}})) : + +| Grafana Version | Package | Repository | +|----------------------------|--------------------|----------------------------------------------------| +| Grafana OSS | grafana | `https://packages.grafana.com/oss/rpm` | +| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/rpm-beta` | +| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm` | +| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm-beta` | + +We recommend all users to install the Enterprise Edition of Grafana, which can be seamlessly upgraded with a Grafana Enterprise [subscription](https://grafana.com/products/enterprise/?utm_source=grafana-install-page). + +## Stackdriver: Meta labels + +From now on it will be possible to utilize meta data label in "group bys", filters and in the alias field. Unfortunately, there's no API to retrieve all the labels, but the group by field dropdown comes with a pre-defined list of common system labels. User labels cannot be pre-defined, but it's possible to enter them manually in the group by field. If a meta data label, user label or system label, is included in the group by segment, it will be possible to create filters based on it and to expand its value on the alias field. + +{{< docs-imagebox img="/img/docs/v66/metadatalabels.gif" max-width="800px" caption="Stackdriver meta labels" >}} + +## CloudWatch: Calculate period based on time range + +When the period field was left blank in Grafana 6.5, it would default to 60 seconds. In case users issued queries with a large time span, there was a high risk that they would reach the 100,800 data points per request limit in the Get Metric Data (GMD) API. When the period field is left blank in Grafana 6.6, the period will be calculated automatically based on the time range. The formula that is used is `time range in seconds / 2000`, and then we snap to next higher value in an array of pre-defined periods `[60, 300, 900, 3600, 21600, 86400]`. This will reduce the risk for receiving a `Too many datapoints requested` error in the panel. + +## CloudWatch: Display partial result in graph when max data points per call limit is reached + +In case all queries in a GMD call are metric stat (not using math expressions), Grafana will paginate the response until all data points are received. But pagination is not supported in case a math expression is being used, so in that case it's not possible to receive more than 100,800 data points. Previously when that limit was reached, we only displayed an error message. In Grafana 6.6, we also display the 100,800 data points that were received in the graph. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v6-6" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## Notice about upcoming changes in backendSrv for plugin authors + +In our mission to migrate away from AngularJS to React we have removed all AngularJS dependencies in the core data retrieval service `backendSrv`. This change is already in master and will be introduced in the next `major` Grafana release. + +Removing the AngularJS dependencies in `backendSrv` has the unfortunate side effect of AngularJS digest no longer being triggered for any request made with `backendSrv`. Because of this, external plugins using `backendSrv` directly may suffer from strange behaviour in the UI. + +To remedy this issue as a plugin author you need to trigger the digest after a direct call to `backendSrv`. + +Example: + +```js +backendSrv.get(‘http://your.url/api’).then(result => { + this.result = result; + this.$scope.$digest(); +}); +``` diff --git a/docs/sources/whatsnew/whats-new-in-v6-7.md b/docs/sources/whatsnew/whats-new-in-v6-7.md new file mode 100644 index 0000000..07b2fd2 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v6-7.md @@ -0,0 +1,94 @@ ++++ +title = "What's New in Grafana v6.7" +description = "Feature and improvement highlights for Grafana v6.7" +keywords = ["grafana", "new", "documentation", "6.7", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v6-7/"] +weight = -26 +[_build] +list = false ++++ + +# What's new in Grafana v6.7 + +This topic includes the release notes for the Grafana v6.7. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +Grafana 6.7 comes with a lot of new features and enhancements: + +- [**Dashboard:** Enforce minimum refresh interval]({{< relref "#enforce-minimum-dashboard-refresh-interval" >}}) +- **Data source:** Google Sheets data source +- [**Explore:** Query history]({{< relref "#query-history" >}}) +- [**Authorization:** Azure OAuth]({{< relref "#azure-oauth" >}}) +- [**Stackdriver:** Project Selector]({{< relref "#stackdriver-project-selector" >}}) +- [**Enterprise:** White Labeling for application title]({{< relref "#white-labeling-for-application-title" >}}) +- [**Enterprise:** Reporting configuration for timeout and concurrency]({{< relref "#reporting-configuration-for-timeout-and-concurrency" >}}) +- [**Enterprise:** Export dashboard as pdf]({{< relref "#export-dashboard-as-pdf" >}}) +- [**Enterprise:** Report landscape mode]({{< relref "#report-landscape-mode" >}}) +- [**Enterprise:** Azure OAuth Team Sync support]({{< relref "#azure-oauth-team-sync-support" >}}) + +## General features + +General features are included in all Grafana editions. + +### Query history +> BETA: Query history is a beta feature. It is local to your browser and is not shared with others. + +Query history is a new feature that lets you view and interact with the queries that you have previously run in Explore. You can add queries to the Explore query editor, write comments, create and share URL links, star your favorite queries, and much more. Starred queries are displayed in Starred tab, so it is easier to reuse queries that you run often without typing them from scratch. + +Learn more about query history in [Explore]({{< relref "../explore" >}}). + +{{< docs-imagebox img="/img/docs/v67/rich-history.gif" max-width="1024px" caption="Query history" >}} + +### Azure OAuth +Grafana v6.7 comes with a new OAuth integration for Microsoft Azure Active Directory. You can now assign users and groups to Grafana roles from the Azure Portal. Learn how to enable and configure it in [Azure AD OAuth2 authentication]({{< relref "../auth/azuread/" >}}). + +### Enforce minimum dashboard refresh interval + +Allowing a low dashboard refresh interval can cause severe load on data sources and Grafana. Grafana v6.7 allows you to restrict the dashboard refresh interval so it cannot be set lower than a given interval. This provides a way for administrators to control dashboard refresh behavior on a global level. + +Refer to min_refresh_interval in [Configuration]({{< relref "../administration/configuration#min-refresh-interval" >}}) for more information and how to enable this feature. + +### Stackdriver project selector + +A Stackdriver data source in Grafana is configured for one service account only. That service account is always associated with a default project in Google Cloud Platform (GCP). Depending on your setup in GCP, the service account might be granted access to more projects than just the default project. + +In Grafana 6.7, the query editor has been enhanced with a project selector that makes it possible to query different projects without changing datasource. Many thanks [Eraac](https://github.com/Eraac), [eliaslaouiti](https://github.com/eliaslaouiti), and [NaurisSadovskis](https://github.com/NaurisSadovskis) for making this happen! + +## Grafana Enterprise features + +General features are included in the Grafana Enterprise edition software. + +### White labeling customizes application title +This release adds a new white labeling option to customize the application title. Learn how to configure it in [White labeling]({{< relref "../enterprise/white-labeling/" >}}). + +``` +[white_labeling] +# Set to your company name to override application title +app_title = Your Company +``` + +### Configure reporting for timeout and concurrency + +This release adds more configuration for the reporting feature rendering requests. You can set the panel rendering request timeout and the maximum number of concurrent calls to the rendering service in your configuration. Learn how to do it in [Reporting]({{< relref "../enterprise/reporting/" >}}). + +``` +[reporting] +# Set timeout for each panel rendering request +rendering_timeout = 10s +# Set maximum number of concurrent calls to the rendering service +concurrent_render_limit = 10 +``` + +### Export dashboard as PDF + +This feature allows you to export a dashboard as a PDF document. All dashboard panels will be rendered as images and added into the PDF document. Learn more in [Export dashboard as PDF]({{< relref "../enterprise/export-pdf/" >}}). + +### Report landscape mode + +You can now use either portrait or landscape mode in your reports. Portrait will render three panels per page and landscape two. +{{< docs-imagebox img="/img/docs/enterprise/reports_create_new.png" max-width="1024px" caption="New report" >}} + +[Reporting]({{< relref "../enterprise/reporting/" >}}) has been updated as a result of this change. + +### Azure OAuth Team Sync support +When setting up OAuth with Microsoft Azure AD, you can now sync Azure groups with Teams in Grafana. +Learn more in [Team sync]({{< relref "../enterprise/team-sync/" >}}). diff --git a/docs/sources/whatsnew/whats-new-in-v7-0.md b/docs/sources/whatsnew/whats-new-in-v7-0.md new file mode 100644 index 0000000..9a2139d --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-0.md @@ -0,0 +1,229 @@ ++++ +title = "What's New in Grafana v7.0" +description = "Feature and improvement highlights for Grafana v7" +keywords = ["grafana", "new", "documentation", "7.0", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-0/"] +weight = -27 +[_build] +list = false ++++ + +# What's new in Grafana v7.0 + +This topic includes the release notes for Grafana v7.0. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +This major release of Grafana is the next step in our Observability story. It includes powerful new features for manipulating, transforming, and doing math on data. Grafana Enterprise has the first version of Usage analytics, which will help Grafana Admins better manage large, corporate Grafana ecosystems. + +The Grafana 7.0 stable release is scheduled for the 18th of May. In the meantime, if you want to know more about what we've been up to and what is coming, sign up for our online GrafanaCon conference. + +[{{< figure src="/assets/img/blog/GrafanaCONline.jpg" max-width="800px" lightbox="false" caption="GrafanaCONline May 13-29" >}}](https://grafana.com/about/events/grafanacon/2020/?source=blog) + +The main highlights are: + +- [**New Panel Editor** Redesign based on community feedback.]({{< relref "#new-panel-editor-and-unified-data-model" >}}) +- [**Explore** New tracing UI and support for visualizing Jaeger and Zipkin traces.]({{< relref "#new-tracing-ui" >}}) +- [**Enterprise** Usage insights, Presence indicator, and Auth improvements.]({{< relref "#grafana-enterprise" >}}) +- [**Transformations** Transformations and simple Math operations for all data sources.]({{< relref "#transformations" >}}) +- [**Field overrides** Automatically configure panels with data from queries.]({{< relref "#field-options-and-overrides" >}}) +- [**Table** New Table panel.]({{< relref "#table-panel" >}}) +- [**Plugins** New plugins platform.]({{< relref "#plugins-platform" >}}) +- [**Tutorials** New tutorials section.]({{< relref "#new-tutorials" >}}) +- [**Cloudwatch** Support for Cloudwatch Logs in Explore and the Logs panel.]({{< relref "#cloudwatch-logs" >}}) +- [**Breaking change** PhantomJS removed.]({{< relref "#breaking-change-phantomjs-removed" >}}) +- [**Time zones** Time zone support]({{< relref "#time-zone-support" >}}) + +## New panel editor and unified data model + +We have redesigned the UI for editing panels. The first visible change is that we have separated panel display settings to a right-hand side pane that you can collapse or expand depending on what your focus is on. With this change we are also introducing our new unified option model and UI for defining data configuration and display options. This unified data configuration system powers a consistent UI for setting data options across visualizations, as well as making all data display settings data driven and overridable. + +This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like unit, min, max, thresholds, links, decimals. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +In previous versions of Grafana, each visualization had slightly different ways to define their options. One immediate benefit is after setting options like units or thresholds in a panel, you can seamlessly switch between visualization types and keep those options. This will bring increased ease of use and more consistency for users and plugin developers. + +We have yet to migrate all core panels to this new architecture so in 7.0 there will be some inconsistencies in the UI between panels. This will be fixed soon in future releases as we update all the core panels and help the community update the community panel plugins. + +Learn more about this feature in [Panel editor]({{< relref "../panels/panel-editor.md" >}}). + +## New tracing UI + +This release adds major support for distributed tracing, including a telemetry mode to complement the existing support for metrics and logs. Traces allow you to follow how single requests travel through a distributed system. We are starting with an integrated trace viewer and two new built-in data sources: Jaeger and Zipkin. + +You can use the new trace view in Explore either directly to search for a particular trace or you can configure Loki to detect trace IDs in the log lines and link directly to a trace timeline pulled from Jaeger or Zipkin data source. + +In the future we will add more workflows and integrations so that correlating between metrics, logs and traces is even easier. + +{{< docs-imagebox img="/img/docs/v70/tracing_ui.png" max-width="1024px" caption="Tracing UI" >}} + +## Transformations + +The data you want to visualize can come from many different places and it is usually not in exactly the right form. Users can now transform non-time series data into tables (e.g., JSON files or even simple lookup tables) in seconds without any customization or additional overhead. They can then combine non-time series data with any other data in Grafana; data from an external database or a panel that already exists in one of their current dashboards. + +By chaining a simple set of point and click [transformations]({{< relref "../panels/transformations/_index.md" >}}), users will be able join, pivot, filter, re-name and do calculations to get the results they need. Perfect for operations across queries or data sources missing essential data transformations. + +[Transformations]({{< relref "../panels/transformations/_index.md" >}}) also adds the ability to do maths across queries. Lots of data sources do not support this natively, so being able to do it in Grafana is a powerful feature. + +For users with large dashboards or with heavy queries, being able to reuse the query result from one panel in another panel can be a huge performance gain for slow queries (e.g log or sql queries). From the data source menu in the query editor, you can choose the `--dashboard--` option and then choose the query result from another panel on the same dashboard. + +The [Google Sheets data source](https://grafana.com/grafana/plugins/grafana-googlesheets-datasource) that was published a few weeks ago works really well together with the transformations feature. + +We are also introducing a new shared data model for both time series and table data that we call [DataFrame]({{< relref "../developers/plugins/data-frames/#data-frames" >}}). A DataFrame is like a table with columns but we refer to columns as fields. A time series is a DataFrame with two fields (time & value). + +**Transformations shipping in 7.0** + +- **Reduce:** Reduce all rows or data points to a single value using a function like max, min, mean or last. +- **Filter by name:** Removes part of the query results using a regex pattern. The pattern can be inclusive or exclusive. +- **Filter data by query** Filter data by query. This is useful if you are sharing the results from a different panel that has many queries and you want to only visualize a subset of that in this panel. +- **Organize fields:** Allows the user to re-order, hide, or rename fields / columns. Useful when data source doesn't allow overrides for visualizing data. +- **Labels to fields:** Groups series by time and returns labels or tags as fields. Useful for showing time series with labels in a table where each label key becomes a separate column. +- **Outer join:** Joins many time series/tables by a field. This can be used to outer join multiple time series on the _time_ field to show many time series in one table. +- **Add field from calculation:** This is a powerful transformation that allows you perform many different types of math operations and add the result as a new field. Can be used to calculate the difference between two series or fields and add the result to a new field. Or multiply one field with another and add the result to a new field. + +Learn more about this feature in [Transformations]({{< relref "../panels/transformations/_index.md" >}}). + +## Field options and overrides + +With Grafana 7.0 we are introducing a new, unified data configuration system that powers a consistent UI for setting data options across visualizations as well as making all data display settings data driven and overridable. This new option architecture and UI will make all panels have a consistent set of options and behaviors for attributes like `unit`, `min`, `max`, `thresholds`, `links`, `decimals` or `value mappings`. Not only that but all these options will share a consistent UI for specifying override rules and is extensible for custom panel specific options. + +Up until now the overrides were available only for Graph and Table panel(via Column Styles), but with 7.0 they work consistently across all visualization types and plugins. + +This feature enables even more powerful visualizations and fine grained control over how the data is displayed. + +Learn more about this feature in [Field options]({{< relref "../panels/field-options/_index.md" >}}). + +## Inspect panels and export data to CSV + +{{< docs-imagebox img="/img/docs/v70/panel_edit_export_raw_data.png" max-width="800px" class="docs-image--right" caption="Panel Edit - Export raw data to CSV" >}} + +Another new feature of Grafana 7.0 is the panel inspector. Inspect allows you to view the raw data for any Grafana panel as well as export that data to a CSV file. With Panel inspect you will also be able to perform simple raw data transformations like join, view query stats or detailed execution data. + +Learn more about this feature in [Inspect a panel]({{< relref "../panels/inspect-panel.md" >}}). + +
+ +## Table panel + +Grafana 7.0 comes with a new table panel (and deprecates the old one). This new table panel supports horizontal scrolling and column resize. Paired with the new `Organize fields` transformation detailed above you can reorder, hide & rename columns. This new panel also supports new cell display modes, like showing a bar gauge inside a cell. + +{{< youtube J29wILRh3QQ >}} +
+ +## Auto grid mode for Stat panel and Gauge + +This new 7.0 feature is for the gauge and stat panels. Before, stat and gauge only supported horizontal or vertical stacking: The auto layout mode just selected vertical or horizontal stacking based on the panel dimensions (whatever was highest). But in 7.0 the auto layout for these two panels will allow dynamic grid layouts where Grafana will try to optimize the usage of space and lay out each sub-visualization in a grid. + +{{< youtube noq1rLGvsrU >}} +
+ +## Cloudwatch Logs + +Grafana 7.0 adds logging support to one of our most popular cloud provider data sources. Autocomplete support for Cloudwatch Logs queries is included for improved productivity. There is support for deep linking to the CloudWatch Logs Insights console for log queries, similar to the deep linking feature for Cloudwatch metrics. Since CloudWatch Logs queries can return time series data, for example through the use of the `stats` command, alerting is supported too. + +## Plugins platform + +The [platform for plugins]({{< relref "../developers/plugins/" >}}) has been completely re-imagined and provides ready-made components and tooling to help both inexperienced and experienced developers get up and running more quickly. The tooling, documentation, and new components will improve plugin quality and reduce long-term maintenance. We are already seeing that a high quality plugin with the Grafana look and feel can be written in much fewer lines of code than previously. + +Learn more about developing plugins in the new framework in [Build a plugin]({{< relref "../developers/plugins/_index.md" >}}). + +### Front end plugins platform + +In Grafana 7.0 we are maturing our panel and front-end datasource plugins platform. + +Plugins can use the same React components that the Grafana team uses to build Grafana. Using these components means the Grafana team will support and improve them continually and make your plugin as polished as the rest of Grafana’s UI. The new [`@grafana/ui` components library](https://developers.grafana.com/ui) is documented with Storybook (visual documentation) and is available on NPM. + +The `@grafana/data`, `@grafana/runtime`, `@grafana/e2e packages` (also available via NPM) aim to simplify the way plugins are developed. We want to deliver a set of [reliable APIs](https://grafana.com/docs/grafana/latest/packages_api/) for plugin developers. + +With [@grafana/toolkit](https://www.npmjs.com/package/@grafana/toolkit) we are delivering a simple CLI that helps plugin authors quickly scaffold, develop and test their plugins without worrying about configuration details. A plugin author no longer needs to be a grunt or webpack expert to build their plugin. + +### Support for backend plugins + +Grafana now officially supports [backend plugins]({{< relref "../developers/plugins/backend/_index.md" >}}) and the first type of plugin to be introduced is a backend component for data source plugins. You can optionally add a backend component to your data source plugin and implement the query logic there to automatically enable alerting in Grafana for your plugin. In the 7.0 release, we introduce the [Grafana Plugin SDK for Go]({{< relref "../developers/plugins/backend/grafana-plugin-sdk-for-go.md" >}}) that enables and simplifies building a backend plugin in [Go](https://golang.org/). + +Plugins can be monitored with the new metrics and health check capabilities. The new Resources capability means backend components can return non-time series data like JSON or static resources like images and opens up Grafana for new use cases. + +With this release, we are deprecating the unofficial first version of backend plugins which will be removed in a future release. + +To learn more, start with the [overview]({{< relref "../developers/plugins/backend/_index.md" >}}). Next, in this [tutorial](https://grafana.com/tutorials/build-a-data-source-backend-plugin/) you'll learn how to build a backend for a data source plugin and enable it for use with [Grafana Alerting]({{< relref "../alerting/_index.md" >}}). Make sure to keep an eye out for additional documentation and tutorials that will be published after the Grafana v7.0 release. + +## New tutorials + +To help you get started with Grafana, we’ve launched a brand new tutorials platform. We’ll continue to expand the platform with more tutorials, but here are some of the ones you can try out now: + +- [Grafana fundamentals](https://grafana.com/tutorials/grafana-fundamentals/) +- [Create users and teams](https://grafana.com/tutorials/create-users-and-teams/) +- [Build a panel plugin](https://grafana.com/tutorials/build-a-panel-plugin/) +- [Build a data source plugin](https://grafana.com/tutorials/build-a-data-source-plugin/) + +## Rollup indicator for Metrictank queries + +{{< docs-imagebox img="/img/docs/v70/metrictank_rollup_metadata.png" max-width="800px" class="docs-image--right" caption="Metrictank rollup metadata" >}} + +Depending on the cardinality of the data and the time range MetricTank may return rolled up (aggregated) data. This can be as subtle as potentially only 1 or 2 graphs out of nine being rolled up. The new rollup indicator is visible in the panel title and you can also inspect extensive metadata and stats about the Metrictank query result and its rollups. + +
+ +## Breaking change - PhantomJS removed + +[PhantomJS](https://phantomjs.org/), have been used for rendering images of dashboards and panels and have been included with Grafana since Grafana v2.0. Since then we’ve had a lot of related bugs and security related issues, mainly due to the fact that PhantomJS have struggled with supporting modern web technologies. Throughout the years, maintaining PhantomJS support in Grafana has been a nightmare. Removing support for PhantomJS has been a high priority for the Grafana project and got stressed even more when the PhantomJS maintainer in March 2018 [announced](https://github.com/ariya/phantomjs/issues/15344) the end of the project. + +Since then we have been working towards removing PhantomJS. In October 2019, when Grafana v6.4 was released, we [announced](https://grafana.com/blog/2019/10/02/grafana-v6.4-released/#phantomjs-deprecation) the deprecation of PhantomJS. Grafana v7.0 removes all PhantomJS support which means that Grafana distribution no longer will include a built-in image renderer. + +As a replacement for PhantomJS we’ve developed the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) which is a plugin that runs on the backend and handles rendering panels and dashboards as PNG images using headless Chromium/Chrome. The [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) can either be installed as a Grafana plugin running in its own process side-by-side with Grafana. or runs as an external HTTP service, hosted using Docker or as a standalone application. + +Read more about [Image Rendering]({{< relref "../administration/image_rendering/" >}}) in the documentation for further instructions. + +## Query history in Explore out of beta + +The Query history feature lets you view and interact with the queries that you have previously run in Explore. You can add queries to the Explore query editor, write comments, create and share URL links, star your favorite queries, and much more. Starred queries are displayed in the Starred tab, so it is easier to reuse queries that you run often without typing them from scratch. + +It was released as a beta feature in Grafana 6.7. The feedback has been really positive and it is now out of beta for the 7.0 release. Learn more about query history in [Explore]({{< relref "../explore" >}}). + +## Stackdriver data source supports Service Monitoring + +[Service monitoring](https://cloud.google.com/service-monitoring) in Google Cloud Platform (GCP) enables you to monitor based on Service Level Objectives (SLOs) for your GCP services. The new SLO query builder in the Stackdriver data source allows you to display SLO data in Grafana. Read more about it in the [Stackdriver data source documentation]({{< relref "../datasources/google-cloud-monitoring/_index.md/#slo-service-level-objective-queries" >}}). + +## Time zone support + +You can now override the [time zone]({{< relref "../dashboards/time-range-controls/#dashboard-time-settings" >}}) used to display date and time values in a dashboard. One benefit of this is that you can specify the local time zone of the service or system that you are monitoring which can be helpful when monitoring a system or service that operates across several time zones. + +## Alerting and deep linking for Azure Log Analytics + +The Azure Monitor data source supports multiple Azure services. Log Analytics queries in the data source now have alerting support too (Azure Monitor and Application Insights already had alerting support). + +A new feature is [deep linking from the graph panel to the Log Analytics query editor in the Azure Portal]({{< relref "../datasources/azuremonitor/#deep-linking-from-grafana-panels-to-the-log-analytics-query-editor-in-azure-portal" >}}). Click on a time series in the panel to see a context menu with a link to View in Azure Portal. Clicking that link opens the Azure Log Analytics query editor in the Azure Portal and runs the query from the Grafana panel. + +## Grafana Enterprise + +Grafana Enterprise focuses on solving problems for large companies and Grafana installations. And in Grafana 7.0 we are finally +solving one of the most common problems of using Grafana at scale. + +This includes problems like: + +- There are too many dashboards, how do I find the right one? +- How to find popular dashboards +- How to find dashboards with errors +- How to identify dashboards that are not being used +- Who created or last viewed this dashboard? + +{{< docs-imagebox img="/img/docs/v70/dashboard_insights_users.png" max-width="1024px" caption="Dashboard Insights Users" >}} + +### Usage insights and Presence indicator + +This release includes a series of features that build on our new usage analytics engine. This “Grafana about Grafana” feature will help our large customers get better insight into the behavior and utilization of their users, dashboards, and data sources. The improved [dashboard search]({{< relref "../enterprise/usage-insights/#improved-dashboard-search" >}}) allows you to sort dashboards by usage and errors. When a user opens a dashboard, they will see a [presence indicator]({{< relref "../enterprise/usage-insights/#presence-indicator" >}}) of who else is viewing the same dashboard. And finally [Dashboard insights]({{< relref "../enterprise/usage-insights/#dashboard-insights" >}}) allows you to view recent dashboard usage. + +{{< docs-imagebox img="/img/docs/v70/presence_indicator.jpg" max-width="1024px" caption="Grafana Enterprise - Presence indicator" >}} + +### SAML Role and Team Sync + +SAML support in Grafana Enterprise is improved by adding Role and Team Sync. Read more about how to use these features in the [SAML team sync documentation]({{< relref "../enterprise/saml.md#configure-team-sync" >}}). + +### Okta OAuth Team Sync + +Okta gets its own provider which adds support for Team Sync. Read more about it in the [Okta documentation]({{< relref "../auth/okta.md" >}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading/#upgrading-to-v7-0" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-1.md b/docs/sources/whatsnew/whats-new-in-v7-1.md new file mode 100644 index 0000000..cd49fbf --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-1.md @@ -0,0 +1,112 @@ ++++ +title = "What's New in Grafana v7.1" +description = "Feature and improvement highlights for Grafana v7.1" +keywords = ["grafana", "new", "documentation", "7.1", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-1/"] +weight = -28 +[_build] +list = false ++++ + +# What's new in Grafana v7.1 + +This topic includes the release notes for the Grafana v7.1. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +The main highlights are: + +- [**Flux and InfluxDB 2.x support in the Influx Datasource**]({{< relref "#influx-datasource" >}}) +- [**Query history search**]({{< relref "#query-history-search" >}}) +- [**Unification of Explore modes**]({{< relref "#explore-modes-unified" >}}) +- [**Elasticsearch- link to another data source from Explore**]({{< relref "#internal-links-for-elasticsearch" >}}) +- [**Merge on time transform for the new table panel**]({{< relref "#transformations" >}}) +- [**Stat panel text mode**]({{< relref "#stat-panel-text-mode" >}}) +- [**Time range picker update**]({{< relref "#time-range-picker-update" >}}) +- [**Provisioning of apps**]({{< relref "#provisioning-of-apps" >}}) +- [**Azure Monitor Datasource**]({{< relref "#azure-monitor-datasource" >}}) +- [**Deep linking for Google Cloud Monitoring (formerly named Google Stackdriver) datasource**]({{< relref "#deep-linking-for-google-cloud-monitoring-formerly-named-google-stackdriver-datasource" >}}) +- [**Grafana Enterprise features**]({{< relref "#grafana-enterprise-features" >}}) + - [**Secret management with HashiCorp Vault**]({{< relref "#support-for-hashicorp-vault" >}}) + - [**Monthly schedules in reports**]({{< relref "#support-for-monthly-schedules-in-reports" >}}) + +## Influx data source + +Support for Flux and Influx v2 has been added. The InfluxData blog post, [How to Build Grafana Dashboards with InfluxDB, Flux and InfluxQL](https://www.influxdata.com/blog/how-grafana-dashboard-influxdb-flux-influxql/) explains the changes in depth. + +## Query history search + +In Grafana v 7.1 we are introducing search functionality in Query history. You can search across queries and your comments. It is especially useful in combination with a time filter and data source filter. Read more about [Query history here]({{< relref "../explore/_index.md#query-history" >}}). + +{{< docs-imagebox img="/img/docs/v71/query_history_search.gif" max-width="800px" caption="Query history search" >}} + +## Explore modes unified + +Grafana 7.1 includes a major change to Explore: it removes the query mode selector. + +Many data sources tell Grafana whether a response contains time series data or logs data. Using this information, Explore chooses which visualization to use for that data. This means that you don't need to switch back and forth between Logs and Metrics modes depending on the type of query that you want to make. + +## Internal links for Elasticsearch + +The new internal linking feature for Elasticsearch allows you to link to other data sources from your logs. You can now create links in Elastic configuration that point to another data source (similar to an existing feature in Loki). An example would be using a traceID field from your logs to be able to link to traces in a tracing data source like Jaeger. + +## Transformations + +We have added a new **Merge on time** transform that can combine many time series or table results. Unlike the join transform, this combines the result into one table even when the time values do not align / match. + +The new table panel introduced in 7.0 was missing a few features that the old table panel had. This feature, along with ad hoc filtering, means that the new table panel has achieved feature parity with the old table panel. + +## Ad hoc filtering in the new table panel + +[Ad hoc filtering]({{}}), a way to automatically add filters to queries without having to define template variables is now supported in the new Table panel. + +## Stat panel text mode + +The [stat panel]({{}}) has a new **Text mode** option to control what text to show. + +By default, the Stat panel displays: + +- Just the value for a single series or field. +- Both the value and name for multiple series or fields. + +You can use the Text mode option to control what text the panel renders. If the value is not important, only name and color is, then change the `Text mode` to **Name**. The value will still be used to determine color and is displayed in a tooltip. + +{{< docs-imagebox img="/img/docs/v71/stat-panel-text-modes.png" max-width="1025px" caption="Stat panel" >}} + +## Provisioning of apps + +Grafana v7.1 adds support for provisioning of app plugins. This allows app plugins to be configured and enabled/disabled using configuration files. For more information about provisioning of app, refer to [provisioning plugin]({{}}). + +## Azure Monitor data source + +Support for multiple dimensions has been added to all services in the Azure Monitor datasource. This means you can now group by more than one dimension with time series queries. With the Kusto based services, Log Analytics and Application Insights Analytics, you can also select multiple metrics as well as multiple dimensions. + +Additionally, the Raw Edit mode for Application Insights Analytics has been replaced with a new service in the drop down for the data source and is called Insights Analytics. The new query editor behaves in the same way as Log Analytics. + +## Deep linking for Google Cloud Monitoring (formerly named Google Stackdriver) data source + +A new feature in Grafana 7.1 is [deep linking from Grafana panels to the Metrics Explorer in Google Cloud Console]({{}}). Click on a time series in the panel to see a context menu with a link to View in Metrics explorer in Google Cloud Console. Clicking that link opens the Metrics explorer in the Monitoring Google Cloud Console and runs the query from the Grafana panel there. + +## Time range picker update + +With 7.1 we are updating the dashboard's time range picker to allow time zone selection. You no longer need to go to dashboard settings to change the dashboard's time zone. + +The time zone picker itself also got UX improvements. Now you can search for the timezone using country or city name, time zone abbreviations, or UTC offsets. + +## Grafana Enterprise features + +General features are included in the Grafana Enterprise edition software. + +### Support for HashiCorp Vault + +You can now use HashiCorp Vault to get secrets for configuration and provisioning of Grafana Enterprise. For more information about HashiCorp Vault, refer to [vault]({{}}). + +### Support for monthly schedules in reports + +With Grafana Enterprise 7.1, you can generate reports on a [monthly schedule]({{}}). + +## Upgrading + +See [upgrade notes]({{}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-2.md b/docs/sources/whatsnew/whats-new-in-v7-2.md new file mode 100644 index 0000000..ab6af41 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-2.md @@ -0,0 +1,162 @@ ++++ +title = "What's New in Grafana v7.2" +description = "Feature and improvement highlights for Grafana v7.2" +keywords = ["grafana", "new", "documentation", "7.2", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-2/"] +weight = -29 +[_build] +list = false ++++ + +# What's new in Grafana v7.2 + +This topic includes the release notes for the Grafana v7.2. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +The main highlights are: + +- [**New date formatting options added**]({{< relref "#new=date-formatting-options-added" >}}) +- [**Field options are out of beta!**]({{< relref "#field-options-are-out-of-beta" >}}) + - [**Added table column filters**]({{< relref "#added-table-column-filters" >}}) + - [**New field override selection options**]({{< relref "#new-field-override-selection-options" >}}) +- [**New transformations and enhancements**]({{< relref "#new-transformations-and-enhancements" >}}) +- [**Drag to reorder queries**]({{< relref "#drag-to-reorder-queries" >}}) +- [**Inspect queries in Explore**]({{< relref "#inspect-queries-in-explore" >}}) +- [**$__rate_interval for Prometheus**]({{< relref "#__rate_interval-for-prometheus" >}}) +- [**Toggle parsed log fields**]({{< relref "#toggle-parsed-log-fields" >}}) +- [**Sensitive alert channel settings are now encrypted**]({{< relref "#sensitive-alert-channel-settings-are-now-encrypted" >}}) +- [**Grafana Enterprise features**]({{< relref "#grafana-enterprise-features" >}}) + - [**Report time range**]({{< relref "#report-time-range" >}}) + - [**Organization-wide report settings**]({{< relref "#organization-wide-report-settings" >}}) + - [**Report grid layout**]({{< relref "#report-grid-layout" >}}) +- [**What's new in other parts of the Grafana ecosystem**]({{< relref "#whats-new-in-other-parts-of-the-grafana-ecosystem">}}) + - [**ADX (Azure Data Explorer) plugin**]({{< relref "#adx-azure-data-explorer-plugin">}}) + - [**X-Ray data source plugin**]({{< relref "#x-ray-data-source-plugin" >}}) + +## New date formatting options added + +You can now customize how dates are formatted in Grafana. Custom date formats apply to the time range picker, graphs, and other panel visualizations. + +This screenshot shows both a custom full date format with a 12 hour clock and am/pm suffix. The graph is also showing the same 12-hour clock format and a customized month and day format compared to the Grafana default `MM/DD` format. + +{{< docs-imagebox img="/img/docs/v72/date_formats.png" max-width="800px" caption="Custom date time formats" >}} + +Date formats are set for a Grafana instance by adjusting [server-wide settings]({{< relref "../administration/configuration.md#date_formats" >}}) in the Grafana configuration file. We hope to add org- and user-level settings in the future. + +``` +[date_formats] +full_date = MMM Do, YYYY @ hh:mm:ss a +interval_second = hh:mm:ss a +interval_minute = hh:mm a +interval_hour = MMM DD hh:mm a +interval_day = MMM DD +interval_month = YYYY-MM +interval_year = YYYY +``` + +There is also experimental support to use the browser location and language to dynamically change the current date format for each user. This feature is disabled by default. + +The [Configuration]({{< relref "../administration/configuration.md#date_formats" >}}) topic has been updated as a result of this change. + +## Field options are out of beta! + +After lots of testing and user feedback, we removed the beta label from the configuration options in the Field and Override tabs. This release also includes the following feature enhancements. + +### Added table column filters + +You can now dynamically apply value filters to any table column. This option can be enabled for all columns or one specific column using an override rule. + +{{< docs-imagebox img="/img/docs/v72/table_column_filters.png" max-width="800px" caption="Table column filters" >}} + +[Filter table columns]({{< relref "../panels/visualizations/table/filter-table-columns.md" >}}) has been added as a result of this feature. + +### New field override selection options + +You can now add override rules that use a regex matcher to choose which fields to apply rules to. + +The [Field options]({{< relref "../panels/field-options/_index.md" >}}) content and [Configure specific fields]({{< relref "../panels/field-options/configure-specific-fields.md" >}}) have been updated as a result of these changes. + +## New transformations and enhancements + +Grafana 7.2 includes the following transformation enhancements: + +- A new [Group By]({{< relref "../panels/transformations/types-options.md#group-by">}}) transformation that allows you to group by multiple fields and add any number of aggregations for other fields. +- The [Labels to fields]({{< relref "../panels/transformations/types-options.md#labels-to-fields">}}) transformation now allows you to pick one label and use that as the name of the value field. +- You can drag transformations to reorder them. Remember that transformations are processed in the order they are listed in the UI, so think before you move something! + +{{< docs-imagebox img="/img/docs/v72/transformations.gif" max-width="800px" caption="Group by and reordering of transformations" >}} + +## Drag to reorder queries + +The up and down arrows, which were previously the only way to change query order, have been removed. Instead, there is now a grab icon that allows you to drag and drop queries in a list to change their order. + +{{< docs-imagebox img="/img/docs/v72/drag-queries.gif" max-width="800px" caption="Drag to reorder queries" >}} + +The [Queries]({{< relref "../panels/queries.md" >}}) topic has been updated as a result of this change. + +## Inspect queries in Explore + +You can enjoy all the details query inspector gave you in dashboards now in Explore as well. You can open query inspector tab with the button next to query history. See [Query inspector in Explore]({{< relref "../explore/_index.md#query-inspector" >}}) for more details. + +## \$\_\_rate_interval for Prometheus + +You can now use the new variable `$__rate_interval` in Prometheus for rate functions mainly. `$__rate_interval` in general is one scrape interval larger than `$__interval` but is never smaller than four times the scrape interval (which is 15s by default). See the [Prometheus data source]({{< relref "../datasources/prometheus.md#using-__rate_interval-variable" >}}) for more details. + +## Toggle parsed log fields + +With this awesome contribution from one of our community members, you can now toggle parsed fields in Explore if your logs are structured in `json` or `logfmt`. + +{{< docs-imagebox img="/img/docs/v72/explore-toggle-parsed-fields.gif" max-width="800px" caption="Toggling parsed fields in Explore" >}} + +The [Toggle parsed fields]({{< relref "../explore/_index.md#toggle-detected-fields" >}}) section has been added to [Explore]({{< relref "../explore/_index.md" >}}) as a result of this feature. + +## Sensitive alert channel settings are now encrypted + +Alert notification channels now store sensitive settings and secrets, such as API tokens and passwords, encrypted in the database. + +Please read the [upgrade notes]({{< relref "../installation/upgrading.md#ensure-encryption-of-existing-alert-notification-channel-secrets" >}}) for more information and how to migrate. + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition software. + +### Report and export dashboards in grid layout + +A new layout option is available when rendering reports: the grid layout. With this option, your report uses the panel layout from your dashboard, so that what you see is what you get. Learn more about the [grid layout]({{< relref "../enterprise/reporting.md#layout-and-orientation" >}}) in the documentation. + +The grid layout is also available for the [Export dashboard as PDF]({{< relref "../enterprise/export-pdf.md" >}}) feature. + +{{< docs-imagebox img="/img/docs/enterprise/reports_grid_landscape_preview.png" max-width="500px" class="docs-image--no-shadow" >}} + +### Report time range + +You can now generate a report with a different time range from the dashboard it is based on. This means that you no longer have to apply workarounds, such as copying dashboards or carefully aligning report generation with the end of the month, to generate reports that cover the period you want. + +For more information, refer to [Reports time range]({{< relref "../enterprise/reporting.md#report-time-range" >}}). + +### Organization-wide report settings + +You can now configure organization-wide report settings, such as report branding, in the Settings tab on the Reporting page. Settings are applied to all the reports of your current organization. + +{{< docs-imagebox img="/img/docs/enterprise/reports_settings.png" max-width="500px" class="docs-image--no-shadow" caption="Reports settings" >}} + +For more information, refer to [Reports settings]({{< relref "../enterprise/reporting.md#reports-settings" >}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## What's new in other parts of the Grafana ecosystem + +### ADX (Azure Data Explorer) plugin + +In collaboration with Microsoft, we have improved the usability of our ADX datasource plugin by adding a visual query builder. The goal is to make it easier for users, regardless of their previous knowledge of writing KQL (Kusto Query Language) queries, to query and visualize their data. + +{{< docs-imagebox img="/img/docs/v72/adx-ds.png" max-width="800px" caption="ADX visual query builder" >}} + +### X-Ray data source plugin + +We are pleased to announce our very first version of our data source plugin for AWS X-Ray. You can use this plugin to visualize traces, look at analytics tables, and see insight summaries. For more information, refer to the [X-Ray data source](https://grafana.com/grafana/plugins/grafana-x-ray-datasource) plugin page. diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md new file mode 100644 index 0000000..3a8d9a3 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -0,0 +1,161 @@ ++++ +title = "What's New in Grafana v7.3" +description = "Feature and improvement highlights for Grafana v7.3" +keywords = ["grafana", "new", "documentation", "7.3", "release notes"] +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-3/"] +weight = -30 +[_build] +list = false ++++ + +# What's new in Grafana v7.3 + +This topic includes the release notes for Grafana v7.3. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) or the [Patch release notes](#patch-release-notes). + +The main highlights are: + +- [**Google Cloud Monitoring:** Out of the box dashboards]({{< relref "#cloud-monitoring-out-of-the-box-dashboards" >}}) +- [**Shorten URL for dashboards and Explore**]({{< relref "#shorten-url-for-dashboards-and-explore" >}}) +- [**Table improvements and new image cell mode**]({{< relref "#table-improvements-and-new-image-cell-mode" >}}) +- [**New color scheme option**]({{< relref "#new-color-scheme-option" >}}) +- [**SigV4 Authentication for Amazon Elasticsearch Service**]({{< relref "#sigv4-authentication-for-aws-users" >}}) +- [**CSV exports for Excel**]({{< relref "#csv-exports-for-excel" >}}) + +## Table improvements and new image cell mode + +The table has been updated with improved hover behavior for cells that have longer content than what fits the current column width. As you can see +in the animated gif below the cell will automatically expand to show you full content of the cell. + +{{< figure src="/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} + +Another new feature that can be seen in the image above is the new image cell display mode. If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. + +## New color scheme option + +{{< figure src="/img/docs/v73/color_scheme_dropdown.png" max-width="450px" caption="Color scheme" class="pull-right" >}} + +A new standard field [color scheme]({{< relref "../panels/field-options/standard-field-options.md#color-scheme" >}}) option has been added. This new option will provide a unified way for all new panels to specify how colors should be assigned. + +- **Single color**: Specifies a single color. Useful in an override rule. +- **From thresholds**: Informs Grafana to take color from the matching threshold. +- **Classic palette**: Assigns a color by looking up a color in a palette by series index. Useful for Graphs and pie charts, and other categorical data visualizations in Grafana. +- **Green-Yellow-Red (by value)**: A continuous color scheme where Grafana will interpolate a color based on the value assigned to the green, yellow, and red components. The value must be within the min & max limits. +- **Blue-Yellow-Red (by value)**: Same as above but different colors. +- **Blues (by value)**: Same as above but color scheme go from panel background to blue. + +
+ +As you can see this adds new continuous color schemes where Grafana will interpolate colors. A great use of these new color schemes is the table panel where you can color the background and get a heatmap like effect. + +{{< figure src="/img/docs/v73/table_color_scheme.png" max-width="900px" caption="table color scheme" >}} + +Another thing to highlight is that all these new color schemes are theme aware and adapt to the current theme. For example here is how the new monochrome color scheme look like in the light theme: + +{{< figure src="/img/docs/v73/table_color_scheme_mono_light.png" max-width="900px" caption="table color monochrome scheme" >}} + +As this new option is a standard field option it works in every panel. Here is another example from the [Bar Gauge]({{< relref "../panels/visualizations/bar-gauge-panel.md" >}}) panel. + +{{< figure src="/img/docs/v73/bar_gauge_gradient_color_scheme.png" max-width="900px" caption="bar gauge color scheme" >}} + +## CSV exports for Excel + +In v7.0, we introduced a new table panel and inspect mode with Download CSV enabled. However, CSV export to Excel was removed. Due to a large number of inquiries and requests, this [community contribution from tomdaly](https://github.com/grafana/grafana/pull/27284) brought the feature back. + +For more information, refer to [Download raw query results as CSV]({{< relref "../panels/inspect-panel/#download-raw-query-results-as-csv" >}}) in the Grafana documentation. + +## Google Cloud monitoring out-of-the-box dashboards + +The updated Google Cloud monitoring data source is shipped with pre-configured dashboards for five of the most popular Google Cloud Platform (GCP) services: + +- BigQuery +- Cloud Load Balancing +- Cloud SQL +- Google Compute Engine `GCE` +- Google Kubernetes Engine `GKE` + +To import the pre-configured dashboards, go to the configuration page of your Google Cloud Monitoring data source and click on the `Dashboards` tab. Click `Import` for the dashboard you would like to use. To customize the dashboard, we recommend to save the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +For more details, see the [Google Cloud Monitoring docs]({{}}) + +## Shorten URL for dashboards and Explore + +This is an amazing new feature that was created in cooperation with one of our community members. The new share shortened link capability allows you to create smaller and simpler URLs of the format `/goto/:uid` instead of using longer URLs that can contain complex query parameters. In Explore, you can create a shortened link by clicking on the share button in Explore toolbar. In the dashboards, a shortened url option is available through the share panel or dashboard button. + +## SigV4 authentication for AWS users + +You can now configure your Elasticsearch data source to access your Amazon Elasticsearch Service domain directly from Grafana. + +For more details, refer to the [Elasticsearch docs]({{}}). + +## Chaining pipeline aggregation in Elasticsearch + +Thanks to a contribution from a community member, it's now possible to chain multiple pipeline aggregations together and use the results of one pipeline aggregation as the input of another. This unleashes the full power of Elasticsearch's pipeline aggregations in Grafana, allowing users to perform high order derivatives or use a pipeline aggregation result as a variable for a Bucket Script Aggregation. + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition software. + +### Auditing + +Auditing tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. Grafana logs events (as JSON) to file or directly to [loki](/oss/loki/). + +Example of a login event: + +```json +{ + "timestamp": "2020-10-22T10:18:00.838094347Z", + "user": { + "userId": 1, + "orgId": 1, + "isAnonymous": false + }, + "action": "login-grafana", + "result": { + "statusType": "success", + "statusCode": 200 + }, + "requestUri": "/login", + "ipAddress": "127.0.0.1:41324", + "userAgent": "Chrome/86.0.4240.111", + "grafanaVersion": "7.3.0" +} +``` + +For more details, see the [Auditing docs]({{}}). + +### Data source usage insights + +Data source usage insights allows you to gain insight into how a data source is being used and how well it works. There is a new tab in the data source settings page called insights that will show you information about how the data source has been used in the past 30 days. + +Insights: + +- Queries per day +- Errors per day +- Average load duration per day (ms) + +### SAML single logout + +SAML’s single logout (SLO) capability allows users to log out from all applications associated with the current identity provider (IdP) session established via SAML SSO. For more information, refer to the [docs]({{}}). + +### SAML IdP-initiated single sign on + +IdP-initiated single sign on (SSO) allows the user to log in directly from the SAML identity provider (IdP). It is disabled by default for security reasons. For more information, refer to the [docs]({{}}). + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. + +## Patch release notes + +- [Grafana 7.3.0 release notes]({{< relref "../release-notes/release-notes-7-3-0.md" >}}) +- [Grafana 7.3.1 release notes]({{< relref "../release-notes/release-notes-7-3-1.md" >}}) +- [Grafana 7.3.2 release notes]({{< relref "../release-notes/release-notes-7-3-2.md" >}}) +- [Grafana 7.3.3 release notes]({{< relref "../release-notes/release-notes-7-3-3.md" >}}) +- [Grafana 7.3.4 release notes]({{< relref "../release-notes/release-notes-7-3-4.md" >}}) +- [Grafana 7.3.5 release notes]({{< relref "../release-notes/release-notes-7-3-5.md" >}}) +- [Grafana 7.3.6 release notes]({{< relref "../release-notes/release-notes-7-3-6.md" >}}) +- [Grafana 7.3.7 release notes]({{< relref "../release-notes/release-notes-7-3-7.md" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v7-4.md b/docs/sources/whatsnew/whats-new-in-v7-4.md new file mode 100644 index 0000000..b0dd1e4 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-4.md @@ -0,0 +1,243 @@ ++++ +title = "What's New in Grafana v7.4" +description = "Feature and improvement highlights for Grafana v7.4" +keywords = ["grafana", "new", "documentation", "7.4", "release notes"] +weight = -31 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-4/"] +[_build] +list = false ++++ + +# What's new in Grafana v7.4 + +This topic includes the release notes for Grafana v7.4. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +Check out the [New Features in 7.4](https://play.grafana.org/d/nP8rcffGk/1-new-features-in-v7-4?orgId=1) dashboard on Grafana Play! + +## Grafana OSS features + +These features are included in the Grafana open source edition. + +### Time series panel visualization (Beta) + +Grafana 7.4 adds a beta version of the next-gen graph visualization. The new graph panel, the _Time series_ visualization, is high-performance visualization based on the uPlot library. This new graph visualization uses the new panel architecture introduced in Grafana 7.0 and integrates with field options, overrides, and transformations. + +The Time series beta panel implements the majority of the functionalities available in the current Graph panel. Our plan is to have close to full coverage of the features in Grafana 8.0, coming later this year. + +Apart from major performance improvements, the new Time series panel implements new features like line interpolation modes, support for more than two Y-axes, soft min and max axis limits, automatic points display based on data density, and gradient fill modes. + +{{< figure src="/img/docs/v74/timeseries_panel.png" max-width="900px" caption="Time series panel" >}} + +The following documentation topics were added for this feature: + +- [Time series panel]({{< relref "../panels/visualizations/time-series/_index.md" >}}) +- [Graph time series as lines]({{< relref "../panels/visualizations/time-series/graph-time-series-as-lines.md" >}}) +- [Graph time series as bars]({{< relref "../panels/visualizations/time-series/graph-time-series-as-bars.md" >}}) +- [Graph time series as points]({{< relref "../panels/visualizations/time-series/graph-time-series-as-points" >}}) +- [Change axis display]({{< relref "../panels/visualizations/time-series/change-axis-display.md" >}}) + +### Node graph panel visualization (Beta) + +_Node graph_ is a new panel type that can visualize directed graphs or network in dashboards, but also in Explore. It uses directed force layout to effectively position the nodes so it can help with displaying complex infrastructure maps, hierarchies, or execution diagrams. + +All the information and stats shown in the Node graph beta are driven by the data provided in the response from the data source. The first data source that is using this panel is AWS X-Ray, for displaying their service map data. + +For more details about how to use the X-Ray service map feature, see the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource). + +For more information, refer to [Node graph panel]({{< relref "../panels/visualizations/node-graph.md" >}}). + +### New transformations + +The following transformations were added in Grafana 7.4. + +#### Sort by transformation + +The _Sort by_ transformation allows you to sort data before sending it to the visualization. + +For more information, refer to [Sort by]({{< relref "../panels/transformations/types-options.md#sort-by" >}}) in [Transformation types and options]({{< relref "../panels/transformations/types-options.md" >}}). + +#### Filter data by value transform + +The new _Filter data by value_ transformation allows you to filter your data directly in Grafana and remove some data points from your query result. + +This transformation is very useful if your data source does not natively filter by values. You might also use this to narrow values to display if you are using a shared query. + +For more information, refer to [Filter data by value]({{< relref "../panels/transformations/types-options.md#filter-data-by-value" >}}) in [Transformation types and options]({{< relref "../panels/transformations/types-options.md" >}}). + +### New override option + +On the Overrides tab, you can now set properties for fields returned by a specific query. + +For more information, refer to [Add a field override]({{< relref "../panels/field-options/configure-specific-fields.md#add-a-field-override" >}}). + +### Exemplar support + +Grafana graphs now support Prometheus _exemplars_. They are displayed as diamonds in the graph visualization. + +> **Note:** Support for exemplars will be added in version Prometheus 2.25+. + +{{< figure src="/img/docs/v74/exemplars.png" max-width="900px" caption="Exemplar example" >}} + +For more information, refer to [Exemplars]({{< relref "../datasources/prometheus.md#exemplars" >}}). + +### Trace to logs + +You can now navigate from a span in a trace view directly to logs relevant for that span. This feature is available for the Tempo, Jaeger, and Zipkin data sources. + +The following topics were updated as a result of this feature: + +- [Explore]({{< relref "../explore/trace-integration.md" >}}) +- [Jaeger]({{< relref "../datasources/jaeger.md#trace-to-logs" >}}) +- [Tempo]({{< relref "../datasources/tempo.md#trace-to-logs" >}}) +- [Zipkin]({{< relref "../datasources/zipkin.md#trace-to-logs" >}}) + +### Server-side expressions + +_Server-side expressions_ is an experimental feature that allows you to manipulate data returned from backend data source queries. Expressions allow you to manipulate data with math and other operations when the data source is a backend data source or a **--Mixed--** data source. + +The main use case is for [multi-dimensional]({{< relref "../basics/timeseries-dimensions.md" >}}) data sources used with the upcoming next generation alerting, but expressions can be used with backend data sources and visualization as well. + +> **Note:** Queries built with this feature might break with minor version upgrades until Grafana 8 is released. This feature does not work with the current Grafana alerting. + +For more information, refer to [Expressions]({{< relref "../panels/expressions.md" >}}). [Queries]({{< relref "../panels/queries.md" >}}) was also updated as a result of this feature. + +### Alert notification query label interpolation + +You can now provide detailed information to alert notification recipients by injecting alert label data as template variables into an alert notification. Labels that exist from the evaluation of the alert query can be used in the alert rule name and in the alert notification message fields using the `${Label}` syntax. The alert label data is automatically injected into the notification fields when the alert is in the alerting state. When there are multiple unique values for the same label, the values are comma-separated. + +{{< figure src="/img/docs/alerting/alert-notification-template-7-4.png" max-width="700px" caption="Variable support in alert notifications" >}} + +For more information, refer to the [alert notification docs]({{< relref "../alerting/notifications.md#notification-templating" >}}). + +### Content security policy support + +We have added support for [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), a layer of security that helps detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. + +CSP support is disabled by default, to enable it you must set `content_security_policy = true` in the Grafana configuration. If enabling it, you should also review, and potentially tweak, the CSP header template, via the configuration setting `content_security_policy_template`. + +You can lock down what can be done in the frontend code. Lock down what can be loaded, what JavaScript is executed. Not compatible with some plugins. + +[content_security_policy]({{< relref "../administration/configuration.md#content_security_policy" >}}) and [content_security_policy_template]({{< relref "../administration/configuration.md#content_security_policy_template" >}}) were added to [Configuration]({{< relref "../administration/configuration.md" >}}) as a result of this change. + +### Hide users in UI + +You can now use the `hidden_users` configuration setting to hide specific users in the UI. For example, this feature can be used to hide users that are used for automation purposes. + +[Configuration]({{< relref "../administration/configuration.md#hidden_users" >}}) has been updated for this feature. + +### Elasticsearch data source updates + +Grafana 7.4 includes the following enhancements + +- Added support for serial differencing pipeline aggregation. +- Added support for moving function pipeline aggregation. +- Added support to the terms aggregation for ordering by percentiles and extended stats. +- Updated date histogram auto interval handling for alert queries. + +> **Note:** We have deprecated browser access mode. It will be removed in a future release. + +For more information, refer to the [Elasticsearch docs]({{}}). + +### Azure Monitor updates + +The Azure Monitor query type was renamed to Metrics and Azure Logs Analytics was renamed to Logs to match the service names in Azure and align the concepts with the rest of Grafana. + +[Azure Monitor]({{< relref "../datasources/azuremonitor.md" >}}) was updated to reflect this change. + +### MQL support added for Google Cloud Monitoring + +You can now use Monitoring Query Language (MQL) for querying time-series data. MQL provides an expressive, text-based interface to retrieve, filter, and manipulate time-series data. + +Unlike the visual query builder, MQL allows you to control the time range and period of output data, create new labels to aggregate data, compute the ratio of current values to past values, and so on. + +MQL uses a set of operations and functions. Operations are linked together using the common pipe mechanism, where the output of one operation becomes the input to the next. Linking operations makes it possible to build up complex queries incrementally. + +Once query type Metrics is selected in the Cloud Monitoring query editor, you can toggle between the editor modes for visual query builder and MQL. For more information, refer to the [Google Cloud Monitoring docs]({{< relref "../datasources/google-cloud-monitoring/_index.md#out-of-the-box-dashboards" >}}). + +Many thanks to [mtanda](https://github.com/mtanda) this contribution! + +## Curated dashboards for Google Cloud Monitoring + +Google Cloud Monitoring data source ships with pre-configured dashboards for some of the most popular GCP services. These curated dashboards are based on similar dashboards in the GCP dashboard samples repository. In this release, we have expanded the set of pre-configured dashboards. + +{{< docs-imagebox img="/img/docs/google-cloud-monitoring/curated-dashboards-7-4.png" max-width= "650px" >}} + +If you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released. + +For more information, refer to the [Google Cloud Monitoring docs]({{< relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards" >}}). + +### Query Editor Help + +The feature previously referred to as DataSource Start Pages or Cheat Sheets has been renamed to Query Editor Help, and is now supported in panel query editors (depending on the data source), as well as in Explore. + +[Queries]({{< relref "../panels/queries.md" >}}) was updated as a result of this feature. + +For more information on adding a query editor help component to your plugin, refer to [Add a query editor help component]({{< relref "../developers/plugins/add-query-editor-help.md" >}}). + +### Variable inspector + +The variables list has an additional column indicating whether variables are referenced in queries and panel names or not. The dependencies graph provides an easy way to check variable dependencies. You can click on a variable name within the graph to make updates to the variable as needed. + +For more information, refer to [Inspect variables and their dependencies]({{< relref "../variables/inspect-variable.md">}}). + +## Grafana Enterprise features + +These features are included in the Grafana Enterprise edition. + +### Licensing changes + +When determining a user’s role for billing purposes, a user who has the ability to edit and save dashboards is considered an Editor. This includes any user who is an Editor or Admin at the Org level, and who has granted Admin or Edit permissions via [Dashboard and folder permissions]({{< relref "../permissions/dashboard-folder-permissions.md">}}). + +After the number of Viewers or Editors has reached its license limit, only Admins will see a banner in Grafana indicating that the license limit has been reached. Previously, all users saw the banner. + +Grafana Enterprise license tokens update automatically on a daily basis, which means you no longer need to manually update your license, and the process for adding additional users to a license is smoother than it was before. + +Refer to [Licensing restrictions]({{< relref "../enterprise/license/license-restrictions.md" >}}) for more information. + +### Export usage insights to Loki + +You can now export usage insights logs to Loki and query them from Grafana. Usage insights logs include dashboard visits, data source views, queries and errors, and more. + +For more information, refer to [Export logs of usage insights]({{< relref "../enterprise/usage-insights/export-logs.md" >}}). + +### New audit log events + +New log out events are logged based on when a token expires or is revoked, as well as [SAML Single Logout]({{< relref "../enterprise/saml.md#single-logout" >}}). A `tokenId` field was added to all audit logs to help understand which session was logged out of. + +Also, a counter for audit log writing actions with status (success / failure) and logger (loki / file / console) labels was added. + +[Auditing]({{< relref "../enterprise/auditing.md" >}}) was updated to reflect these changes. + +### Reports support Unicode + +You can now select a font, other than the default, for Unicode-based scripts. As a result, an automatically generated PDF of a dashboard, which contains for example Chinese or Cyrillic text, can display them. Because the size of a report increases as additional fonts are added, this feature is not on by default. + +[Reporting]({{< relref "../enterprise/reporting.md#rendering-configuration" >}}) was updated as a result of this change. + +### Request security + +Request security introduces ways to limit requests from the Grafana server, and it targets requests that are generated by users. + +For more information, refer to [Request security]({{< relref "../enterprise/request-security.md" >}}). + +## Breaking changes + +The following Grafana 7.4 changes might break previous functionality. + +### Plugin compatibility + +We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require a small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an $onInit function. For more details on how to migrate AngularJS code open the migration guide and search for pre-assigning bindings. + +In order not to break all angular panel plugins and data sources we have some custom angular inject behavior that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. + +### Fixes Constant variable persistence confusion + +In order to minimize the confusion with Constant variable usage, we've removed the ability to make Constant variables visible. This change will also migrate all existing visible Constant variables to Textbox variables because which we think this is a more appropriate type of variable for this use case. + +## Upgrading + +See [upgrade notes]({{< relref "../installation/upgrading.md" >}}). + +## Changelog + +Check out [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) for a complete list of new features, changes, and bug fixes. diff --git a/docs/sources/whatsnew/whats-new-in-v7-5.md b/docs/sources/whatsnew/whats-new-in-v7-5.md new file mode 100644 index 0000000..6ce2acc --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v7-5.md @@ -0,0 +1,162 @@ ++++ +title = "What's new in Grafana v7.5" +description = "Feature and improvement highlights for Grafana v7.5" +keywords = ["grafana", "new", "documentation", "7.5", "release notes"] +weight = -32 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-5/"] +[_build] +list = false ++++ + +# What’s new in Grafana v7.5 + +This topic includes the release notes for Grafana v7.5. For all details, read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## Grafana OSS features + +These features are included in the Grafana open source edition. + +### Pie chart panel visualization (beta) + +Grafana 7.5 adds a beta version of the next-generation pie chart panel. + +![Pie chart panel](/img/docs/pie-chart-panel/pie-chart-panel-7-5.png) + +For more information, refer to [Pie chart panel]({{< relref "../panels/visualizations/pie-chart-panel.md" >}}). + +### Alerting for Loki + +Grafana 7.5 comes with alerting support for Loki. With LogQL you can wrap a log query with the functions that allow for creating metrics out of the logs, such as "rate()". Metric queries can then be used to calculate things such as the rate of error messages. [When combined with log parsers](https://www.youtube.com/watch?v=H9z2V0Ib1q0), they can be used to calculate metrics from a value within the log line, such latency or request size. + +With alerting support for Loki, you can now create alerts on Loki metrics queries. + +[Alerting]({{< relref "../alerting/_index.md" >}}) was updated as a result of this change. + +![Loki alerting](/img/docs/alerting/alerting-for-loki-7-5.png) + +### Loki label browser + +A new Loki logs browser lets you construct the queries step by step: you choose labels that you like to consider, such as "job", then you select the values that you like, such as "my-app1". Note that you can select values from more than one label, and that they get facetted. This means only possible label combinations are selectable. When you're done, you can run the query either as a logs or a metrics query (a metrics query returns the log volume in a chart). + +### Changed default HTTP method for new Prometheus data sources + +For new Prometheus data sources, we have changed the default HTTP method to POST. POST allows for much larger query bodies than using the GET method. This is necessary when sending queries from graphs with a lot of targets, for example, many hosts in a dashboard variable. The POST method also makes the Query Inspector data easier to read since the query is in plain text whereas the GET query is URL encoded. + +> **Note:** This is not going to affect provisioned data sources or already created data sources. + +[Prometheus data source]({{< relref "../datasources/prometheus.md" >}}) was updated as a result of this change. + +### Word highlighting for Elasticsearch + +When searching for text in Elasticsearch logs, matching words in the log line returned by the query are now highlighted. + +![Elastic logs highlighting](/img/docs/elasticsearch/elastic-word-highlighting-7-5.png) + +### Better format definition for trace data + +In Grafana 7.5, we changed how data for the trace view is sent from the data source. The required data frame has a clear format, which is more aligned with how data is generally represented in Grafana. This makes it easier for third-party developers to implement data sources leveraging the trace view. + +For more information, refer to [trace data API docs]({{< relref "../explore/trace-integration.md#data-api" >}}). + +### Paste in SSL certs for Postgres data source + +Previously, when users wanted to configure the Postgres data source to connect with SSL certification, they needed to put the certification on the server, and configure the data source with file path. + +Instead of the file path, users can now paste the SSL certification content in the UI. This allows them to configure the certification even when they do not have access to the server. + +> **Note:** It remains as limitation for the hosted Grafana, because the user doesn't have access to the server configuration. + +[Postgres data source]({{< relref "../datasources/postgres.md" >}}) and [Provisioning]({{< relref "../administration/provisioning.md" >}}) were updated as a result of this change. + +### Deprecation notice for some Azure Monitor queries + +In the upcoming Grafana 8.0 release, Application Insights and Insights Analytics query types within the Azure Monitor data source will be deprecated and be made read-only in favor of querying Application Insights from Metrics and Logs. + +Grafana 7.5 includes a deprecation notice for these queries, and some documentation to help users prepare for the upcoming changes. + +For more information, refer to [Deprecating Application Insights and Insights Analytics]({{< relref "../datasources/azuremonitor.md#deprecating-application-insights-and-insights-analytics" >}}). + +### Cloudwatch data source enhancements + +- Support for region eu-south-1 has been added to the CloudWatch data source. New metrics have also been added to the namespaces AWS/Timestream, AWS/RDS (RDS Proxy metrics), AWS/NetworkFirewall, AWS/GroundStation, and AWS/DDoSProtection. Many thanks to [relvira](https://github.com/relvira), [ilyastoli](https://github.com/ilyastoli), and [rubycut](https://github.com/rubycut) for contributing! +- Added a page limit to the List Metrics API call to improve speed and reduce memory consumption. You can change this limit by entering a higher value in [list_metrics_page_limit]({{< relref "../administration/configuration.md#list-metrics-page-limit" >}}) in the Grafana configuration file. +- You can now enable or disable authentication providers and assume a role other than default by changing the [allowed_auth_providers]({{< relref "../administration/configuration.md#allowed-auth-providers" >}}) and [assume_role_enabled]({{< relref "../administration/configuration.md#assume-role-enabled" >}}) options in the Grafana configuration file. By default, the allowed authentication providers are _AWS SDK Default_, _Access && secret key_, and _Credentials File_, and role is _Assume role (ARN)_. +- You can now specify a custom endpoint in the CloudWatch data source configuration page. This field is optional, and if it is left empty, then the default endpoint for CloudWatch is used. By specifying a regional endpoint, you can reduce request latency. + + [AWS Cloudwatch data source]({{< relref "../datasources/cloudwatch.md#endpoint" >}}) was updated as a result of this change. + +### Increased API limit for CloudMonitoring Services + +In previous versions, when querying metrics for Service Level Objectives (SLOs) in the CloudMonitoring data source, only the first 100 services were listed in the **Service** field list. To overcome this issue, the API limit for listing services has been increased to 1000. + +### Tempo as a backend data source + +We have converted Tempo to a backend data source and dropped support for tempo-query's (Jaeger) response. To configure it, you can now point to the port that is set in the Tempo configuration file. + +```yaml +server: + http_listen_port: 3101 +``` + +[Azure Monitor data source]({{< relref "../datasources/azuremonitor.md" >}}) was updated as a result of this change. + +## Enterprise features + +These features are included in the Grafana Enterprise edition. + +### Query caching + +When caching is enabled, Grafana temporarily stores the results of data source queries. When you or another user submit the same query again, the results return from the cache instead of from the data source (such as Splunk or ServiceNow). + +Query caching advantages: +- Faster dashboard load times, especially for popular dashboards. +- Reduced API costs. +- Reduced likelihood that APIs will rate-limit or throttle requests. + +Caching currently works for all backend data sources. You can enable the cache globally or per data source, and you can configure the cache duration per data source. The cache is currently in-memory. + +For more information, refer to [Query caching]({{< relref "../enterprise/query-caching.md" >}}). + +### Use template variable in reports + +If you have created dashboards with template variables, then you can choose which values are selected when rendering a report. This makes it easier to tailor reports to their audience or generate multiple reports from the same dashboard. + +Enable this feature in configuration settings using the `templateVariables` flag. + +For more information, refer to [Reporting]({{< relref "../enterprise/reporting.md#choose-template-variables" >}}). + +### Active user limits + +If a Grafana instance has exceeded its licensed number of active users, then non-active users who try to sign in to Grafana will be prevented from doing so. Active users are users who have logged in to Grafana in the past 30 days. The total number of users registered in Grafana does not affect this rule. This enforcement is applied separately for Viewers and for Editor/Admins, so if you reach your active Viewer limit, new Editor/Admins will still be able to sign in. This rule also includes a 10% buffer, meaning that you need to exceed your limit by 10% before users are prevented from signing in. + +Here is an example: + +A Grafana Enterprise instance includes 100 Viewers and 50 Editor/Admins. Over the course of the last 30 days, 110 Viewers and 20 Editor/Admins have signed in to Grafana. + +All of the Viewers who have signed in over the past 30 days will retain the ability to sign in. + +When a previously-inactive Viewer (someone who has not signed in over the past 30 days) tries to sign in, they will see a message and be prevented from signing in until the number of active users dips back below 110. New Editor/Admins are not affected by this; they can continue to sign in until the number of active Editors/Admins reaches 55. + +If you try to sign in to a fourth device or browser, then you will be prevented from doing so; the limit of concurrent sessions is three. + +If you sign in to a fourth device or browser, then you will be signed out of the session that is least current. +Concurrent session limits +Each Grafana Enterprise user will be limited to three concurrent user sessions. When a user opens a fourth session, then the longest-inactive session will be automatically signed out. + +A new session is created when you sign in to Grafana from a different device or a different browser. Multiple windows and tabs in the same browser are all part of the same session, so having many Grafana tabs open will not cause any issues. + +For more information on Grafana Enterprise licensing and restrictions, refer to [License restrictions]({{< relref "../enterprise/license/license-restrictions.md" >}}). + +## Breaking changes + +There are no known breaking changes in this release. + +## Updated configuration + +``` +[server] +read_timeout = 0 +``` + +Sets the maximum time using a duration format (5s/5m/5ms) before timing out read of an incoming request and closing idle connections. +`0` means there is no timeout for reading the request. diff --git a/e2e/kill-server b/e2e/kill-server new file mode 100755 index 0000000..70bf9f9 --- /dev/null +++ b/e2e/kill-server @@ -0,0 +1,11 @@ +#!/bin/bash + +. e2e/variables + +if [ -f "$PIDFILE" ]; then + echo -e "Found pidfile, killing running grafana-server" + kill -9 `cat $PIDFILE` + rm $PIDFILE +fi + +rm -rf e2e/tmp diff --git a/e2e/run-suite b/e2e/run-suite new file mode 100755 index 0000000..267c06a --- /dev/null +++ b/e2e/run-suite @@ -0,0 +1,32 @@ +#!/bin/bash +set -xeo pipefail + +. e2e/variables + +HOST=${HOST:-$DEFAULT_HOST} +PORT=${PORT:-$DEFAULT_PORT} + +echo -e "Starting Cypress scenarios" + +CMD="start" +PARAMS="" +SLOWMO=0 +URL=${BASE_URL:-"http://$HOST:$PORT"} +SUITE=${SUITE:-$DEFAULT_SUITE} + +if [ "$1" == "debug" ]; then + echo -e "Debug mode" + SLOWMO=1 + PARAMS="--headed --no-exit" +fi + +if [ "$1" == "dev" ]; then + echo "Dev mode" + CMD="open" +fi + +cd packages/grafana-e2e + +yarn $CMD --env BASE_URL=$URL,SLOWMO=$SLOWMO \ + --config integrationFolder=../../e2e/$SUITE/specs,screenshotsFolder=../../e2e/$SUITE/screenshots,videosFolder=../../e2e/$SUITE/videos,fileServerFolder=./cypress,viewportWidth=1920,viewportHeight=1080,trashAssetsBeforeRuns=false \ + $PARAMS diff --git a/e2e/shared/smokeTestScenario.ts b/e2e/shared/smokeTestScenario.ts new file mode 100644 index 0000000..5b7171c --- /dev/null +++ b/e2e/shared/smokeTestScenario.ts @@ -0,0 +1,33 @@ +import { e2e } from '@grafana/e2e'; + +export const smokeTestScenario = { + describeName: 'Smoke tests', + itName: 'Login scenario, create test data source, dashboard, panel, and export scenario', + addScenarioDataSource: true, + addScenarioDashBoard: true, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard(); + e2e.components.PageToolbar.item('Add panel').click(); + e2e.pages.AddDashboard.addNewPanel().click(); + + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + }); + + // Make sure the graph renders via checking legend + e2e.components.Panels.Visualization.Graph.Legend.legendItemAlias('A-series').should('be.visible'); + + // Expand options section + e2e.components.Panels.Visualization.Graph.VisualizationTab.legendSection().click(); + + // Disable legend + e2e.components.Panels.Visualization.Graph.Legend.showLegendSwitch().click(); + + e2e.components.Panels.Visualization.Graph.Legend.legendItemAlias('A-series').should('not.exist'); + }, +}; diff --git a/e2e/start-and-run-suite b/e2e/start-and-run-suite new file mode 100755 index 0000000..9aac995 --- /dev/null +++ b/e2e/start-and-run-suite @@ -0,0 +1,13 @@ +#!/bin/bash + +. e2e/variables + +if [ "$BASE_URL" != "" ]; then + echo -e "BASE_URL set, skipping starting server" +else + # Start it in the background + ./e2e/start-server 2>&1 > e2e/server.log & + ./e2e/wait-for-grafana +fi + +./e2e/run-suite "$@" diff --git a/e2e/start-server b/e2e/start-server new file mode 100755 index 0000000..24105ae --- /dev/null +++ b/e2e/start-server @@ -0,0 +1,59 @@ +#!/bin/bash +set -eo pipefail + +. e2e/variables + +PORT=${PORT:-$DEFAULT_PORT} +PACKAGE_FILE=${PACKAGE_FILE:-$DEFAULT_PACKAGE_FILE} + +./e2e/kill-server + +mkdir $RUNDIR + +echo -e "Copying grafana backend files to temp dir..." + +# Expand any wildcards +pkgs=(${PACKAGE_FILE}) +pkg=${pkgs[0]} +if [[ -f ${pkg} ]]; then + echo "Found package tar file ${pkg}, extracting..." + tar zxf ${pkg} -C $RUNDIR + mv $RUNDIR/grafana-*/* $RUNDIR +else + echo "Couldn't find package ${PACKAGE_FILE} - copying local dev files" + + if [[ ! -f bin/grafana-server ]]; then + echo bin/grafana-server missing + exit 1 + fi + + cp -r ./bin $RUNDIR + cp -r ./public $RUNDIR + cp -r ./tools $RUNDIR + + mkdir $RUNDIR/conf + mkdir $PROV_DIR + mkdir $PROV_DIR/datasources + mkdir $PROV_DIR/dashboards + + cp ./conf/defaults.ini $RUNDIR/conf/defaults.ini +fi + +echo -e "Copy provisioning setup from devenv" + +cp devenv/datasources.yaml $PROV_DIR/datasources +cp devenv/dashboards.yaml $PROV_DIR/dashboards + +cp -r devenv $RUNDIR + +echo -e "Starting Grafana Server port $PORT" + +$RUNDIR/bin/grafana-server \ + --homepath=$RUNDIR \ + --pidfile=$RUNDIR/pid \ + cfg:server.http_port=$PORT \ + cfg:app_mode=development + +# 2>&1 > $RUNDIR/output.log & +# cfg:log.level=debug \ + diff --git a/e2e/suite1/specs/1-smoketests.spec.ts b/e2e/suite1/specs/1-smoketests.spec.ts new file mode 100644 index 0000000..185eb38 --- /dev/null +++ b/e2e/suite1/specs/1-smoketests.spec.ts @@ -0,0 +1,4 @@ +import { e2e } from '@grafana/e2e'; +import { smokeTestScenario } from '../../shared/smokeTestScenario'; + +e2e.scenario(smokeTestScenario); diff --git a/e2e/suite1/specs/bar-gauge.spec.ts b/e2e/suite1/specs/bar-gauge.spec.ts new file mode 100644 index 0000000..382e70d --- /dev/null +++ b/e2e/suite1/specs/bar-gauge.spec.ts @@ -0,0 +1,19 @@ +import { e2e } from '@grafana/e2e'; +import { selectors } from '@grafana/e2e-selectors'; + +e2e.scenario({ + describeName: 'Bar Gauge Panel', + itName: 'Bar Gauge rendering e2e tests', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Bar Gauge + e2e.flows.openDashboard({ uid: 'O6f11TZWk' }); + + e2e() + .get(`#panel-6 [aria-label^="${selectors.components.Panels.Visualization.BarGauge.value}"]`) + .should('have.css', 'color', 'rgb(242, 73, 92)') + .contains('100'); + }, +}); diff --git a/e2e/suite1/specs/dashboard-templating.spec.ts b/e2e/suite1/specs/dashboard-templating.spec.ts new file mode 100644 index 0000000..a7c4db0 --- /dev/null +++ b/e2e/suite1/specs/dashboard-templating.spec.ts @@ -0,0 +1,53 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Dashboard templating', + itName: 'Verify variable interpolation works', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // Open dashboard global variables and interpolation + e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); + + const items: any = []; + const expectedItems: string[] = [ + '__dashboard = Templating - Global variables and interpolation', + '__dashboard.name = Templating - Global variables and interpolation', + '__dashboard.uid = HYaGDGIMk', + '__org.name = Main Org.', + '__org.id = 1', + '__user.id = 1', + '__user.login = admin', + '__user.email = admin@localhost', + `Server:raw = A'A"A,BB\\B,CCC`, + `Server:regex = (A'A"A|BB\\\\B|CCC)`, + `Server:lucene = ("A'A\\"A" OR "BB\\\\B" OR "CCC")`, + `Server:glob = {A'A"A,BB\\B,CCC}`, + `Server:pipe = A'A"A|BB\\B|CCC`, + `Server:distributed = A'A"A,Server=BB\\B,Server=CCC`, + `Server:csv = A'A"A,BB\\B,CCC`, + `Server:html = A'A"A, BB\\B, CCC`, + `Server:json = ["A'A\\"A","BB\\\\B","CCC"]`, + `Server:percentencode = %7BA%27A%22A%2CBB%5CB%2CCCC%7D`, + `Server:singlequote = 'A\\'A"A','BB\\B','CCC'`, + `Server:doublequote = "A'A\\"A","BB\\B","CCC"`, + `Server:sqlstring = 'A''A"A','BB\\\B','CCC'`, + `Server:date = null`, + `Server:text = All`, + `Server:queryparam = var-Server=All`, + ]; + + e2e() + .get('.markdown-html li') + .should('have.length', 24) + .each((element) => { + items.push(element.text()); + }) + .then(() => { + expectedItems.forEach((expected, index) => { + expect(items[index]).to.equal(expected); + }); + }); + }, +}); diff --git a/e2e/suite1/specs/dashboard-time-zone.spec.ts b/e2e/suite1/specs/dashboard-time-zone.spec.ts new file mode 100644 index 0000000..7b4f994 --- /dev/null +++ b/e2e/suite1/specs/dashboard-time-zone.spec.ts @@ -0,0 +1,82 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Dashboard time zone support', + itName: 'Tests dashboard time zone scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCasdf' }); + + const fromTimeZone = 'Coordinated Universal Time'; + const toTimeZone = 'America/Chicago'; + const offset = -5; + + const panelsToCheck = [ + 'Random walk series', + 'Millisecond res x-axis and tooltip', + '2 yaxis and axis labels', + 'Stacking value ontop of nulls', + 'Null between points', + 'Legend Table No Scroll Visible', + ]; + + const timesInUtc: Record = {}; + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.containerByTitle(title) + .should('be.visible') + .within(() => + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .should('be.visible') + .last() + .should((element) => { + timesInUtc[title] = element.text(); + }) + ); + } + + e2e.components.PageToolbar.item('Dashboard settings').click(); + + e2e.components.TimeZonePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('be.visible').should('have.text', fromTimeZone); + + e2e.components.Select.input().should('be.visible').click(); + + e2e.components.Select.option().should('be.visible').contains(toTimeZone).click(); + }); + + e2e.components.BackButton.backArrow().click(); + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.containerByTitle(title) + .should('be.visible') + .within(() => + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .should('be.visible') + .last() + .should((element) => { + const utc = timesInUtc[title]; + const tz = element.text(); + const isCorrect = isTimeCorrect(utc, tz, offset); + assert.isTrue(isCorrect, `Panel with title: "${title}"`); + }) + ); + } + }, +}); + +const isTimeCorrect = (utc: string, tz: string, offset: number): boolean => { + const minutes = 1000 * 60; + + const a = Cypress.moment(utc, 'HH:mm').set('seconds', 0).set('milliseconds', 0); + + const b = Cypress.moment(tz, 'HH:mm').set('seconds', 0).set('milliseconds', 0).add('hours', offset); + + return a.diff(b, 'minutes') <= 6 * minutes; +}; diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/suite1/specs/explore.spec.ts new file mode 100644 index 0000000..7b4765a --- /dev/null +++ b/e2e/suite1/specs/explore.spec.ts @@ -0,0 +1,25 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Explore', + itName: 'Basic path through Explore.', + addScenarioDataSource: true, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.pages.Explore.visit(); + e2e.pages.Explore.General.container().should('have.length', 1); + e2e.components.RefreshPicker.runButton().should('have.length', 1); + + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + }); + + const canvases = e2e().get('canvas'); + canvases.should('have.length', 1); + }, +}); diff --git a/e2e/suite1/specs/gauge.spec.ts b/e2e/suite1/specs/gauge.spec.ts new file mode 100644 index 0000000..4fb1552 --- /dev/null +++ b/e2e/suite1/specs/gauge.spec.ts @@ -0,0 +1,21 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Gauge Panel', + itName: 'Gauge rendering e2e tests', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Gauge + e2e.flows.openDashboard({ uid: '_5rDmaQiz' }); + + cy.wait(1000); + + // check that gauges are rendered + e2e().get('body').find(`.flot-base`).should('have.length', 16); + + // check that no panel errors exist + e2e.components.Panels.Panel.headerCornerInfo('error').should('not.exist'); + }, +}); diff --git a/e2e/suite1/specs/inspect-drawer.spec.ts b/e2e/suite1/specs/inspect-drawer.spec.ts new file mode 100644 index 0000000..51087db --- /dev/null +++ b/e2e/suite1/specs/inspect-drawer.spec.ts @@ -0,0 +1,148 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = '2 yaxis and axis labels'; + +e2e.scenario({ + describeName: 'Inspect drawer tests', + itName: 'Tests various Inspect Drawer scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // @ts-ignore some typing issue + e2e().on('uncaught:exception', (err) => { + if (err.stack?.indexOf("TypeError: Cannot read property 'getText' of null") !== -1) { + // On occasion monaco editor will not have the time to be properly unloaded when we change the tab + // and then the e2e test fails with the uncaught:exception: + // TypeError: Cannot read property 'getText' of null + // at Object.ai [as getFoldingRanges] (http://localhost:3001/public/build/monaco-json.worker.js:2:215257) + // at e.getFoldingRanges (http://localhost:3001/public/build/monaco-json.worker.js:2:221188) + // at e.fmr (http://localhost:3001/public/build/monaco-json.worker.js:2:116605) + // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7414) + // at Object.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7018) + // at e._handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:5038) + // at e.handleMessage (http://localhost:3001/public/build/monaco-json.worker.js:2:4606) + // at e.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:7097) + // at Tt.self.onmessage (http://localhost:3001/public/build/monaco-json.worker.js:2:117109) + + // return false to prevent the error from + // failing this test + return false; + } + + return true; + }); + + const viewPortWidth = e2e.config().viewportWidth; + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + // testing opening inspect drawer directly by clicking on Inspect in header menu + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST); + + expectDrawerTabsAndContent(); + + expectDrawerExpandAndContract(viewPortWidth); + + expectDrawerClose(); + + expectSubMenuScenario('Data'); + expectSubMenuScenario('Query'); + expectSubMenuScenario('Panel JSON', 'JSON'); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); + + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Query').should('be.visible'); + // query should be the active tab + e2e.components.Tab.active().should('have.text', 'Query'); + }); + + e2e.components.PanelInspector.Query.content().should('be.visible'); + }, +}); + +const expectDrawerTabsAndContent = () => { + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`) + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Data').should('be.visible'); + // data should be the active tab + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Data'); + }); + e2e.components.PanelInspector.Data.content().should('be.visible'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + // other tabs should also be visible, click on each to see if we get any console errors + e2e.components.Tab.title('Stats').should('be.visible').click(); + e2e.components.PanelInspector.Stats.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + e2e.components.Tab.title('JSON').should('be.visible').click(); + e2e.components.PanelInspector.Json.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Query.content().should('not.exist'); + + e2e.components.Tab.title('Query').should('be.visible').click(); + + e2e.components.PanelInspector.Query.content().should('be.visible'); + e2e.components.PanelInspector.Data.content().should('not.exist'); + e2e.components.PanelInspector.Stats.content().should('not.exist'); + e2e.components.PanelInspector.Json.content().should('not.exist'); + }); +}; + +const expectDrawerClose = () => { + // close using close button + e2e.components.Drawer.General.close().click(); + e2e.components.Drawer.General.title(`Inspect: ${PANEL_UNDER_TEST}`).should('not.exist'); +}; + +const expectDrawerExpandAndContract = (viewPortWidth: number) => { + // try expand button + // drawer should take up half the screen + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth / 2}px`); + + e2e.components.Drawer.General.expand().click(); + e2e.components.Drawer.General.contract().should('be.visible'); + + // drawer should take up the whole screen + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth}px`); + + // try contract button + e2e.components.Drawer.General.contract().click(); + e2e.components.Drawer.General.expand().should('be.visible'); + + e2e.components.Drawer.General.rcContentWrapper() + .should('be.visible') + .should('have.css', 'width', `${viewPortWidth / 2}px`); +}; + +const expectSubMenuScenario = (subMenu: string, tabTitle?: string) => { + tabTitle = tabTitle ?? subMenu; + // testing opening inspect drawer from sub menus under Inspect in header menu + e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible').click(); + + // sub menus are in the DOM but not visible and because there is no hover support in Cypress force click + // https://github.com/cypress-io/cypress-example-recipes/blob/master/examples/testing-dom__hover-hidden-elements/cypress/integration/hover-hidden-elements-spec.js + e2e.components.Panels.Panel.headerItems(subMenu).click({ force: true }); + + // data should be the default tab + e2e.components.Tab.title(tabTitle).should('be.visible'); + e2e.components.Tab.active().should('have.text', tabTitle); + + expectDrawerClose(); +}; diff --git a/e2e/suite1/specs/panelEdit_base.spec.ts b/e2e/suite1/specs/panelEdit_base.spec.ts new file mode 100644 index 0000000..c4d3b5d --- /dev/null +++ b/e2e/suite1/specs/panelEdit_base.spec.ts @@ -0,0 +1,114 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = 'Random walk series'; + +e2e.scenario({ + describeName: 'Panel edit tests', + itName: 'Tests various Panel edit scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + // New panel editor opens when navigating from Panel menu + e2e.components.PanelEditor.General.content().should('be.visible'); + + // Queries tab is rendered and open by default + e2e.components.PanelEditor.DataPane.content() + .should('be.visible') + .within(() => { + e2e.components.Tab.title('Query').should('be.visible'); + // data should be the active tab + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Query1'); // there's already a query so therefore Query + 1 + }); + e2e.components.QueryTab.content().should('be.visible'); + e2e.components.TransformTab.content().should('not.exist'); + e2e.components.AlertTab.content().should('not.exist'); + + // Bottom pane tabs + // Can change to Transform tab + e2e.components.Tab.title('Transform').should('be.visible').click(); + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Transform0'); // there's no transform so therefore Transform + 0 + }); + e2e.components.Transforms.card('Merge').scrollIntoView().should('be.visible'); + e2e.components.QueryTab.content().should('not.exist'); + e2e.components.AlertTab.content().should('not.exist'); + + // Can change to Alerts tab (graph panel is the default vis so the alerts tab should be rendered) + e2e.components.Tab.title('Alert').should('be.visible').click(); + e2e.components.Tab.active().within((li: JQuery) => { + expect(li.text()).equals('Alert0'); // there's no alert so therefore Alert + 0 + }); + e2e.components.AlertTab.content().should('be.visible'); + e2e.components.QueryTab.content().should('not.exist'); + e2e.components.TransformTab.content().should('not.exist'); + + e2e.components.Tab.title('Query').should('be.visible').click(); + }); + + // Panel sidebar is rendered open by default + e2e.components.PanelEditor.OptionsPane.content().should('be.visible'); + + // Can toggle on/off sidebar + e2e.components.PanelEditor.OptionsPane.close().should('be.visible'); + e2e.components.PanelEditor.OptionsPane.open().should('not.exist'); + + // close options pane + e2e.components.PanelEditor.OptionsPane.close().click(); + e2e.components.PanelEditor.OptionsPane.open().should('be.visible'); + e2e.components.PanelEditor.OptionsPane.close().should('not.exist'); + e2e.components.PanelEditor.OptionsPane.content().should('not.exist'); + + // open options pane + e2e.components.PanelEditor.OptionsPane.open().click(); + e2e.components.PanelEditor.OptionsPane.close().should('be.visible'); + e2e.components.PanelEditor.OptionsPane.open().should('not.exist'); + e2e.components.PanelEditor.OptionsPane.content().should('be.visible'); + + // Can change visualisation type + e2e.components.OptionsGroup.toggle('Panel type').should('be.visible').click(); + + // Check that Graph is chosen + e2e.components.PluginVisualization.item('Graph').should('be.visible'); + e2e.components.PluginVisualization.current().within((div: JQuery) => { + expect(div.text()).equals('Graph'); + }); + + // Change to Text panel + e2e.components.PluginVisualization.item('Text').scrollIntoView().should('be.visible').click(); + e2e.components.PluginVisualization.current().within((div: JQuery) => { + expect(div.text()).equals('Text'); + }); + + // Data pane should not be rendered + e2e.components.PanelEditor.DataPane.content().should('not.exist'); + + // Change to Table panel + e2e.components.PluginVisualization.item('Table').scrollIntoView().should('be.visible').click(); + e2e.components.PluginVisualization.current().within((div: JQuery) => { + expect(div.text()).equals('Table'); + }); + + // Data pane should be rendered + e2e.components.PanelEditor.DataPane.content().should('be.visible'); + + // Field & Overrides tabs (need to switch to React based vis, i.e. Table) + e2e.components.PanelEditor.OptionsPane.tab('Field').should('be.visible'); + e2e.components.PanelEditor.OptionsPane.tab('Overrides').should('be.visible'); + e2e.components.PanelEditor.OptionsPane.tab('Field').click(); + + e2e.components.FieldConfigEditor.content().should('be.visible'); + e2e.components.OverridesConfigEditor.content().should('not.exist'); + + e2e.components.PanelEditor.OptionsPane.tab('Field').should('be.visible'); + e2e.components.PanelEditor.OptionsPane.tab('Overrides').should('be.visible').click(); + + e2e.components.OverridesConfigEditor.content().should('be.visible'); + e2e.components.FieldConfigEditor.content().should('not.exist'); + }, +}); diff --git a/e2e/suite1/specs/panelEdit_queries.spec.ts b/e2e/suite1/specs/panelEdit_queries.spec.ts new file mode 100644 index 0000000..861feea --- /dev/null +++ b/e2e/suite1/specs/panelEdit_queries.spec.ts @@ -0,0 +1,115 @@ +import { e2e } from '@grafana/e2e'; +import { expect } from '../../../public/test/lib/common'; + +const PANEL_UNDER_TEST = 'Random walk series'; + +e2e.scenario({ + describeName: 'Panel edit tests - queries', + itName: 'Testes various Panel edit queries scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + // New panel editor opens when navigating from Panel menu + e2e.components.PanelEditor.General.content().should('be.visible'); + + // Queries tab is rendered and open by default + e2e.components.PanelEditor.DataPane.content().should('be.visible'); + + // We expect row with refId A to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(1); + }); + + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('apiPostQuery'); + + // Add query button should be visible and clicking on it should create a new row + e2e.components.QueryTab.addQuery().scrollIntoView().should('be.visible').click(); + + // We expect row with refId A and B to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(2); + }); + + // Remove refId A + e2e.components.QueryEditorRow.actionButton('Remove query').eq(0).should('be.visible').click(); + + e2e().wait('@apiPostQuery'); + + // We expect row with refId B to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(1); + }); + + // Duplicate refId B + e2e.components.QueryEditorRow.actionButton('Duplicate query').eq(0).should('be.visible').click(); + + // We expect row with refId Band and A to exist and be visible + e2e.components.QueryEditorRows.rows().within((rows) => { + expect(rows.length).equals(2); + }); + + // Change to CSV Metric Values scenario for A + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + e2e.components.Select.input().eq(0).should('be.visible').click(); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').eq(0).click(); + }); + + e2e().wait('@apiPostQuery'); + + // Disable / enable row + expectInspectorResultAndClose((keys) => { + const length = keys.length; + expect(keys[length - 2].innerText).equals('A:'); + expect(keys[length - 1].innerText).equals('B:'); + }); + + // Disable row with refId A + e2e.components.QueryEditorRow.actionButton('Disable/enable query').eq(1).should('be.visible').click(); + + e2e().wait('@apiPostQuery'); + + expectInspectorResultAndClose((keys) => { + const length = keys.length; + expect(keys[length - 1].innerText).equals('B:'); + }); + + // Enable row with refId B + e2e.components.QueryEditorRow.actionButton('Disable/enable query').eq(1).should('be.visible').click(); + + e2e().wait('@apiPostQuery'); + + expectInspectorResultAndClose((keys) => { + const length = keys.length; + expect(keys[length - 2].innerText).equals('A:'); + expect(keys[length - 1].innerText).equals('B:'); + }); + }, +}); + +const expectInspectorResultAndClose = (expectCallBack: (keys: any[]) => void) => { + e2e.components.QueryTab.queryInspectorButton().should('be.visible').click(); + + e2e.components.PanelInspector.Query.refreshButton().should('be.visible').click(); + + e2e().wait('@apiPostQuery'); + + e2e.components.PanelInspector.Query.jsonObjectKeys() + .should('be.visible') + .within((keys: any) => expectCallBack(keys)); + + e2e.components.Drawer.General.close().should('be.visible').click(); +}; diff --git a/e2e/suite1/specs/panelEdit_transforms.spec.ts b/e2e/suite1/specs/panelEdit_transforms.spec.ts new file mode 100644 index 0000000..49e551c --- /dev/null +++ b/e2e/suite1/specs/panelEdit_transforms.spec.ts @@ -0,0 +1,22 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = 'Random walk series'; + +e2e.scenario({ + describeName: 'Panel edit tests - transformations', + itName: 'Tests transformations editor', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + e2e.components.Tab.title('Transform').should('be.visible').click(); + + e2e.components.TransformTab.newTransform('Reduce').should('be.visible').click(); + + e2e.components.Transforms.Reduce.calculationsLabel().should('be.visible'); + }, +}); diff --git a/e2e/suite1/specs/query-editor.spec.ts b/e2e/suite1/specs/query-editor.spec.ts new file mode 100644 index 0000000..edc15ba --- /dev/null +++ b/e2e/suite1/specs/query-editor.spec.ts @@ -0,0 +1,28 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Query editor', + itName: 'Undo should work in query editor for prometheus.', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.pages.Explore.visit(); + e2e.components.DataSourcePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + cy.contains('gdev-prometheus').scrollIntoView().should('be.visible').click(); + }); + const queryText = 'http_requests_total'; + + e2e.components.QueryField.container().should('be.visible').type(queryText).type('{backspace}'); + + cy.contains(queryText.slice(0, -1)).should('be.visible'); + + e2e.components.QueryField.container().type(e2e.typings.undo()); + + cy.contains(queryText).should('be.visible'); + }, +}); diff --git a/e2e/suite1/specs/select-focus.spec.ts b/e2e/suite1/specs/select-focus.spec.ts new file mode 100644 index 0000000..0087e3a --- /dev/null +++ b/e2e/suite1/specs/select-focus.spec.ts @@ -0,0 +1,31 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Select focus/unfocus tests', + itName: 'Tests select focus/unfocus scenarios', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: '5SdHCadmz' }); + e2e.components.PageToolbar.item('Dashboard settings').click(); + + e2e.components.FolderPicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + e2e.components.Select.option().should('be.visible').first().click(); + + e2e.components.Select.input().should('exist').should('have.focus'); + }); + + e2e.pages.Dashboard.Settings.General.title().click(); + + e2e.components.FolderPicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('exist').should('not.have.focus'); + }); + }, +}); diff --git a/e2e/suite1/specs/solo-route.spec.ts b/e2e/suite1/specs/solo-route.spec.ts new file mode 100644 index 0000000..52c74ef --- /dev/null +++ b/e2e/suite1/specs/solo-route.spec.ts @@ -0,0 +1,15 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Solo Route', + itName: 'Can view panels with shared queries in fullsceen', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // open Panel Tests - Bar Gauge + e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); + + e2e().get('canvas').should('have.length', 6); + }, +}); diff --git a/e2e/suite1/specs/templating-dashboard-links-and-variables.ts b/e2e/suite1/specs/templating-dashboard-links-and-variables.ts new file mode 100644 index 0000000..926f63f --- /dev/null +++ b/e2e/suite1/specs/templating-dashboard-links-and-variables.ts @@ -0,0 +1,60 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Templating', + itName: 'Tests dashboard links and variables in links', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: '/api/search?tag=templating&limit=100', + }) + .as('tagsTemplatingSearch'); + e2e() + .route({ + method: 'GET', + url: '/api/search?tag=demo&limit=100', + }) + .as('tagsDemoSearch'); + + // waiting for links to render, couldn't find a better way using routes for instance + e2e().wait(1000); + + const verifyLinks = (variableValue: string) => { + e2e.components.DashboardLinks.link() + .should('be.visible') + .and((links) => { + expect(links).to.have.length.greaterThan(13); + + for (let index = 0; index < links.length; index++) { + expect(Cypress.$(links[index]).attr('href')).contains(`var-custom=${variableValue}`); + } + }); + }; + + e2e.components.DashboardLinks.dropDown() + .should('be.visible') + .click() + .wait('@tagsTemplatingSearch') + .wait('@tagsDemoSearch'); + + // verify all links, should have All value + verifyLinks('All'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch'); + + // verify all links, should have p2 value + verifyLinks('p2'); + }, +}); diff --git a/e2e/suite1/specs/trace-view-scrolling.spec.ts b/e2e/suite1/specs/trace-view-scrolling.spec.ts new file mode 100644 index 0000000..95ff102 --- /dev/null +++ b/e2e/suite1/specs/trace-view-scrolling.spec.ts @@ -0,0 +1,35 @@ +import { e2e } from '@grafana/e2e'; + +describe('Trace view', () => { + it('Can lazy load big traces', () => { + e2e.flows.login('admin', 'admin'); + e2e() + .intercept('GET', '/api/traces/long-trace', { + fixture: 'long-trace-response.json', + }) + .as('longTrace'); + + e2e.pages.Explore.visit(); + + e2e.components.DataSourcePicker.container() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').click(); + + e2e().contains('gdev-jaeger').scrollIntoView().should('be.visible').click(); + }); + + e2e.components.QueryField.container().should('be.visible').type('long-trace'); + + e2e.components.RefreshPicker.runButton().should('be.visible').click(); + + e2e().wait('@longTrace'); + + e2e.components.TraceViewer.spanBar().should('have.length', 100); + + e2e.pages.Explore.General.scrollBar().scrollTo('center'); + + // After scrolling we should have 140 spans instead of the first 100 + e2e.components.TraceViewer.spanBar().should('have.length', 140); + }); +}); diff --git a/e2e/suite1/specs/variables/load-options-from-url.ts b/e2e/suite1/specs/variables/load-options-from-url.ts new file mode 100644 index 0000000..e272503 --- /dev/null +++ b/e2e/suite1/specs/variables/load-options-from-url.ts @@ -0,0 +1,156 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk'; + +describe('Variables - Load options from Url', () => { + it('default options should be correct', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAC').should('be.visible'); + }); + + it('options set in url should load correct options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=B&var-server=BB&var-pod=BBB` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + }); + + it('options set in url that do not exist should load correct options', () => { + e2e.flows.login('admin', 'admin'); + // @ts-ignore some typing issue + e2e().on('uncaught:exception', (err) => { + if (err.stack?.indexOf("Couldn't find any field of type string in the results.") !== -1) { + // return false to prevent the error from + // failing this test + return false; + } + + return true; + }); + + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=X` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('X').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 10); + }); + }); +}); diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/suite1/specs/variables/new-query-variable.ts new file mode 100644 index 0000000..acecf71 --- /dev/null +++ b/e2e/suite1/specs/variables/new-query-variable.ts @@ -0,0 +1,195 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk'; + +describe('Variables - Add variable', () => { + it('query variable should be default and default fields should be correct', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('name'); + expect(input.val()).equals('query0'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('be.visible').should('have.text', 'Query'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('optional display name'); + expect(input.val()).equals(''); + }); + e2e() + .get('#Description') + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('descriptive text'); + expect(input.val()).equals(''); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalHideSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', ''); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'gdev-testdata'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'Never'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .within((input) => { + const placeholder = '/.*-(?.*)-(?.*)-.*/'; + expect(input.attr('placeholder')).equals(placeholder); + expect(input.val()).equals(''); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelect() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'Disabled'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch().should('not.be.checked'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch().should('not.be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.valueGroupsTagsEnabledSwitch().should('not.be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('not.exist'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().should('not.exist'); + }); + + it('adding a single value query variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .clear() + .type('a label'); + + e2e().get('#Description').should('be.visible').clear().type('a description'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type('gdev-testdata').type('{enter}'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().should('be.visible').click(); + + e2e().wait(1500); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + e2e().get('.variable-link-wrapper').should('be.visible').click(); + e2e().wait(500); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 1); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + }); + }); + + it('adding a multi value query variable', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=templating` }); + + e2e.pages.Dashboard.Settings.Variables.List.newButton().should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput() + .should('be.visible') + .clear() + .type('a label'); + + e2e().get('#Description').should('be.visible').clear().type('a description'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type('gdev-testdata').type('{enter}'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch() + .click({ force: true }) + .should('be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch() + .click({ force: true }) + .should('be.checked'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().within((input) => { + expect(input.attr('placeholder')).equals('blank = auto'); + expect(input.val()).equals(''); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().should('be.visible').click(); + + e2e().wait(500); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + e2e().get('.variable-link-wrapper').should('be.visible').click(); + e2e().wait(500); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 2); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('C').should('be.visible'); + }); + }); +}); diff --git a/e2e/suite1/specs/variables/set-options-from-ui.ts b/e2e/suite1/specs/variables/set-options-from-ui.ts new file mode 100644 index 0000000..4a22c95 --- /dev/null +++ b/e2e/suite1/specs/variables/set-options-from-ui.ts @@ -0,0 +1,167 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk'; + +describe('Variables - Set options from ui', () => { + it('clicking a value that is not part of dependents options should change these to All', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-server=AA&var-pod=AAA` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('have.length', 2); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').eq(0).should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 10); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCC').should('be.visible'); + }); + + it('adding a value that is not part of dependents options should add the new values dependant options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-server=AA&var-pod=AAA` }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e().wait('@query'); + e2e().wait(500); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').scrollIntoView().should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 7); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AAA').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('AAC').should('be.visible'); + }); + + it('removing a value that is part of dependents options should remove the new values dependant options', () => { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ + uid: `${PAGE_UNDER_TEST}?var-datacenter=A&var-datacenter=B&var-server=AA&var-server=BB&var-pod=AAA&var-pod=BBB`, + }); + e2e().server(); + e2e() + .route({ + method: 'POST', + url: '/api/ds/query', + }) + .as('query'); + + e2e().wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').should('be.visible').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click(); + + e2e.components.PageToolbar.container().click(); + + e2e().wait('@query'); + e2e().wait(500); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BC').should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB').should('be.visible').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() + .should('be.visible') + .within(() => { + e2e().get('.variable-option').should('have.length', 4); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); + }); +}); diff --git a/e2e/suite1/specs/variables/textbox-variables.ts b/e2e/suite1/specs/variables/textbox-variables.ts new file mode 100644 index 0000000..f9ced29 --- /dev/null +++ b/e2e/suite1/specs/variables/textbox-variables.ts @@ -0,0 +1,263 @@ +import { e2e } from '@grafana/e2e'; + +const PAGE_UNDER_TEST = 'AejrN1AMz'; + +describe('TextBox - load options scenarios', function () { + it('default options should be correct', function () { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`, + }) + .as('dash'); + + e2e().wait('@dash'); + + validateTextboxAndMarkup('default value'); + }); + + it('loading variable from url should be correct', function () { + e2e.flows.login('admin', 'admin'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-text=not default value` }); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: `/api/dashboards/uid/${PAGE_UNDER_TEST}`, + }) + .as('dash'); + + e2e().wait('@dash'); + + validateTextboxAndMarkup('not default value'); + }); +}); + +describe.skip('TextBox - change query scenarios', function () { + it('when changing the query value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + + validateVariable('changed value'); + }); + }); + + it('when changing the query value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + + validateVariable('changed value'); + }); + }); +}); + +describe.skip('TextBox - change picker value scenarios', function () { + it('when changing the input value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + validateVariable('default value'); + }); + }); + + it('when changing the input value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + e2e() + .get('@dashuid') + .then((dashuid: any) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + e2e().wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + validateVariable('changed value'); + }); + }); +}); + +function copyExistingDashboard() { + e2e.flows.login('admin', 'admin'); + e2e().server(); + e2e() + .route({ + method: 'GET', + url: '/api/search?query=&type=dash-folder&permission=Edit', + }) + .as('dash-settings'); + e2e() + .route({ + method: 'POST', + url: '/api/dashboards/db/', + }) + .as('save-dash'); + e2e() + .route({ + method: 'GET', + url: /\/api\/dashboards\/uid\/(?!AejrN1AMz)\w+/, + }) + .as('load-dash'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?editview=settings&orgId=1` }); + + e2e().wait('@dash-settings'); + + e2e.pages.Dashboard.Settings.General.saveAsDashBoard().should('be.visible').click(); + + e2e.pages.SaveDashboardAsModal.newName().should('be.visible').type(`${Date.now()}`); + + e2e.pages.SaveDashboardAsModal.save().should('be.visible').click(); + + e2e().wait('@save-dash'); + e2e().wait('@load-dash'); + + e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible'); + + e2e() + .location() + .then((loc) => { + const dashuid = /\/d\/(\w+)\//.exec(loc.href)![1]; + e2e().wrap(dashuid).as('dashuid'); + }); + + e2e().wait(500); +} + +function saveDashboard(saveVariables: boolean) { + e2e.components.PageToolbar.item('Save dashboard').should('be.visible').click(); + + if (saveVariables) { + e2e.pages.SaveDashboardModal.saveVariables().should('exist').click({ force: true }); + } + + e2e.pages.SaveDashboardModal.save().should('be.visible').click(); + + e2e().wait('@save-dash'); +} + +function validateTextboxAndMarkup(value: string) { + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e().get('input').should('be.visible').should('have.value', value); + }); + + e2e.components.Panels.Visualization.Text.container() + .should('be.visible') + .within(() => { + e2e().get('h1').should('be.visible').should('have.text', `variable: ${value}`); + }); +} + +function validateVariable(value: string) { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .should('have.value', value); +} + +function changeTextBoxInput() { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e() + .get('input') + .should('be.visible') + .should('have.value', 'default value') + .clear() + .type('changed value') + .type('{enter}'); + }); + + e2e() + .location() + .should((loc) => { + expect(loc.search).to.contain('var-text=changed%20value'); + }); +} + +function changeQueryInput() { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInput() + .should('be.visible') + .clear() + .type('changed value') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('have.length', 1) + .should('have.text', 'changed value'); +} diff --git a/e2e/suite1/tsconfig.json b/e2e/suite1/tsconfig.json new file mode 100644 index 0000000..f1cce18 --- /dev/null +++ b/e2e/suite1/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["cypress"] + }, + "extends": "../../tsconfig.json", + "include": ["**/*.ts", "../../packages/grafana-e2e/cypress/support/index.d.ts"] +} diff --git a/e2e/variables b/e2e/variables new file mode 100644 index 0000000..8a76f61 --- /dev/null +++ b/e2e/variables @@ -0,0 +1,10 @@ +#!/bin/bash + +DEFAULT_RUNDIR=e2e/tmp +RUNDIR=${RUNDIR:-$DEFAULT_RUNDIR} +PIDFILE=$RUNDIR/pid +DEFAULT_PACKAGE_FILE=dist/grafana-*linux-amd64.tar.gz +PROV_DIR=$RUNDIR/conf/provisioning +DEFAULT_HOST=localhost +DEFAULT_PORT=3001 +DEFAULT_SUITE=suite1 diff --git a/e2e/verify-release b/e2e/verify-release new file mode 100755 index 0000000..fb923d5 --- /dev/null +++ b/e2e/verify-release @@ -0,0 +1,5 @@ +#!/bin/bash + +. e2e/variables + +SUITE=verify ./e2e/run-suite diff --git a/e2e/verify/specs/smoketests.spec.ts b/e2e/verify/specs/smoketests.spec.ts new file mode 100644 index 0000000..185eb38 --- /dev/null +++ b/e2e/verify/specs/smoketests.spec.ts @@ -0,0 +1,4 @@ +import { e2e } from '@grafana/e2e'; +import { smokeTestScenario } from '../../shared/smokeTestScenario'; + +e2e.scenario(smokeTestScenario); diff --git a/e2e/verify/tsconfig.json b/e2e/verify/tsconfig.json new file mode 100644 index 0000000..f1cce18 --- /dev/null +++ b/e2e/verify/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["cypress"] + }, + "extends": "../../tsconfig.json", + "include": ["**/*.ts", "../../packages/grafana-e2e/cypress/support/index.d.ts"] +} diff --git a/e2e/wait-for-grafana b/e2e/wait-for-grafana new file mode 100755 index 0000000..b3c9839 --- /dev/null +++ b/e2e/wait-for-grafana @@ -0,0 +1,11 @@ +#!/bin/bash +set -eo pipefail + +. e2e/variables + +HOST=${HOST:-$DEFAULT_HOST} +PORT=${PORT:-$DEFAULT_PORT} + +echo -e "Waiting for grafana-server to finish starting, host=$HOST, port=$PORT" + +timeout 60 bash -c 'until nc -z $0 $1; do sleep 1; done' $HOST $PORT diff --git a/emails/README.md b/emails/README.md new file mode 100644 index 0000000..562d524 --- /dev/null +++ b/emails/README.md @@ -0,0 +1,15 @@ +## Prerequisites + +- npm install +- gem install premailer + +## Tasks + +- npm run build (default task will build new inlines email templates) +- npm start (will build on source html or css change) + +## Result + +Assembled email templates will be in `dist/` and final +inlined templates will be in `../public/emails/` + diff --git a/emails/assets/css/ink.css b/emails/assets/css/ink.css new file mode 100644 index 0000000..f4c1a29 --- /dev/null +++ b/emails/assets/css/ink.css @@ -0,0 +1,688 @@ +/********************************************** +* Ink v1.0.5 - Copyright 2013 ZURB Inc * +**********************************************/ + +/* Client-specific Styles & Reset */ + +#outlook a { + padding:0; +} + +body{ + width:100% !important; + min-width: 100%; + -webkit-text-size-adjust:100%; + -ms-text-size-adjust:100%; + margin:0; + padding:0; +} + + + +.ExternalClass { + width:100%; +} + +.ExternalClass, +.ExternalClass p, +.ExternalClass span, +.ExternalClass font, +.ExternalClass td, +.ExternalClass div { + line-height: 100%; +} + +#backgroundTable { + margin:0; + padding:0; + width:100% !important; + line-height: 100% !important; +} + +img { + outline:none; + text-decoration:none; + -ms-interpolation-mode: bicubic; + width: auto; + float: left; + clear: both; + display: block; +} + +center { + width: 100%; + min-width: 580px; +} + +a img { + border: none; +} + +p { + margin: 0 0 0 10px; +} + +table { + border-spacing: 0; + border-collapse: collapse; +} + +td { + word-break: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + border-collapse: collapse !important; +} + +table, tr, td { + padding: 0; + vertical-align: top; + text-align: left; +} + +hr { + color: #d9d9d9; + background-color: #d9d9d9; + height: 1px; + border: none; +} + +/* Responsive Grid */ + +table.body { + height: 100%; + width: 100%; +} + +table.container { + width: 580px; + margin: 0 auto; + text-align: inherit; +} + +table.row { + padding: 0px; + width: 100%; + position: relative; +} + +table.container table.row { + display: block; +} + +td.wrapper { + padding: 10px 20px 0px 0px; + position: relative; +} + +table.columns, +table.column { + margin: 0 auto; +} + +table.columns td, +table.column td { + padding: 0px 0px 10px; +} + +table.columns td.sub-columns, +table.column td.sub-columns, +table.columns td.sub-column, +table.column td.sub-column { + padding-right: 10px; +} + +td.sub-column, td.sub-columns { + min-width: 0px; +} + +table.row td.last, +table.container td.last { + padding-right: 0px; +} + +table.one { width: 30px; } +table.two { width: 80px; } +table.three { width: 130px; } +table.four { width: 180px; } +table.five { width: 230px; } +table.six { width: 280px; } +table.seven { width: 330px; } +table.eight { width: 380px; } +table.nine { width: 430px; } +table.ten { width: 480px; } +table.eleven { width: 530px; } +table.twelve { width: 580px; } + +table.one center { min-width: 30px; } +table.two center { min-width: 80px; } +table.three center { min-width: 130px; } +table.four center { min-width: 180px; } +table.five center { min-width: 230px; } +table.six center { min-width: 280px; } +table.seven center { min-width: 330px; } +table.eight center { min-width: 380px; } +table.nine center { min-width: 430px; } +table.ten center { min-width: 480px; } +table.eleven center { min-width: 530px; } +table.twelve center { min-width: 580px; } + +table.one .panel center { min-width: 10px; } +table.two .panel center { min-width: 60px; } +table.three .panel center { min-width: 110px; } +table.four .panel center { min-width: 160px; } +table.five .panel center { min-width: 210px; } +table.six .panel center { min-width: 260px; } +table.seven .panel center { min-width: 310px; } +table.eight .panel center { min-width: 360px; } +table.nine .panel center { min-width: 410px; } +table.ten .panel center { min-width: 460px; } +table.eleven .panel center { min-width: 510px; } +table.twelve .panel center { min-width: 560px; } + +.body .columns td.one, +.body .column td.one { width: 8.333333%; } +.body .columns td.two, +.body .column td.two { width: 16.666666%; } +.body .columns td.three, +.body .column td.three { width: 25%; } +.body .columns td.four, +.body .column td.four { width: 33.333333%; } +.body .columns td.five, +.body .column td.five { width: 41.666666%; } +.body .columns td.six, +.body .column td.six { width: 50%; } +.body .columns td.seven, +.body .column td.seven { width: 58.333333%; } +.body .columns td.eight, +.body .column td.eight { width: 66.666666%; } +.body .columns td.nine, +.body .column td.nine { width: 75%; } +.body .columns td.ten, +.body .column td.ten { width: 83.333333%; } +.body .columns td.eleven, +.body .column td.eleven { width: 91.666666%; } +.body .columns td.twelve, +.body .column td.twelve { width: 100%; } + +td.offset-by-one { padding-left: 50px; } +td.offset-by-two { padding-left: 100px; } +td.offset-by-three { padding-left: 150px; } +td.offset-by-four { padding-left: 200px; } +td.offset-by-five { padding-left: 250px; } +td.offset-by-six { padding-left: 300px; } +td.offset-by-seven { padding-left: 350px; } +td.offset-by-eight { padding-left: 400px; } +td.offset-by-nine { padding-left: 450px; } +td.offset-by-ten { padding-left: 500px; } +td.offset-by-eleven { padding-left: 550px; } + +td.expander { + visibility: hidden; + width: 0px; + padding: 0 !important; +} + +table.columns .text-pad, +table.column .text-pad { + padding-left: 10px; + padding-right: 10px; +} + +table.columns .left-text-pad, +table.columns .text-pad-left, +table.column .left-text-pad, +table.column .text-pad-left { + padding-left: 10px; +} + +table.columns .right-text-pad, +table.columns .text-pad-right, +table.column .right-text-pad, +table.column .text-pad-right { + padding-right: 10px; +} + +/* Block Grid */ + +.block-grid { + width: 100%; + max-width: 580px; +} + +.block-grid td { + display: inline-block; + padding:10px; +} + +.two-up td { + width:270px; +} + +.three-up td { + width:173px; +} + +.four-up td { + width:125px; +} + +.five-up td { + width:96px; +} + +.six-up td { + width:76px; +} + +.seven-up td { + width:62px; +} + +.eight-up td { + width:52px; +} + +/* Alignment & Visibility Classes */ + +table.center, td.center { + text-align: center; +} + +h1.center, +h2.center, +h3.center, +h4.center, +h5.center, +h6.center { + text-align: center; +} + +span.center { + display: block; + width: 100%; + text-align: center; +} + +img.center { + margin: 0 auto; + float: none; +} + +.show-for-small, +.hide-for-desktop { + display: none; +} + +/* Typography */ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + color: #222222; + font-family: "Helvetica", "Arial", sans-serif; + font-weight: normal; + padding:0; + margin: 0; + text-align: left; + line-height: 1.3; +} + +h1, h2, h3, h4, h5, h6 { + word-break: normal; +} + +h1 {font-size: 40px;} +h2 {font-size: 36px;} +h3 {font-size: 32px;} +h4 {font-size: 28px;} +h5 {font-size: 24px;} +h6 {font-size: 20px;} +body, table.body, p, td {font-size: 14px;line-height:19px;} + +p.lead, p.lede, p.leed { + font-size: 18px; + line-height:21px; +} + +p { + margin-bottom: 10px; +} + +small { + font-size: 10px; +} + +a { + color: #2ba6cb; + text-decoration: none; +} + +a:hover { + color: #2795b6 !important; +} + +a:active { + color: #2795b6 !important; +} + +a:visited { + color: #2ba6cb !important; +} + +h1 a, +h2 a, +h3 a, +h4 a, +h5 a, +h6 a { + color: #2ba6cb; +} + +h1 a:active, +h2 a:active, +h3 a:active, +h4 a:active, +h5 a:active, +h6 a:active { + color: #2ba6cb !important; +} + +h1 a:visited, +h2 a:visited, +h3 a:visited, +h4 a:visited, +h5 a:visited, +h6 a:visited { + color: #2ba6cb !important; +} + +/* Panels */ + +.panel { + background: #f2f2f2; + border: 1px solid #d9d9d9; + padding: 10px !important; +} + +.sub-grid table { + width: 100%; +} + +.sub-grid td.sub-columns { + padding-bottom: 0; +} + +/* Buttons */ + +table.button, +table.tiny-button, +table.small-button, +table.medium-button, +table.large-button { + width: 100%; + overflow: hidden; +} + +table.button td, +table.tiny-button td, +table.small-button td, +table.medium-button td, +table.large-button td { + display: block; + width: auto !important; + text-align: center; + background: #2ba6cb; + border: 1px solid #2284a1; + color: #ffffff; + padding: 8px 0; +} + +table.tiny-button td { + padding: 5px 0 4px; +} + +table.small-button td { + padding: 8px 0 7px; +} + +table.medium-button td { + padding: 12px 0 10px; +} + +table.large-button td { + padding: 21px 0 18px; +} + +table.button td a, +table.tiny-button td a, +table.small-button td a, +table.medium-button td a, +table.large-button td a { + font-weight: bold; + text-decoration: none; + font-family: Helvetica, Arial, sans-serif; + color: #ffffff; + font-size: 16px; +} + +table.tiny-button td a { + font-size: 12px; + font-weight: normal; +} + +table.small-button td a { + font-size: 16px; +} + +table.medium-button td a { + font-size: 20px; +} + +table.large-button td a { + font-size: 24px; +} + +table.button:hover td, +table.button:visited td, +table.button:active td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:visited td a, +table.button:active td a { + color: #fff !important; +} + +table.button:hover td, +table.tiny-button:hover td, +table.small-button:hover td, +table.medium-button:hover td, +table.large-button:hover td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:active td a, +table.button td a:visited, +table.tiny-button:hover td a, +table.tiny-button:active td a, +table.tiny-button td a:visited, +table.small-button:hover td a, +table.small-button:active td a, +table.small-button td a:visited, +table.medium-button:hover td a, +table.medium-button:active td a, +table.medium-button td a:visited, +table.large-button:hover td a, +table.large-button:active td a, +table.large-button td a:visited { + color: #ffffff !important; +} + +table.secondary td { + background: #e9e9e9; + border-color: #d0d0d0; + color: #555; +} + +table.secondary td a { + color: #555; +} + +table.secondary:hover td { + background: #d0d0d0 !important; + color: #555; +} + +table.secondary:hover td a, +table.secondary td a:visited, +table.secondary:active td a { + color: #555 !important; +} + +table.success td { + background: #5da423; + border-color: #457a1a; +} + +table.success:hover td { + background: #457a1a !important; +} + +table.alert td { + background: #c60f13; + border-color: #970b0e; +} + +table.alert:hover td { + background: #970b0e !important; +} + +table.radius td { + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} + +table.round td { + -webkit-border-radius: 500px; + -moz-border-radius: 500px; + border-radius: 500px; +} + +/* Outlook First */ + +body.outlook p { + display: inline !important; +} + +/* Media Queries */ + +@media only screen and (max-width: 600px) { + + table[class="body"] img { + + } + + table[class="body"] center { + min-width: 0 !important; + } + + table[class="body"] .container { + width: 95% !important; + } + + table[class="body"] .row { + width: 100% !important; + display: block !important; + } + + table[class="body"] .wrapper { + display: block !important; + padding-right: 0 !important; + } + + table[class="body"] .columns, + table[class="body"] .column { + table-layout: fixed !important; + float: none !important; + width: 100% !important; + padding-right: 0px !important; + padding-left: 0px !important; + display: block !important; + } + + table[class="body"] .wrapper.first .columns, + table[class="body"] .wrapper.first .column { + display: table !important; + } + + table[class="body"] table.columns td, + table[class="body"] table.column td { + width: 100% !important; + } + + table[class="body"] .columns td.one, + table[class="body"] .column td.one { width: 8.333333% !important; } + table[class="body"] .columns td.two, + table[class="body"] .column td.two { width: 16.666666% !important; } + table[class="body"] .columns td.three, + table[class="body"] .column td.three { width: 25% !important; } + table[class="body"] .columns td.four, + table[class="body"] .column td.four { width: 33.333333% !important; } + table[class="body"] .columns td.five, + table[class="body"] .column td.five { width: 41.666666% !important; } + table[class="body"] .columns td.six, + table[class="body"] .column td.six { width: 50% !important; } + table[class="body"] .columns td.seven, + table[class="body"] .column td.seven { width: 58.333333% !important; } + table[class="body"] .columns td.eight, + table[class="body"] .column td.eight { width: 66.666666% !important; } + table[class="body"] .columns td.nine, + table[class="body"] .column td.nine { width: 75% !important; } + table[class="body"] .columns td.ten, + table[class="body"] .column td.ten { width: 83.333333% !important; } + table[class="body"] .columns td.eleven, + table[class="body"] .column td.eleven { width: 91.666666% !important; } + table[class="body"] .columns td.twelve, + table[class="body"] .column td.twelve { width: 100% !important; } + + table[class="body"] td.offset-by-one, + table[class="body"] td.offset-by-two, + table[class="body"] td.offset-by-three, + table[class="body"] td.offset-by-four, + table[class="body"] td.offset-by-five, + table[class="body"] td.offset-by-six, + table[class="body"] td.offset-by-seven, + table[class="body"] td.offset-by-eight, + table[class="body"] td.offset-by-nine, + table[class="body"] td.offset-by-ten, + table[class="body"] td.offset-by-eleven { + padding-left: 0 !important; + } + + table[class="body"] table.columns td.expander { + width: 1px !important; + } + + table[class="body"] .right-text-pad, + table[class="body"] .text-pad-right { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad, + table[class="body"] .text-pad-left { + padding-right: 10px !important; + } + + table[class="body"] .hide-for-small, + table[class="body"] .show-for-desktop { + display: none !important; + } + + table[class="body"] .show-for-small, + table[class="body"] .hide-for-desktop { + display: inherit !important; + } +} diff --git a/emails/assets/css/style.css b/emails/assets/css/style.css new file mode 100644 index 0000000..83f8d2c --- /dev/null +++ b/emails/assets/css/style.css @@ -0,0 +1,195 @@ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; +} + +h1 {font-size: 40px;} +h2 {font-size: 36px;} +h3 { + font-size: 22px; + margin-top: 10px; + margin-bottom: 10px; +} +h4 {font-size: 20px;} +h5 {font-size: 18px;} +h6 {font-size: 16px;} + +.emphasis { + font-weight: 600; +} + +a { + color: #E67612; + text-decoration: none; +} + +a:hover { + color: #ff8f2b !important; +} + +a:active { + color: #F2821E !important; +} + +a:visited { + color: #E67612 !important; +} + +table.facebook td { + background: #3b5998; + border-color: #2d4473; +} + +table.facebook:hover td { + background: #2d4473 !important; +} + +table.twitter td { + background: #00acee; + border-color: #0087bb; +} + +table.twitter:hover td { + background: #0087bb !important; +} + +table.google-plus td { + background-color: #DB4A39; + border-color: #CC0000; +} + +table.google-plus:hover td { + background: #CC0000 !important; +} + +.template-label { + color: #ffffff; + font-weight: bold; + font-size: 11px; +} + +.callout .wrapper { + padding-bottom: 20px; +} + +.callout .panel { + background: #ECF8FF; + border-color: #b9e5ff; +} + +.header { +margin-top:25px; +margin-bottom: 25px; +} + +.data { + font-size: 16px; +} + +.footer { + background-color: #2e2e2e; + color: #999999; + margin-top: 20px; +} + +@media only screen and (max-width: 600px) { + table[class="body"] .right-text-pad { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad { + padding-right: 10px !important; + } + + .logo { + margin-left: 10px; + } +} + +table.better-button { + margin-top: 10px; + margin-bottom: 20px; +} + +table.columns td.better-button { + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + padding-bottom: 0px; +} + +.better-button a { + text-decoration: none; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + + padding: 12px 25px; + border: 1px solid #ff8f2b; + display: inline-block; + color: #FFF; +} + +.better-button:hover a { + color: #FFFFFF !important; + background-color: #F2821E; + border: 1px solid #F2821E; +} + +.better-button:visited a { + color: #FFFFFF !important; +} + +.better-button:active a { + color: #FFFFFF !important; +} + +table.better-button-alt { + margin-top: 10px; + margin-bottom: 20px; +} + +table.columns td.better-button-alt { + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + padding-bottom: 0px; +} + +.better-button-alt a { + text-decoration: none; + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + + padding: 12px 25px; + border: 1px solid #ff8f2b; + background-color: #EFEFEF; + display: inline-block; + color: #ff8f2b; +} + +.better-button-alt:hover a { + color: #ff8f2b !important; + background-color: #DDDDDD; + border: 1px solid #F2821E; +} + +.better-button-alt:visited a { + color: #ff8f2b !important; +} + +.better-button-alt:active a { + color: #ff8f2b !important; +} + +.verification-code { + background-color: #EEEEEE; + padding: 3px; + margin: 8px; + display: inline-block; + font-weight: bold; + font-size: 20px; +} diff --git a/emails/grunt/aliases.yaml b/emails/grunt/aliases.yaml new file mode 100644 index 0000000..6a2e477 --- /dev/null +++ b/emails/grunt/aliases.yaml @@ -0,0 +1,8 @@ + +default: + - 'clean' + - 'assemble' + - 'replace' + - 'uncss' + - 'processhtml' + - 'premailer' diff --git a/emails/grunt/assemble.js b/emails/grunt/assemble.js new file mode 100644 index 0000000..40686af --- /dev/null +++ b/emails/grunt/assemble.js @@ -0,0 +1,16 @@ +module.exports = function () { + 'use strict'; + return { + options: { + layout: 'templates/layouts/default.html', + partials: ['templates/partials/*.hbs'], + helpers: ['templates/helpers/**/*.js'], + data: [], + flatten: true, + }, + pages: { + src: ['templates/*.html'], + dest: 'dist/', + }, + }; +}; diff --git a/emails/grunt/clean.js b/emails/grunt/clean.js new file mode 100644 index 0000000..a829e29 --- /dev/null +++ b/emails/grunt/clean.js @@ -0,0 +1,5 @@ +module.exports = function (config) { + return { + dist: ['dist'], + }; +}; diff --git a/emails/grunt/premailer.js b/emails/grunt/premailer.js new file mode 100644 index 0000000..34671a7 --- /dev/null +++ b/emails/grunt/premailer.js @@ -0,0 +1,16 @@ +module.exports = { + main: { + options: { + verbose: true, + removeComments: true, + }, + files: [ + { + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: '../public/emails/', // Destination path prefix. + }, + ], + }, +}; diff --git a/emails/grunt/processhtml.js b/emails/grunt/processhtml.js new file mode 100644 index 0000000..777b2d2 --- /dev/null +++ b/emails/grunt/processhtml.js @@ -0,0 +1,12 @@ +module.exports = { + dist: { + files: [ + { + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: 'dist/', // Destination path prefix. + }, + ], + }, +}; diff --git a/emails/grunt/replace.js b/emails/grunt/replace.js new file mode 100644 index 0000000..dff1639 --- /dev/null +++ b/emails/grunt/replace.js @@ -0,0 +1,16 @@ +module.exports = { + dist: { + overwrite: true, + src: ['dist/*.html'], + replacements: [ + { + from: '[[', + to: '{{', + }, + { + from: ']]', + to: '}}', + }, + ], + }, +}; diff --git a/emails/grunt/uncss.js b/emails/grunt/uncss.js new file mode 100644 index 0000000..c1ec535 --- /dev/null +++ b/emails/grunt/uncss.js @@ -0,0 +1,9 @@ +module.exports = { + dist: { + src: ['dist/*.html'], + dest: 'dist/css/tidy.css', + options: { + report: 'min', // optional: include to report savings + }, + }, +}; diff --git a/emails/grunt/watch.js b/emails/grunt/watch.js new file mode 100644 index 0000000..8596d0b --- /dev/null +++ b/emails/grunt/watch.js @@ -0,0 +1,15 @@ +module.exports = { + src: { + files: [ + //what are the files that we want to watch + 'assets/css/*.css', + 'templates/**/*.html', + 'grunt/*.js', + ], + tasks: ['default'], + options: { + nospawn: true, + livereload: false, + }, + }, +}; diff --git a/emails/gruntfile.js b/emails/gruntfile.js new file mode 100644 index 0000000..1864eab --- /dev/null +++ b/emails/gruntfile.js @@ -0,0 +1,4 @@ +module.exports = function (grunt) { + // load grunt config + require('load-grunt-config')(grunt); +}; diff --git a/emails/package.json b/emails/package.json new file mode 100644 index 0000000..f4eb62d --- /dev/null +++ b/emails/package.json @@ -0,0 +1,26 @@ +{ + "name": "Grafana-Email-Campaign", + "version": "1.0.0", + "description": "Grafana Email templates based on Zurb Ink", + "repository": "dnnsldr/", + "author": { + "name": "dnnsldr", + "email": "delder@riester.com", + "url": "https://github.com/dnnsldr" + }, + "scripts": { + "build": "grunt", + "start": "grunt watch" + }, + "devDependencies": { + "grunt": "1.0.1", + "grunt-premailer": "1.1.0", + "grunt-processhtml": "^0.4.2", + "grunt-uncss": "0.9.0", + "load-grunt-config": "3.0.1", + "grunt-contrib-watch": "1.1.0", + "grunt-text-replace": "0.4.0", + "grunt-assemble": "0.6.3", + "grunt-contrib-clean": "2.0.0" + } +} diff --git a/emails/templates/alert_notification.html b/emails/templates/alert_notification.html new file mode 100644 index 0000000..d41104e --- /dev/null +++ b/emails/templates/alert_notification.html @@ -0,0 +1,134 @@ +[[Subject .Subject "[[.Title]]"]] + + + + + +
+ + + + +
+

[[.Title]]

+
+
+ + + + + +
+ + + + +
+

[[.Message]]

+
+
+ +[[if ne .Error "" ]] + + + + +
+
+ + + + + + + +
+
Error message
+
+

[[.Error]]

+
+
+
+[[end]] + +[[if ne .State "ok" ]] + + + + +
+
+ + + + + + [[range .EvalMatches]] + + + + + [[end]] +
+
Metric name
+
+
Value
+
+
[[.Metric]]
+
+
[[.Value]]
+
+
+
+[[end]] + + + + + +
+ + + + +
+ [[if ne .ImageLink "" ]] + Alerting Panel + [[end]] + [[if ne .EmbeddedImage "" ]] + Alerting Panel + [[end]] +
+
+ + + + + + +
+ + + + + +
+ + + + +
+ View your Alert rule +
+
+ + + + +
+ Go to the Alerts page +
+
+
+ + diff --git a/emails/templates/invited_to_org.html b/emails/templates/invited_to_org.html new file mode 100644 index 0000000..6818306 --- /dev/null +++ b/emails/templates/invited_to_org.html @@ -0,0 +1,47 @@ + + +[[Subject .Subject "[[.InvitedBy]] has added you to the [[.OrgName]] organization"]] + + + + + +
+ + + + + + +
+

You have been added to [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + + + + +
+

[[.InvitedBy]] has added you to the [[.OrgName]] organization in Grafana. +

Once logged in, [[.OrgName]] will be available in the left side menu, in the dropdown below your username.

+
+ + + + +
Log in now
+
+
+ + diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html new file mode 100644 index 0000000..80d24d5 --- /dev/null +++ b/emails/templates/layouts/default.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + +
+ + + + + + +
+ +
+ +
+ +
+
+ + + + + + + + +
+ {{> body }} + +
+ + + + + + +
+
+ + diff --git a/emails/templates/new_user_invite.html b/emails/templates/new_user_invite.html new file mode 100644 index 0000000..6e75a20 --- /dev/null +++ b/emails/templates/new_user_invite.html @@ -0,0 +1,49 @@ + + +[[Subject .Subject "[[.InvitedBy]] has invited you to join Grafana"]] + + + + + +
+ + + + + + +
+

You're invited to join [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + + + + + + + +
+

You've been invited to join the [[.OrgName]] organization by [[.InvitedBy]]. To accept your invitation and join the team, please click the link below:

+
+ + + + +
Accept Invitation
+
+

You can also copy/paste this link into your browser directly: [[.LinkUrl]]

+
+
\ No newline at end of file diff --git a/emails/templates/reset_password.html b/emails/templates/reset_password.html new file mode 100644 index 0000000..e9d1527 --- /dev/null +++ b/emails/templates/reset_password.html @@ -0,0 +1,42 @@ +[[Subject .Subject "Reset your Grafana password - [[.Name]]"]] + + + + + +
+ + + + + + +
+

Hi [[.Name]],

+
+ +
+ + + + + +
+ + + + + +
+

+ Please click the following link to reset your password within [[.EmailCodeValidHours]] hours. +

+

+ [[.AppUrl]]user/password/reset?code=[[.Code]] +

+

Not working? Try copying and pasting it to your browser.

+
+ +
+ + diff --git a/emails/templates/signup_started.html b/emails/templates/signup_started.html new file mode 100644 index 0000000..80b4c32 --- /dev/null +++ b/emails/templates/signup_started.html @@ -0,0 +1,46 @@ +[[Subject .Subject "Welcome to Grafana, please complete your sign up!"]] + + + + + +
+ + + + + + +
+

Complete the signup

+
+ +
+ + + + + +
+ + + + + + + + +
+ Copy and paste the email verification code:
+ [[.Code]]
in + the sign up form or use the link below. +
+ + + + +
Complete Sign Up
+
+
+ + diff --git a/emails/templates/welcome_on_signup.html b/emails/templates/welcome_on_signup.html new file mode 100644 index 0000000..c75b68f --- /dev/null +++ b/emails/templates/welcome_on_signup.html @@ -0,0 +1,48 @@ +[[Subject .Subject "Welcome to Grafana"]] + + + + + +
+ + + + + + + + + +
+

Hi [[.Name]],

+
+ Welcome! Ready to start building some beautiful metric and analytic dashboards? +
+ +
+ + + + + +
+ + + + + + + + +
+

+ If you are new to Grafana please read the Getting Started guide. +

+
+ Thank you for joining our community. +
+

The Grafana Team

+
+
+ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2161850 --- /dev/null +++ b/go.mod @@ -0,0 +1,107 @@ +module github.com/grafana/grafana + +go 1.15 + +// Override xorm's outdated go-mssqldb dependency, since we can't upgrade to current xorm (due to breaking changes). +// We need a more current go-mssqldb so we get rid of a version of apache/thrift with vulnerabilities. +// Also, use our fork with fixes for unimplemented methods (required for Go 1.16). +replace github.com/denisenkom/go-mssqldb => github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 + +// Override k8s.io/client-go outdated dependency, which is an indirect dependency of grafana/loki. +// It's also present on grafana/loki's go.mod so we'll need till it gets updated. +replace k8s.io/client-go => k8s.io/client-go v0.18.8 + +require ( + cloud.google.com/go/storage v1.13.0 + github.com/BurntSushi/toml v0.3.1 + github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f + github.com/aws/aws-sdk-go v1.37.20 + github.com/beevik/etree v1.1.0 + github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 + github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b + github.com/centrifugal/centrifuge v0.13.0 + github.com/cortexproject/cortex v1.4.1-0.20201022071705-85942c5703cf + github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce + github.com/davecgh/go-spew v1.1.1 + github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204 + github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect + github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b + github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect + github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691 // indirect + github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect + github.com/fatih/color v1.10.0 + github.com/gchaincl/sqlhooks v1.3.0 + github.com/getsentry/sentry-go v0.10.0 + github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b + github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible + github.com/go-sql-driver/mysql v1.5.0 + github.com/go-stack/stack v1.8.0 + github.com/gobwas/glob v0.2.3 + github.com/golang/mock v1.5.0 + github.com/golang/protobuf v1.4.3 + github.com/google/go-cmp v0.5.4 + github.com/google/uuid v1.2.0 + github.com/gosimple/slug v1.9.0 + github.com/grafana/grafana-aws-sdk v0.3.0 + github.com/grafana/grafana-aws-sdk v0.4.0 + github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 + github.com/grafana/grafana-plugin-sdk-go v0.88.0 + github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387 + github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 + github.com/hashicorp/go-hclog v0.15.0 + github.com/hashicorp/go-plugin v1.4.0 + github.com/hashicorp/go-version v1.2.1 + github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec + github.com/influxdata/influxdb-client-go/v2 v2.2.0 + github.com/jaegertracing/jaeger v1.22.0 + github.com/jmespath/go-jmespath v0.4.0 + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/json-iterator/go v1.1.10 + github.com/jung-kurt/gofpdf v1.16.2 + github.com/lib/pq v1.9.0 + github.com/linkedin/goavro/v2 v2.10.0 + github.com/magefile/mage v1.11.0 + github.com/mattn/go-isatty v0.0.12 + github.com/mattn/go-sqlite3 v1.14.6 + github.com/opentracing/opentracing-go v1.2.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.9.0 + github.com/prometheus/client_model v0.2.0 + github.com/prometheus/common v0.18.0 + github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 + github.com/robfig/cron/v3 v3.0.1 + github.com/russellhaering/goxmldsig v1.1.0 + github.com/smartystreets/goconvey v1.6.4 + github.com/stretchr/testify v1.7.0 + github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf + github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff + github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 + github.com/uber/jaeger-client-go v2.25.0+incompatible + github.com/unknwon/com v1.0.1 + github.com/urfave/cli/v2 v2.3.0 + github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec + github.com/xorcare/pointer v1.1.0 + github.com/yudai/gojsondiff v1.0.0 + go.opentelemetry.io/collector v0.21.0 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad + golang.org/x/net v0.0.0-20210119194325-5f4716e94777 + golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 + golang.org/x/sync v0.0.0-20201207232520-09787c993a3a + golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e + gonum.org/v1/gonum v0.8.2 + google.golang.org/api v0.40.0 + google.golang.org/grpc v1.36.0 + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect + gopkg.in/ini.v1 v1.62.0 + gopkg.in/ldap.v3 v3.0.2 + gopkg.in/macaron.v1 v1.4.0 + gopkg.in/mail.v2 v2.3.1 + gopkg.in/redis.v5 v5.2.9 + gopkg.in/square/go-jose.v2 v2.5.1 + gopkg.in/yaml.v2 v2.4.0 + xorm.io/core v0.7.3 + xorm.io/xorm v0.8.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df1510f --- /dev/null +++ b/go.sum @@ -0,0 +1,2225 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0 h1:XgtDnVJRCPEUG21gjFiRPz4zI1Mjg16R+NYQjfmU4XY= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigtable v1.1.0/go.mod h1:B6ByKcIdYmhoyDzmOnQxyOhN6r05qnewYIxxG6L0/b4= +cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.13.0 h1:amPvhCOI+Hltp6rPu+62YdwhIrjf+34PKVAL4HwgYwk= +cloud.google.com/go/storage v1.13.0/go.mod h1:pqFyBUK3zZqMIIU5+8NaZq6/Ma3ClgUg9Hv5jfuJnvo= +code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= +collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= +contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= +contrib.go.opencensus.io/exporter/prometheus v0.2.0/go.mod h1:TYmVAyE8Tn1lyPcltF5IYYfWp2KHu7lQGIZnj8iZMys= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= +github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= +github.com/Azure/azure-sdk-for-go v23.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v36.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v43.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v44.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v44.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v45.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v46.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.9.3-0.20191028180845-3492b2aff503/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= +github.com/Azure/go-autorest/autorest v0.11.2/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest v0.11.4/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest v0.11.10/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.1-0.20191028180845-3492b2aff503/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= +github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/adal v0.8.3/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= +github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA= +github.com/Azure/go-autorest/autorest/to v0.3.1-0.20191028180845-3492b2aff503/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA= +github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI= +github.com/Azure/go-autorest/autorest/validation v0.2.1-0.20191028180845-3492b2aff503/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/DataDog/zstd v1.3.6-0.20190409195224-796139022798/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.4.4/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/FZambia/eagle v0.0.1 h1:FN1yTkPihMb5nE8SrlRjoCf7T9H9bTKJFQOm6ach2YU= +github.com/FZambia/eagle v0.0.1/go.mod h1:xq6u/JeNZ5/8mrAQ76MMhzNTodASh9FavQlCgg4j48w= +github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8= +github.com/FZambia/sentinel v1.1.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= +github.com/HdrHistogram/hdrhistogram-go v0.9.0 h1:dpujRju0R4M/QZzcnR1LH1qm+TVG3UzkWdp5tH1WMcg= +github.com/HdrHistogram/hdrhistogram-go v0.9.0/go.mod h1:nxrse8/Tzg2tg3DZcZjm6qEclQKK70g0KxO61gFFZD4= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/squirrel v0.0.0-20161115235646-20f192218cf5/go.mod h1:xnKTFzjGUiZtiOagBsfnvomW+nJg2usB1ZpordQWqNM= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/OneOfOne/xxhash v1.2.6 h1:U68crOE3y3MPttCMQGywZOLrTeF5HHJ3/vDBCJn9/bA= +github.com/OneOfOne/xxhash v1.2.6/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/sarama v1.22.2-0.20190604114437-cd910a683f9f/go.mod h1:XLH1GYJnLVE0XCr6KdJGVJRTwY30moWNJ4sERjXX6fs= +github.com/Shopify/sarama v1.28.0/go.mod h1:j/2xTrU39dlzBmsxF1eQ2/DdWrxyBCl6pzz7a81o/ZY= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f h1:HR5nRmUQgXrwqZOwZ2DAc/aCi3Bu3xENpspW935vxu0= +github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3HiCrHjHBdcm6E83vGaXh1KomZMA2P6aeo3hKx/wg0= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/aliyun/aliyun-oss-go-sdk v2.0.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antonmedv/expr v1.8.9/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= +github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= +github.com/apache/arrow/go/arrow v0.0.0-20200629181129-68b1273cbbf7 h1:dgL2mSOuj63SXOyojjWKq2ni3FQpQ+KrLKD7Pbq6t/4= +github.com/apache/arrow/go/arrow v0.0.0-20200629181129-68b1273cbbf7/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0 h1:5hryIiq9gtn+MiLVn0wP37kb/uTeRZgN08WoCsAhIhI= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= +github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= +github.com/armon/go-metrics v0.3.3/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-lambda-go v1.17.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= +github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= +github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.33.5/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.33.12/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= +github.com/aws/aws-sdk-go v1.35.30/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= +github.com/aws/aws-sdk-go v1.37.20 h1:CJCXpMYmBJrRH8YwoSE0oB9S3J5ax+62F14sYlDCztg= +github.com/aws/aws-sdk-go v1.37.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg= +github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 h1:wOysYcIdqv3WnvwqFFzrYCFALPED7qkUGaLXu359GSc= +github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3/go.mod h1:UMqtWQTnOe4byzwe7Zhwh8f8s+36uszN51sJrSIZlTE= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmatcuk/doublestar v1.2.2/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bsm/sarama-cluster v2.1.13+incompatible/go.mod h1:r7ao+4tTNXvWm+VRpRJchr2kQhqxgmAp2iEX5W96gMM= +github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= +github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee h1:BnPxIde0gjtTnc9Er7cxvBk8DHLWhEux0SxayC8dP6I= +github.com/c2h5oh/datasize v0.0.0-20200112174442-28bbd4740fee/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v0.0.0-20181003080854-62661b46c409/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v1.0.0/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/centrifugal/centrifuge v0.13.0 h1:nfcpaa/TXoA56ba/xXs3rc2Vrj/UZTFS1iTQ9q2MSZE= +github.com/centrifugal/centrifuge v0.13.0/go.mod h1:jdFw/2dBFpME3OTisc5FVAC1ilqodnyMnQFjem0k0yg= +github.com/centrifugal/centrifuge-go v0.6.5/go.mod h1:YW9BKhnEMOBPU7C/wfqdqzhIiF0xRd0R4sHW82a7sf8= +github.com/centrifugal/protocol v0.3.4 h1:9q22iSp4CQOdQahfopvfmWWxDbj1Lo7ERG2j56mAxkE= +github.com/centrifugal/protocol v0.3.4/go.mod h1:2YbBCaDwQHl37ErRdMrKSj18X2yVvpkQYtSX6aVbe5A= +github.com/cespare/xxhash v0.0.0-20181017004759-096ff4a8a059/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/chromedp/cdproto v0.0.0-20200116234248-4da64dd111ac/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= +github.com/chromedp/cdproto v0.0.0-20200424080200-0de008e41fa0/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= +github.com/chromedp/chromedp v0.5.3/go.mod h1:YLdPtndaHQ4rCpSpBG+IPpy9JvX0VD+7aaLxYgYj28w= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= +github.com/cockroachdb/datadriven v0.0.0-20190531201743-edce55837238/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd h1:qMd81Ts1T2OTKmB4acZcyKaMtRnY5Y44NuXGX2GFJ1w= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/containerd/containerd v1.2.7/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cortexproject/cortex v0.6.1-0.20200228110116-92ab6cbe0995/go.mod h1:3Xa3DjJxtpXqxcMGdk850lcIRb81M0fyY1MQ6udY134= +github.com/cortexproject/cortex v1.2.1-0.20200805064754-d8edc95e2c91/go.mod h1:PVPxNLrxKH+yc8asaJOxuz7TiRmMizFfnSMOnRzM6oM= +github.com/cortexproject/cortex v1.3.1-0.20200923145333-8587ea61fe17/go.mod h1:dJ9gpW7dzQ7z09cKtNN9PfebumgyO4dtNdFQ6eQEed0= +github.com/cortexproject/cortex v1.4.1-0.20201022071705-85942c5703cf h1:TGmSZFMNUP0U3bFMPeaVo1TFWlFaBtxrwS5Rs0zmFCs= +github.com/cortexproject/cortex v1.4.1-0.20201022071705-85942c5703cf/go.mod h1:MBJnS5mzVcHqivBp2391HpflMeMiT+f8r4VNkJlsZFs= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da/go.mod h1:+rmNIXRvYMqLQeR4DHyTvs6y0MEMymTz4vyFpFkKTPs= +github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce h1:pAuTpLhCqC20s2RLhUirfw606jReW+8z2U5EvG+0S7E= +github.com/crewjam/saml v0.4.6-0.20201227203850-bca570abb2ce/go.mod h1:/gCaeLf13J8/621RNZ6TaExji/8xCWcn6UmdJ57wURQ= +github.com/crossdock/crossdock-go v0.0.0-20160816171116-049aabb0122b/go.mod h1:v9FBN7gdVTpiD/+LZ7Po0UKvROyT87uLVxTHVky/dlQ= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= +github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= +github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= +github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc= +github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4= +github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A= +github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= +github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE= +github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ= +github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc= +github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8= +github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= +github.com/deepmap/oapi-codegen v1.3.13 h1:9HKGCsdJqE4dnrQ8VerFS0/1ZOJPmAhN+g8xgp8y3K4= +github.com/deepmap/oapi-codegen v1.3.13/go.mod h1:WAmG5dWY8/PYHt4vKxlt90NsbHMAOCiteYKZMiIRfOo= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-bitstream v0.0.0-20180413035011-3522498ce2c8/go.mod h1:VMaSuZ+SZcx/wljOQKvp5srsbCiKDEb6K2wC4+PiBmQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= +github.com/digitalocean/godo v1.37.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.38.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.42.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.42.1/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/digitalocean/godo v1.46.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= +github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v0.7.3-0.20190103212154-2b7e084dc98b/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v0.7.3-0.20190817195342-4760db040282/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v17.12.0-ce-rc1.0.20200706150819-a40b877fbb9e+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v17.12.0-ce-rc1.0.20201009160326-9c15e82f19b0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-plugins-helpers v0.0.0-20181025120712-1e6269c305b8/go.mod h1:LFyLie6XcDbyKGeVK6bHe+9aJTYCxWLBg5IrJZOaXKA= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elastic/go-sysinfo v1.0.1/go.mod h1:O/D5m1VpYLwGjCYzEt63g3Z1uO3jXfwyzzjiW90t8cY= +github.com/elastic/go-sysinfo v1.1.1/go.mod h1:i1ZYdU10oLNfRzq4vq62BEwD2fH8KaWh6eh0ikPT9F0= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/ema/qdisc v0.0.0-20190904071900-b82c76788043/go.mod h1:ix4kG2zvdUd8kEKSW0ZTr1XLks0epFpI4j745DXxlNE= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/evanphx/json-patch v0.0.0-20200808040245-162e5629780b/go.mod h1:NAJj0yf/KaRKURN6nyi7A9IZydMivZEm9oQLWNjfKDc= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b h1:V6c4/dSTNhSaNn4c5ulbakfv277qCvs7byFYv7P83iQ= +github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b/go.mod h1:oO8UHw+fDHjDsk4CTy/E96WDzFUYozAtBAaGNoVL0+c= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691 h1:KnnwHN59Jxec0htA2pe/i0/WI9vxXLQifdhBrP3lqcQ= +github.com/facebookgo/structtag v0.0.0-20150214074306-217e25fb9691/go.mod h1:sKLL1iua/0etWfo/nPCmyz+v2XDMXy+Ho53W7RAuZNY= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fatih/structtag v1.1.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/fgprof v0.9.1/go.mod h1:7/HK6JFtFaARhIljgP2IV8rJLIoHDoOYoUphsnGvqxE= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluent/fluent-bit-go v0.0.0-20190925192703-ea13c021720c/go.mod h1:WQX+afhrekY9rGK+WT4xvKSlzmia9gDoLYu4GGYGASQ= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/frankban/quicktest v1.7.3/go.mod h1:V1d2J5pfxYH6EjBAgSK7YNXcXlTWxUHdE1sVDXkjnig= +github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK2Y= +github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/getkin/kin-openapi v0.13.0/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw= +github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g= +github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b h1:U65wj9SF7qUBTGrnt6VxbHCT0Dw8dz4uch52G+5SdfA= +github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b/go.mod h1:AG8Z6qkQM8s47aUDJOco/SNwJ8Czif2hMm7rc0abDog= +github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 h1:YSIA98PevNf1NtCa/J6cz7gjzpz99WVAOa9Eg0klKps= +github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07/go.mod h1://cJFfDp/70L0oTNAMB+M8Jd0rpuIx/55iARuJ6StwE= +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI= +github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.17.2/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.4/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= +github.com/go-openapi/analysis v0.19.10/go.mod h1:qmhS3VNFxBlquFJ0RGoDtylO9y4pgTAUNE9AEEMdlJQ= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.17.2/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.3/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/errors v0.19.4/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.17.2/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.17.2/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.17.2/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/loads v0.19.3/go.mod h1:YVfqhUCdahYwR3f3iiwQLhicVRvLlU/WO5WPaZvcvSI= +github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= +github.com/go-openapi/loads v0.19.5/go.mod h1:dswLCAdonkRufe/gSUC3gN8nTSaB9uaS2es0x5/IbjY= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.18.0/go.mod h1:uI6pHuxWYTy94zZxgcwJkUWa9wbIlhteGfloI10GD4U= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/runtime v0.19.3/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/runtime v0.19.15/go.mod h1:dhGWCTKRXlAfGnQG0ONViOZpjfg0m2gUt9nTQPQZuoo= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.17.2/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/spec v0.19.6/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.7/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.19.8/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.17.2/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/strfmt v0.19.2/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.4/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.17.2/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.4/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.7/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/validate v0.17.2/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7vS9k0lo6zwJo= +github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= +github.com/go-redis/redis/v8 v8.0.0-beta.10.0.20200905143926-df7fe4e2ce72/go.mod h1:CJP1ZIHwhosNYwIdaHPZK9vHsM3+roNBaZ7U9Of1DXc= +github.com/go-redis/redis/v8 v8.2.3/go.mod h1:ysgGY09J/QeDYbu3HikWEIPCwaeOkuNoTgKayTEaEOw= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y= +github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= +github.com/gocql/gocql v0.0.0-20200121121104-95d072f1b5bb/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/gocql/gocql v0.0.0-20200228163523-cd4b606dd2fb/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/gocql/gocql v0.0.0-20200526081602-cd04bd7f22a7/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= +github.com/godbus/dbus v0.0.0-20190402143921-271e53dc4968/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.3.0 h1:M695OaDJ5ipWvDPcoAg/YL9c3uORAegkEfBqTQF/fTQ= +github.com/gogo/googleapis v1.3.0/go.mod h1:d+q1s/xVJxZGKWwC/6UfPIF33J+G1Tq4GYv9Y+Tg/EU= +github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.2.2-0.20190730201129-28a6bbf47e48/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.0.3 h1:WkVBY59mw7qUNTr/bLwO7J2vesJ0rQ2C3tMXrTd3w5M= +github.com/gogo/status v1.0.3/go.mod h1:SavQ51ycCLnc7dGyJxp8YAmudx8xqiVrRf+6IXRsugc= +github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/flatbuffers v1.11.0 h1:O7CEyB8Cb3/DmtxODGtLHcEvpr81Jm5qLg/hsHnxA2A= +github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200615235658-03e1cf38a040/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201007051231-1066cbb265c7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.2+incompatible h1:silFMLAnr330+NRuag/VjIGF7TLp/LBrV2CJKFLWEww= +github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/googleapis/gnostic v0.4.0/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gophercloud/gophercloud v0.3.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gophercloud/gophercloud v0.6.0/go.mod h1:GICNByuaEBibcjmjvI7QvYJSZEbGkcYwAR7EZK2WMqM= +github.com/gophercloud/gophercloud v0.11.0/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss= +github.com/gophercloud/gophercloud v0.12.0/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss= +github.com/gophercloud/gophercloud v0.13.0/go.mod h1:VX0Ibx85B60B5XOrZr6kaNwrmPUzcmMpwxvQ1WQIIWM= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de h1:F7WD09S8QB4LrkEpka0dFPLSotH11HRpCsLIbIcJ7sU= +github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= +github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= +github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036 h1:GplhUk6Xes5JIhUUrggPcPBhOn+eT8+WsHiebvq7GgA= +github.com/grafana/go-mssqldb v0.0.0-20210326084033-d0ce3c521036/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/grafana/grafana-aws-sdk v0.2.0 h1:UTBBYwye+ad5YUIlwN7TGxLdz1wXN3Ezhl0pseDGRVA= +github.com/grafana/grafana-aws-sdk v0.2.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= +github.com/grafana/grafana-aws-sdk v0.3.0 h1:UT3rIXQFeAh0OaRJT7dUQojYaSjbI9RwOtMacaerv8I= +github.com/grafana/grafana-aws-sdk v0.3.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= +github.com/grafana/grafana-aws-sdk v0.4.0 h1:JmTaXfOJ/ydHSWH9kEt8Yhfb9kAhIW4LUOO3SWCviYg= +github.com/grafana/grafana-aws-sdk v0.4.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= +github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag= +github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To= +github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= +github.com/grafana/grafana-plugin-sdk-go v0.88.0 h1:Up4HGwlzHTb3XGljteirrqY7g0UNbMC46/eYNqVhHdw= +github.com/grafana/grafana-plugin-sdk-go v0.88.0/go.mod h1:PTALh0lz+Y7k0+OMczAABTpeocL63aw6FVOBptp5GVo= +github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387 h1:iwcM8lkYJ3EhytGLJ2BvRSwutb0QWoI7EWbYv3yJRsY= +github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387/go.mod h1:jHA1OHnPsuj3LLgMXmFopsKDt4ARHHUhrmT3JrGf71g= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.1.0/go.mod h1:f5nM7jw/oeRSadq3xCzHAvxcr8HZnzsqU6ILg/0NiiE= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.0/go.mod h1:mJzapYve32yjrKlk9GbyCZHuPgZsrbyIbyKhSzOpg6s= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 h1:FlFbCRLd5Jr4iYXZufAvgWN6Ao0JrI5chLINnUXDDr0= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= +github.com/grpc-ecosystem/grpc-gateway v1.4.1/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.4/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.12.1/go.mod h1:8XEsbTttt/W+VvjtQhLACqCisSPWTxCZ7sBRjU6iH9c= +github.com/grpc-ecosystem/grpc-gateway v1.14.5/go.mod h1:UJ0EZAp832vCd54Wev9N1BMKEyvcZ5+IM0AwDrnlkEc= +github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw= +github.com/grpc-ecosystem/grpc-gateway v1.15.0/go.mod h1:vO11I9oWA+KsxmfFQPhLnnIb1VDE24M+pdxZFiuZcA8= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/api v1.4.0/go.mod h1:xc8u05kyMa3Wjr9eEAsIAo3dg8+LywT5E/Cl7cNS5nU= +github.com/hashicorp/consul/api v1.5.0/go.mod h1:LqwrLNW876eYSuUOo4ZLHBcdKc038txr/IMfbLPATa4= +github.com/hashicorp/consul/api v1.6.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= +github.com/hashicorp/consul/api v1.7.0/go.mod h1:1NSuaUUkFaJzMasbfq/11wKYWSR67Xn6r2DXKhuDNFg= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.4.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/consul/sdk v0.5.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/consul/sdk v0.6.0/go.mod h1:fY08Y9z5SvJqevyZNy6WWPXiG3KwBPAvlcdx16zZ0fM= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.12.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.14.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.15.0 h1:qMuK0wxsoW4D0ddCCYwPSTm4KQv1X1ke3WmPWZ0Mvsk= +github.com/hashicorp/go-hclog v0.15.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.2.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= +github.com/hashicorp/go-plugin v1.2.2/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= +github.com/hashicorp/go-plugin v1.4.0 h1:b0O7rs5uiJ99Iu9HugEzsM67afboErkHUWddUSpUO3A= +github.com/hashicorp/go-plugin v1.4.0/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.1.4/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.1.5/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.2.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= +github.com/hashicorp/serf v0.8.5/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= +github.com/hashicorp/serf v0.9.0/go.mod h1:YL0HO+FifKOW2u1ke99DGVu1zhcpZzNwrLIqBC7vbYU= +github.com/hashicorp/serf v0.9.3/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d h1:W+SIwDdl3+jXWeidYySAgzytE3piq6GumXeBjFBG67c= +github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hetznercloud/hcloud-go v1.21.1/go.mod h1:xng8lbDUg+xM1dgc0yGHX5EeqbwIq7UYlMWMTx3SQVg= +github.com/hetznercloud/hcloud-go v1.22.0/go.mod h1:xng8lbDUg+xM1dgc0yGHX5EeqbwIq7UYlMWMTx3SQVg= +github.com/hodgesds/perf-utils v0.0.8/go.mod h1:F6TfvsbtrF88i++hou29dTXlI2sfsJv+gRZDtmTJkAs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/igm/sockjs-go/v3 v3.0.0 h1:4wLoB9WCnQ8RI87cmqUH778ACDFVmRpkKRCWBeuc+Ww= +github.com/igm/sockjs-go/v3 v3.0.0/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec h1:CGkYB1Q7DSsH/ku+to+foV4agt2F2miquaLUgF6L178= +github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/flux v0.65.0/go.mod h1:BwN2XG2lMszOoquQaFdPET8FRQfrXiZsWmcMO9rkaVY= +github.com/influxdata/flux v0.65.1/go.mod h1:J754/zds0vvpfwuq7Gc2wRdVwEodfpCFM7mYlOw2LqY= +github.com/influxdata/go-syslog/v3 v3.0.1-0.20200510134747-836dce2cf6da/go.mod h1:aXdIdfn2OcGnMhOTojXmwZqXKgC3MU5riiNvzwwG9OY= +github.com/influxdata/influxdb v1.7.7/go.mod h1:qZna6X/4elxqT3yI9iZYdZrWWdeFOOprn86kgg4+IzY= +github.com/influxdata/influxdb v1.8.0/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.1/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.2/go.mod h1:SIzcnsjaHRFpmlxpJ4S3NT64qtEKYweNTUMb/vh0OMQ= +github.com/influxdata/influxdb v1.8.3/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI= +github.com/influxdata/influxdb-client-go/v2 v2.2.0 h1:2R/le0s/MZpHtc+ijuXKe2c4KGN14M85mWtGlmg6vec= +github.com/influxdata/influxdb-client-go/v2 v2.2.0/go.mod h1:fa/d1lAdUHxuc1jedx30ZfNG573oQTQmUni3N6pcW+0= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/influxdata/influxql v1.1.0/go.mod h1:KpVI7okXjK6PRi3Z5B+mtKZli+R1DnZgb3N+tzevNgo= +github.com/influxdata/influxql v1.1.1-0.20200828144457-65d3ef77d385/go.mod h1:gHp9y86a/pxhjJ+zMjNXiQAA197Xk9wLxaz+fGG+kWk= +github.com/influxdata/line-protocol v0.0.0-20180522152040-32c6aa80de5e/go.mod h1:4kt73NQhadE3daL3WhR5EJ/J2ocX0PZzwxQ0gXJ7oFE= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/promql/v2 v2.12.0/go.mod h1:fxOPu+DY0bqCTCECchSRtWfc+0X19ybifQhZoQNF5D8= +github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bSgUQ7q5ZLSO+bKBGqJiCBGAl+9DxyW63zLTujjUlOE= +github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= +github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= +github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jaegertracing/jaeger v1.21.0/go.mod h1:PCTGGFohQBPQMR4j333V5lt6If7tj8aWJ+pQNgvZ+wU= +github.com/jaegertracing/jaeger v1.22.0 h1:kFBhBn9XSB8V68DjD3t6qb/IUAJLLtyJ/27caGQOu7E= +github.com/jaegertracing/jaeger v1.22.0/go.mod h1:WnwW68MjJEViSLRQhe0nkIsBDaF3CzfFd8wJcpJv24k= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v0.0.0-20190328161633-dc7c13fece03/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= +github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= +github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.2.1/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/joncrlsn/dque v2.2.1-0.20200515025108-956d14155fa2+incompatible/go.mod h1:hDZb8oMj3Kp8MxtbNLg9vrtAUDHjgI1yZvqivT4O8Iw= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20190830100107-3784a6c7c552/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= +github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg= +github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= +github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= +github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.0.0/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE= +github.com/kylelemons/godebug v0.0.0-20160406211939-eadb3ce320cb/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= +github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= +github.com/leoluk/perflib_exporter v0.1.0/go.mod h1:rpV0lYj7lemdTm31t7zpCqYqPnw7xs86f+BaaNBVYFM= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8= +github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/linkedin/goavro/v2 v2.10.0 h1:eTBIRoInBM88gITGXYtUSqqxLTFXfOsJBiX8ZMW0o4U= +github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= +github.com/lovoo/gcloud-opentracing v0.3.0/go.mod h1:ZFqk2y38kMDDikZPAK7ynTTGuyt17nSPdS3K5e+ZTBY= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= +github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= +github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e h1:qqXczln0qwkVGcpQ+sQuPOVntt2FytYarXXxYSNJkgw= +github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20191113090002-7c0f6868bffe/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-tty v0.0.0-20180907095812-13ff1204f104/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= +github.com/mattn/go-xmlrpc v0.0.3/go.mod h1:mqc2dz7tP5x5BKlCahN/n+hs7OSZKJkS9JsHNBRlrxA= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/matttproud/golang_protobuf_extensions v1.0.0/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= +github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= +github.com/mdlayher/netlink v0.0.0-20190828143259-340058475d09/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= +github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= +github.com/mdlayher/wifi v0.0.0-20190303161829-b1436901ddee/go.mod h1:Evt/EIne46u9PtQbeTx2NTcqURpr5K4SvKtGmBuDPN8= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.15/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/minio/md5-simd v1.1.0/go.mod h1:XpBqgZULrMYD3R+M28PcmP0CkI7PEMzB3U77ZrKZ0Gw= +github.com/minio/minio-go/v6 v6.0.44/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tBvN2PaZg7Gg= +github.com/minio/minio-go/v6 v6.0.56/go.mod h1:KQMM+/44DSlSGSQWSfRrAZ12FVMmpWNuX37i2AX0jfI= +github.com/minio/minio-go/v7 v7.0.2/go.mod h1:dJ80Mv2HeGkYLH1sqS/ksz07ON6csH3S6JUMSQ2zAns= +github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg= +github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE= +github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= +github.com/mna/redisc v1.1.7 h1:FdmtJsfTjoIjNXiQf4ozgNjuE+zxWH+fJSe+I/dD4vc= +github.com/mna/redisc v1.1.7/go.mod h1:GXeOb7zyYKiT+K8MKdIiJvuv7MfhDoQGcuzfiJQmqQI= +github.com/moby/term v0.0.0-20200915141129-7f0af18e79f2/go.mod h1:TjQg8pa4iejrUrjiz0MCtMV38jdMNW4doKSiBrEvCQQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= +github.com/mozillazg/go-cos v0.13.0/go.mod h1:Zp6DvvXn0RUOXGJ2chmWt2bLEqRAnJnS3DnAZsJsoaE= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= +github.com/ncw/swift v1.0.50/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oklog/ulid v0.0.0-20170117200651-66bb6560562f/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ= +github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/olivere/elastic v6.2.27+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/olivere/elastic v6.2.35+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4= +github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= +github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opentracing-contrib/go-grpc v0.0.0-20180928155321-4b5a12d3ff02/go.mod h1:JNdpVEzCpXBgIiv4ds+TzhN1hrtxq6ClLrTlT9OQRSc= +github.com/opentracing-contrib/go-grpc v0.0.0-20191001143057-db30781987df h1:vdYtBU6zvL7v+Tr+0xFM/qhahw/EvY8DMMunZHKH6eE= +github.com/opentracing-contrib/go-grpc v0.0.0-20191001143057-db30781987df/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing-contrib/go-stdlib v0.0.0-20190519235532-cf7a6c988dc9/go.mod h1:PLldrQSroqzH70Xl+1DQcGnefIbqsKR7UDaiux3zV+w= +github.com/opentracing-contrib/go-stdlib v1.0.0 h1:TBS7YuVotp8myLon4Pv7BtCBzOTo1DeZCld0Z63mW2w= +github.com/opentracing-contrib/go-stdlib v1.0.0/go.mod h1:qtI1ogk+2JhVPIXVc6q+NHziSmy2W5GbdQZFUHADCBU= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.0.3-0.20180606204148-bd9c31933947/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.1-0.20200124165624-2876d2018785/go.mod h1:C+iumr2ni468+1jvcHXLCdqP9uQnoQbdX93F3aWahWU= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= +github.com/orijtech/prometheus-go-metrics-exporter v0.0.6/go.mod h1:BiTx/ugZex8LheBk3j53tktWaRdFjV5FCfT2o0P7msE= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pierrec/cmdflag v0.0.2/go.mod h1:a3zKGZ3cdQUfxjd0RGMLZr8xI3nvpJOB+m6o/1X5BmU= +github.com/pierrec/lz4 v0.0.0-20190327172049-315a67e90e41/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4 v2.4.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4 v2.6.0+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.0.2-0.20200813132121-22f5d580d5c4/go.mod h1:vvUajMAuienWCEdMnA5Zb5mp0VIa9M8VvKcVEOkoAh8= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ= +github.com/prometheus/alertmanager v0.18.0/go.mod h1:WcxHBl40VSPuOaqWae6l6HpnEOVRIycEJ7i9iYkadEE= +github.com/prometheus/alertmanager v0.19.0/go.mod h1:Eyp94Yi/T+kdeb2qvq66E3RGuph5T/jm/RBVh4yz1xo= +github.com/prometheus/alertmanager v0.20.0/go.mod h1:9g2i48FAyZW6BtbsnvHtMHQXl2aVtrORKwKVCQ+nbrg= +github.com/prometheus/alertmanager v0.21.0/go.mod h1:h7tJ81NA0VLWvWEayi1QltevFkLF3KxmC/malTcT8Go= +github.com/prometheus/alertmanager v0.21.1-0.20200911160112-1fdff6b3f939/go.mod h1:imXRHOP6QTsE0fFsIsAV/cXimS32m7gVZOiUj11m6Ig= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.2.0/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.4.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.9.0 h1:Rrch9mh17XcxvEu9D9DEpb4isxjGBtcevQjKvxPRQIU= +github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU= +github.com/prometheus/client_model v0.0.0-20170216185247-6f3806018612/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180518154759-7600349dcfe1/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.8.0/go.mod h1:PC/OgXc+UN7B4ALwvn1yzVZmVwvhXp5JsbBv6wSv6i0= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.11.1/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.12.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.14.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.17.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.18.0 h1:WCVKW7aL6LEe1uryfI9dnEc2ZqNB1Fn0ok930v0iL1Y= +github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289 h1:dTUS1vaLWq+Y6XKOTnrFpoVsQKLCbCp1OLj24TDi7oM= +github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289/go.mod h1:FGbBv5OPKjch+jNUJmEQpMZytIdyW0NdBtWFcfSKusc= +github.com/prometheus/procfs v0.0.0-20180612222113-7d6f385de8be/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.6/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/prometheus v0.0.0-20180315085919-58e2a31db8de/go.mod h1:oAIUtOny2rjMX0OWN5vPR5/q/twIROJvdqnQKDdil/s= +github.com/prometheus/prometheus v0.0.0-20190818123050-43acd0e2e93f/go.mod h1:rMTlmxGCvukf2KMu3fClMDKLLoJ5hl61MhcJ7xKakf0= +github.com/prometheus/prometheus v1.8.2-0.20200107122003-4708915ac6ef/go.mod h1:7U90zPoLkWjEIQcy/rweQla82OCTUzxVHE51G3OhJbI= +github.com/prometheus/prometheus v1.8.2-0.20200213233353-b90be6f32a33/go.mod h1:fkIPPkuZnkXyopYHmXPxf9rgiPkVgZCN8w9o8+UgBlY= +github.com/prometheus/prometheus v1.8.2-0.20200707115909-30505a202a4c/go.mod h1:/kMSPIRsxr/apyHxlzYMdFnaPXUXXqILU5uzIoNhOvc= +github.com/prometheus/prometheus v1.8.2-0.20200722151933-4a8531a64b32/go.mod h1:+/y4DzJ62qmhy0o/H4PtXegRXw+80E8RVRHhLbv+bkM= +github.com/prometheus/prometheus v1.8.2-0.20200805082714-e0cf219f0de2/go.mod h1:i1KZsZmyDTJRvnR7zE8z/u2v+tkpPjoiPpnWp6nwhr0= +github.com/prometheus/prometheus v1.8.2-0.20200819132913-cb830b0a9c78/go.mod h1:zfAqy/MwhMFajB9E2n12/9gG2fvofIE9uKDtlZCDxqs= +github.com/prometheus/prometheus v1.8.2-0.20200923143134-7e2db3d092f3/go.mod h1:9VNWoDFHOMovlubld5uKKxfCDcPBj2GMOCjcUFXkYaM= +github.com/prometheus/prometheus v1.8.2-0.20201014093524-73e2ce1bd643/go.mod h1:XYjkJiog7fyQu3puQNivZPI2pNq1C/775EIoHfDvuvY= +github.com/prometheus/prometheus v1.8.2-0.20201105135750-00f16d1ac3a4 h1:54z99l8Q3TuyyeoNZkyY4Lq7eFht9J2Mynq4T1Hxbzc= +github.com/prometheus/prometheus v1.8.2-0.20201105135750-00f16d1ac3a4/go.mod h1:XYjkJiog7fyQu3puQNivZPI2pNq1C/775EIoHfDvuvY= +github.com/prometheus/statsd_exporter v0.15.0/go.mod h1:Dv8HnkoLQkeEjkIE4/2ndAA7WL1zHKK7WMqFQqu72rw= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rafaeljusto/redigomock v0.0.0-20190202135759-257e089e14a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= +github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk= +github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/samuel/go-zookeeper v0.0.0-20190810000440-0ceca61e4d75/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/samuel/go-zookeeper v0.0.0-20200724154423-2164a8ac840e/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sanity-io/litter v1.2.0/go.mod h1:JF6pZUFgu2Q0sBZ+HSV35P8TVPI1TTzEwyu9FXAw2W4= +github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= +github.com/satori/go.uuid v0.0.0-20160603004225-b111a074d5ef/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/schollz/progressbar/v3 v3.3.4/go.mod h1:Rp5lZwpgtYmlvmGo1FyDwXMqagyRBQYSDwzlP9QDu84= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/securego/gosec v0.0.0-20200203094520-d13bb6d2420c/go.mod h1:gp0gaHj0WlmPh9BdsTmo1aq6C27yIPWdxCKGFGdVKBE= +github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= +github.com/segmentio/fasthash v1.0.2/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= +github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/sercand/kuberesolver v2.1.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= +github.com/sercand/kuberesolver v2.4.0+incompatible h1:WE2OlRf6wjLxHwNkkFLQGaZcVLEXjMjBPjjEU5vksH8= +github.com/sercand/kuberesolver v2.4.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil v3.20.12-0.20201210134652-afe0c04c5d5a+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/vfsgen v0.0.0-20180825020608-02ddb050ef6b/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/siebenmann/go-kstat v0.0.0-20160321171754-d34789b79745/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8= +github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/soundcloud/go-runit v0.0.0-20150630195641-06ad41a06c4a/go.mod h1:LeFCbQYJ3KJlPs/FvPz2dy1tkpxyeNESVyCNNzRXFR0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.2/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= +github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= +github.com/thanos-io/thanos v0.8.1-0.20200109203923-552ffa4c1a0d/go.mod h1:usT/TxtJQ7DzinTt+G9kinDQmRS5sxwu0unVKZ9vdcw= +github.com/thanos-io/thanos v0.13.1-0.20200731083140-69b87607decf/go.mod h1:G8caR6G7pSDreRDvFm9wFuyjEBztmr8Ag3kBYpa/fEc= +github.com/thanos-io/thanos v0.13.1-0.20200807203500-9b578afb4763/go.mod h1:KyW0a93tsh7v4hXAwo2CVAIRYuZT1Kkf4e04gisQjAg= +github.com/thanos-io/thanos v0.13.1-0.20201019130456-f41940581d9a h1:4rNkFHeY+EIR7UdiYn5fZE7Q35Y3Dmae8q1Qbb90tcY= +github.com/thanos-io/thanos v0.13.1-0.20201019130456-f41940581d9a/go.mod h1:A3qUEEbsVkplJnxyDLwuIuvTDaJPByTH+hMdTl9ujAA= +github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff h1:QCdUBuN+iKWAB9HqPTkBwyKPPUHDobJ2AuELSNZwd4o= +github.com/timberio/go-datemath v0.1.1-0.20200323150745-74ddef604fff/go.mod h1:m7kjsbCuO4QKP3KLfnxiUZWiOiFXmxj30HeexjL3lc0= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tonistiigi/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:Q5IRRDY+cjIaiOjTAnXN5LKQV5MPqVx5ofQn85Jy5Yw= +github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 h1:VBsKFh4W1JEMz3eLCmM9zOJKZdDkP5W4b3Y4hc7SbZc= +github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= +github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.20.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.22.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.23.1+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.24.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-client-go v2.25.0+incompatible h1:IxcNZ7WRY1Y3G4poYlx24szfsn/3LvK9QHCq9oQw8+U= +github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v1.5.1-0.20181102163054-1fc5c315e03c/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/uber/jaeger-lib v2.2.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/uber/jaeger-lib v2.4.0+incompatible h1:fY7QsGQWiCt8pajv4r7JEvmATdCVaWxXbjwyYwsNaLQ= +github.com/uber/jaeger-lib v2.4.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/unknwon/com v0.0.0-20190804042917-757f69c95f3e/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= +github.com/vektra/mockery v0.0.0-20181123154057-e78b021dcbb5/go.mod h1:ppEjwdhyy7Y31EnHRDm1JkChoC7LXIJ7Ex0VYLWtZtQ= +github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM= +github.com/weaveworks/common v0.0.0-20200206153930-760e36ae819a/go.mod h1:6enWAqfQBFrE8X/XdJwZr8IKgh1chStuFR0mjU/UOUw= +github.com/weaveworks/common v0.0.0-20200625145055-4b1847531bc9/go.mod h1:c98fKi5B9u8OsKGiWHLRKus6ToQ1Tubeow44ECO1uxY= +github.com/weaveworks/common v0.0.0-20200914083218-61ffdd448099/go.mod h1:hz10LOsAdzC3K/iXaKoFxOKTDRgxJl+BTGX1GY+TzO4= +github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec h1:5JmevdpzK10Z2ua0VDToj7Kg2+/t0FzdYBjsurYRE8k= +github.com/weaveworks/common v0.0.0-20201119133501-0619918236ec/go.mod h1:ykzWac1LtVfOxdCK+jD754at1Ws9dKCwFeUzkFBffPs= +github.com/weaveworks/promrus v1.2.0 h1:jOLf6pe6/vss4qGHjXmGz4oDJQA+AOCqEL3FvvZGz7M= +github.com/weaveworks/promrus v1.2.0/go.mod h1:SaE82+OJ91yqjrE1rsvBWVzNZKcHYFtMUyS1+Ogs/KA= +github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xdg-go/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:FV1RpvYFmF8wnKtr3ArzkC0b+tAySCbw8eP7QSIvLKM= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v0.0.0-20180616005107-d6fb6747feb6/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= +github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg= +github.com/xorcare/pointer v1.1.0 h1:sFwXOhRF8QZ0tyVZrtxWGIoVZNEmRzBCaFWdONPQIUM= +github.com/xorcare/pointer v1.1.0/go.mod h1:6KLhkOh6YbuvZkT4YbxIbR/wzLBjyMxOiNzZhJTor2Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= +github.com/zenazn/goji v0.9.1-0.20160507202103-64eb34159fe5/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= +go.elastic.co/apm v1.5.0/go.mod h1:OdB9sPtM6Vt7oz3VXt7+KR96i9li74qrxBGHTQygFvk= +go.elastic.co/apm/module/apmhttp v1.5.0/go.mod h1:1FbmNuyD3ddauwzgVwFB0fqY6KbZt3JkV187tGCYYhY= +go.elastic.co/apm/module/apmot v1.5.0/go.mod h1:d2KYwhJParTpyw2WnTNy8geNlHKKFX+4oK3YLlsesWE= +go.elastic.co/fastjson v1.0.0/go.mod h1:PmeUOMMtLHQr9ZS9J9owrAVg0FkaZDRZJEFTTGHtchs= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/etcd v0.0.0-20190709142735-eb7dd97135a5/go.mod h1:N0RPWo9FXJYZQI4BTkDtQylrstIigYHeR18ONnyTufk= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.etcd.io/etcd v0.5.0-alpha.5.0.20200520232829-54ba9589114f/go.mod h1:skWido08r9w6Lq/w70DO5XYIKMu4QFu1+4VsqLQuJy8= +go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.0.4/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.mongodb.org/mongo-driver v1.3.2/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.22.6 h1:BdkrbWrzDlV9dnbzoP7sfN+dHheJ4J9JOaYxcUDL+ok= +go.opencensus.io v0.22.6/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/collector v0.21.0 h1:M7AaU/6S15gdqqhyX8+XsjT3TUrNIyvsb2ScKxU62HA= +go.opentelemetry.io/collector v0.21.0/go.mod h1:74t/Wj1bGM+L/O/zss6DchCa6ptX1jwrdpVfRuXyd6A= +go.opentelemetry.io/otel v0.11.0/go.mod h1:G8UCk+KooF2HLkgo8RHX9epABH/aRGYET7gQOqBVdB0= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/automaxprocs v1.2.0/go.mod h1:YfO3fm683kQpzETxlTGZhGIVmXAhaw3gxeBADbpZtnU= +go.uber.org/automaxprocs v1.3.0/go.mod h1:9CWT6lKIep8U41DDaPiH6eFscnTyjfTANNQNx6LrIcA= +go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q= +go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +golang.org/x/crypto v0.0.0-20180608092829-8ac0e0d97ce4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191029154019-8994fa331a53/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/exp v0.0.0-20200821190819-94841d0725da h1:vfV2BR+q1+/jmgJR30Ms3RHbryruQ3Yd83lLAAue9cs= +golang.org/x/exp v0.0.0-20200821190819-94841d0725da/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190424112056-4829fb13d2c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 h1:BaN3BAqnopnKjvl+15DYP6LLrbBHfbfmlFYzmFj/Q9Q= +golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200930132711-30421366ff76/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190425145619-16072639606e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190426135247-a129542de9ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190902133755-9109b7679e13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180805044716-cb6730876b98/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181112210238-4b1f3b6b1646/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190118193359-16909d206f00/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190802220118-1d1727260058/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190805222050-c5a2fd39b72a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190813034749-528a2984e271/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190918214516-5a1a30219888/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191111182352-50fa39b762bc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191203134012-c197fd4bf371/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200108203644-89082a384178/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200203023011-6f24f261dadb/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200216192241-b320d3a0f5a2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200513201620-d5fe73897c97/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200603131246-cc40288be839/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200725200936-102e7d357031/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200822203824-307de81be3f4/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201008025239-9df69603baec/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201226215659-b1c90890d22a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= +gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.26.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.32.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.38.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.40.0 h1:uWrpz12dpVPn7cojP82mk02XDgTJLDPc2KbVTxrWb4A= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180608181217-32ee49c4dd80/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20190927181202-20e1ac93f88c/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200108215221-bd8f9a0ef82f/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200603110839-e855014d5736/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200624020401-64a14ca9d1ad/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200710124503-20a17af7bd0e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200724131911-43cab4749ae7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba h1:np3A9jnmE/eMtrOwwvUycmQ1XoLyj5nqZ41bAyYLqJ0= +google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0 h1:o1bcQ6imQMIOpdrO3SWf2z5RV72WbDwdXuK0MDlc8As= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc/examples v0.0.0-20200728065043-dfc0c05b2da9/go.mod h1:5j1uub0jRGhRiSghIlrThmBUgcgLXOVJQ/l1getT4uo= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.46.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.52.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/jcmturner/aescts.v1 v1.0.1/go.mod h1:nsR8qBOg+OucoIW+WMhB3GspUQXq9XorLnQb9XtvcOo= +gopkg.in/jcmturner/dnsutils.v1 v1.0.1/go.mod h1:m3v+5svpVOhtFAP/wSz+yzh4Mc0Fg7eRhxkJMWSIz9Q= +gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4= +gopkg.in/jcmturner/gokrb5.v7 v7.2.3/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuvyavf11/WM= +gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= +gopkg.in/ldap.v3 v3.0.2 h1:R6RBtabK6e1GO0eQKtkyOFbAHO73QesLzI2w2DZ6b9w= +gopkg.in/ldap.v3 v3.0.2/go.mod h1:oxD7NyBuxchC+SgJDE1Q5Od05eGt29SDQVBmV+HYbzw= +gopkg.in/macaron.v1 v1.3.4/go.mod h1:/RoHTdC8ALpyJ3+QR36mKjwnT1F1dyYtsGM9Ate6ZFI= +gopkg.in/macaron.v1 v1.4.0 h1:RJHC09fAnQ8tuGUiZNjG0uyL1BWSdSWd9SpufIcEArQ= +gopkg.in/macaron.v1 v1.4.0/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/redis.v5 v5.2.9 h1:MNZYOLPomQzZMfpN3ZtD1uyJ2IDonTTlxYiV/pEApiw= +gopkg.in/redis.v5 v5.2.9/go.mod h1:6gtv0/+A4iM08kdRfocWYB3bLX2tebpNtfKlFT6H4mY= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.6/go.mod h1:pyyisuGw24ruLjrr1ddx39WE0y9OooInRzEYLhQB2YY= +honnef.co/go/tools v0.1.1/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +k8s.io/api v0.0.0-20190813020757-36bff7324fb7/go.mod h1:3Iy+myeAORNCLgjd/Xu9ebwN7Vh59Bw0vh9jhoX+V58= +k8s.io/api v0.0.0-20191115095533-47f6de673b26/go.mod h1:iA/8arsvelvo4IDqIhX4IbjTEKBGgvsf2OraTuRtLFU= +k8s.io/api v0.18.3/go.mod h1:UOaMwERbqJMfeeeHc8XJKawj4P9TgDRnViIqqBeH2QA= +k8s.io/api v0.18.5/go.mod h1:tN+e/2nbdGKOAH55NMV8oGrMG+3uRlA9GaRfvnCCSNk= +k8s.io/api v0.18.6/go.mod h1:eeyxr+cwCjMdLAmr2W3RyDI0VvTawSg/3RFFBEnmZGI= +k8s.io/api v0.18.8/go.mod h1:d/CXqwWv+Z2XEG1LgceeDmHQwpUJhROPx16SlxJgERY= +k8s.io/api v0.19.2/go.mod h1:IQpK0zFQ1xc5iNIQPqzgoOwuFugaYHK4iCknlAQP9nI= +k8s.io/apimachinery v0.0.0-20190809020650-423f5d784010/go.mod h1:Waf/xTS2FGRrgXCkO5FP3XxTOWh0qLf2QhL1qFZZ/R8= +k8s.io/apimachinery v0.0.0-20191115015347-3c7067801da2/go.mod h1:dXFS2zaQR8fyzuvRdJDHw2Aerij/yVGJSre0bZQSVJA= +k8s.io/apimachinery v0.18.3/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.5/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.6/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= +k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig= +k8s.io/apimachinery v0.19.2/go.mod h1:DnPGDnARWFvYa3pMHgSxtbZb7gpzzAZ1pTfaUNDVlmA= +k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.3.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/kube-openapi v0.0.0-20190709113604-33be087ad058/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4= +k8s.io/kube-openapi v0.0.0-20190722073852-5e22f3d471e6/go.mod h1:RZvgC8MSN6DjiMV6oIfEE9pDL9CYXokkfaCKZeHm3nc= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= +k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= +k8s.io/utils v0.0.0-20190809000727-6c36bc71fc4a/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20191114200735-6ca3b61696b6/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200414100711-2df71ebbae66/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= +xorm.io/builder v0.3.6 h1:ha28mQ2M+TFx96Hxo+iq6tQgnkC9IZkM6D8w9sKHHF8= +xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU= +xorm.io/core v0.7.2/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= +xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= +xorm.io/xorm v0.8.2 h1:nbg1AyWn7iLrwp0Dqg8IrYOBkBYYJ85ry9bvZLVl4Ok= +xorm.io/xorm v0.8.2/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= diff --git a/grafana-mixin/.gitignore b/grafana-mixin/.gitignore new file mode 100644 index 0000000..56b93f9 --- /dev/null +++ b/grafana-mixin/.gitignore @@ -0,0 +1,3 @@ +/alerts.yaml +/rules.yaml +dashboards_out \ No newline at end of file diff --git a/grafana-mixin/Makefile b/grafana-mixin/Makefile new file mode 100644 index 0000000..df6ef32 --- /dev/null +++ b/grafana-mixin/Makefile @@ -0,0 +1,13 @@ +all: fmt lint build clean + +fmt: + ./scripts/format.sh + +lint: + ./scripts/lint.sh + +build: + ./scripts/build.sh + +clean: + rm -rf dashboards_out alerts.yaml rules.yaml diff --git a/grafana-mixin/README.md b/grafana-mixin/README.md new file mode 100644 index 0000000..60feb1d --- /dev/null +++ b/grafana-mixin/README.md @@ -0,0 +1,28 @@ +# Grafana Mixin + +_This is a work in progress. We aim for it to become a good role model for alerts +and dashboards eventually, but it is not quite there yet._ + +The Grafana Mixin is a set of configurable, reusable, and extensible alerts and +dashboards based on the metrics exported by Grafana. The mixin creates +recording and alerting rules for Prometheus and suitable dashboard descriptions +for Grafana. + +To use them, you need to have `mixtool` and `jsonnetfmt` installed. If you +have a working Go development environment, it's easiest to run the following: + +```bash +$ go get github.com/monitoring-mixins/mixtool/cmd/mixtool +$ go get github.com/google/go-jsonnet/cmd/jsonnetfmt +``` + +You can then build the Prometheus rules files `alerts.yaml` and +`rules.yaml` and a directory `dashboard_out` with the JSON dashboard files +for Grafana: + +```bash +$ make build +``` + +For more advanced uses of mixins, see +https://github.com/monitoring-mixins/docs. diff --git a/grafana-mixin/alerts/alerts.yaml b/grafana-mixin/alerts/alerts.yaml new file mode 100644 index 0000000..d81b11e --- /dev/null +++ b/grafana-mixin/alerts/alerts.yaml @@ -0,0 +1,14 @@ +groups: + - name: GrafanaAlerts + rules: + - alert: GrafanaRequestsFailing + for: 5m + expr: | + 100 * namespace_job_handler_statuscode:http_request_total:rate5m{handler!~"/datasources/proxy/:id.*|/ds/query|/tsdb/query", statuscode=~"5.."} + / + namespace_job_handler_statuscode:http_request_total:rate5m{handler!~"/datasources/proxy/:id.*|/ds/query|/tsdb/query"} + > 0.5 + labels: + severity: 'warning' + annotations: + message: "'{{ $labels.namespace }}' / '{{ $labels.job }}' / '{{ $labels.handler }}' is experiencing {{ $value | humanize }}% errors" diff --git a/grafana-mixin/dashboards/grafana-overview.json b/grafana-mixin/dashboards/grafana-overview.json new file mode 100644 index 0000000..de50454 --- /dev/null +++ b/grafana-mixin/dashboards/grafana-overview.json @@ -0,0 +1,512 @@ +{ + "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, + "id": 35, + "iteration": 1602761142538, + "links": [], + "panels": [ + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + } + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "grafana_alerting_result_total{job=~\"$job\", instance=~\"$instance\", state=\"alerting\"}", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Firing Alerts", + "type": "stat" + }, + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + } + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "sum(grafana_stat_totals_dashboard{job=~\"$job\", instance=~\"$instance\"})", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Dashboards", + "type": "stat" + }, + { + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": { + "align": null + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 10, + "options": { + "showHeader": true + }, + "pluginVersion": "7.0.4", + "targets": [ + { + "expr": "grafana_build_info{job=~\"$job\", instance=~\"$instance\"}", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Build Info", + "transformations": [ + { + "id": "labelsToFields", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true, + "branch": true, + "container": true, + "goversion": true, + "namespace": true, + "pod": true, + "revision": true + }, + "indexByName": { + "Time": 7, + "Value": 11, + "branch": 4, + "container": 8, + "edition": 2, + "goversion": 6, + "instance": 1, + "job": 0, + "namespace": 9, + "pod": 10, + "revision": 5, + "version": 3 + }, + "renameByName": {} + } + } + ], + "type": "table" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "sum by (statuscode) (irate(http_request_total{job=~\"$job\", instance=~\"$instance\"}[1m])) ", + "interval": "", + "legendFormat": "{{statuscode}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "RPS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:157", + "format": "reqps", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:158", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "max(http_request_duration_milliseconds{job=~\"$job\", instance=~\"$instance\", quantile=\"0.99\"})", + "interval": "", + "legendFormat": "max-99th", + "refId": "A" + }, + { + "expr": "max(http_request_duration_milliseconds{job=~\"$job\", instance=~\"$instance\", quantile=\"0.9\"})", + "interval": "", + "legendFormat": "max-90th", + "refId": "B" + }, + { + "expr": "sum(irate(http_request_duration_milliseconds_sum{job=~\"$job\", instance=~\"$instance\"}[$__interval])) / sum(irate(http_request_duration_milliseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__interval])) ", + "interval": "", + "legendFormat": "avg", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Request Latency", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:210", + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:211", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 25, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "prometheus", + "value": "prometheus" + }, + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "tags": [], + "text": "All", + "value": ["$__all"] + }, + "datasource": "$datasource", + "definition": "label_values(grafana_build_info, job)", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "job", + "options": [], + "query": "label_values(grafana_build_info, job)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": ".*", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": "$datasource", + "definition": "label_values(grafana_build_info, instance)", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "instance", + "options": [], + "query": "label_values(grafana_build_info, instance)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Grafana Overview", + "uid": "6be0s85Mk", + "version": 4 +} diff --git a/grafana-mixin/mixin.libsonnet b/grafana-mixin/mixin.libsonnet new file mode 100644 index 0000000..c60e0e7 --- /dev/null +++ b/grafana-mixin/mixin.libsonnet @@ -0,0 +1,15 @@ +{ + grafanaDashboards: { + 'grafana-overview.json': (import 'dashboards/grafana-overview.json'), + }, + + // Helper function to ensure that we don't override other rules, by forcing + // the patching of the groups list, and not the overall rules object. + local importRules(rules) = { + groups+: std.native('parseYaml')(rules)[0].groups, + }, + + prometheusRules+: importRules(importstr 'rules/rules.yaml'), + + prometheusAlerts+: importRules(importstr 'alerts/alerts.yaml'), +} diff --git a/grafana-mixin/rules/rules.yaml b/grafana-mixin/rules/rules.yaml new file mode 100644 index 0000000..9eab65e --- /dev/null +++ b/grafana-mixin/rules/rules.yaml @@ -0,0 +1,7 @@ +groups: + - name: grafana_rules + rules: + # Record error rate of http requests excluding dataproxy, /ds/query and /tsdb/query requests + - record: namespace_job_handler_statuscode:http_request_total:rate5m + expr: | + sum by (namespace, job, handler, statuscode) (rate(http_request_total[5m])) diff --git a/grafana-mixin/scripts/build.sh b/grafana-mixin/scripts/build.sh new file mode 100755 index 0000000..b6d93c2 --- /dev/null +++ b/grafana-mixin/scripts/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +mixtool generate all mixin.libsonnet diff --git a/grafana-mixin/scripts/common.sh b/grafana-mixin/scripts/common.sh new file mode 100644 index 0000000..de4e05f --- /dev/null +++ b/grafana-mixin/scripts/common.sh @@ -0,0 +1 @@ +JSONNET_FMT="jsonnetfmt -n 2 --max-blank-lines 2 --string-style s --comment-style s" diff --git a/grafana-mixin/scripts/format.sh b/grafana-mixin/scripts/format.sh new file mode 100755 index 0000000..1d541f1 --- /dev/null +++ b/grafana-mixin/scripts/format.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +. scripts/common.sh + +find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + xargs -n 1 -- ${JSONNET_FMT} -i diff --git a/grafana-mixin/scripts/lint.sh b/grafana-mixin/scripts/lint.sh new file mode 100755 index 0000000..f6fbeb1 --- /dev/null +++ b/grafana-mixin/scripts/lint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -eo pipefail + +cd "$(dirname "$0")"/.. + +. scripts/common.sh + +find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + while read f; do \ + ${JSONNET_FMT} "$f" | diff -u "$f" -; \ + done + +mixtool lint mixin.libsonnet diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..575b844 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,26 @@ +// We set this specifically for 2 reasons. +// 1. It makes sense for both CI tests and local tests to behave the same so issues are found earlier +// 2. Any wrong timezone handling could be hidden if we use UTC/GMT local time (which would happen in CI). +process.env.TZ = 'Pacific/Easter'; + +module.exports = { + verbose: false, + transform: { + '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', + }, + moduleDirectories: ['node_modules', 'public'], + roots: ['/public/app', '/public/test', '/packages', '/scripts'], + testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + setupFiles: ['jest-canvas-mock', './public/test/jest-shim.ts', './public/test/jest-setup.ts'], + setupFilesAfterEnv: ['./public/test/setupTests.ts'], + snapshotSerializers: ['enzyme-to-json/serializer'], + globals: { 'ts-jest': { isolatedModules: true } }, + moduleNameMapper: { + '\\.svg': '/public/test/mocks/svg.ts', + '\\.css': '/public/test/mocks/style.ts', + 'monaco-editor/esm/vs/editor/editor.api': '/public/test/mocks/monaco.ts', + '^react($|/.+)': '/node_modules/react$1', + }, + watchPathIgnorePatterns: ['/node_modules/'], +}; diff --git a/latest.json b/latest.json new file mode 100644 index 0000000..41e11b3 --- /dev/null +++ b/latest.json @@ -0,0 +1,4 @@ +{ + "stable": "7.4.3", + "testing": "7.4.3" +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..d66109f --- /dev/null +++ b/lerna.json @@ -0,0 +1,8 @@ +{ + "npmClient": "yarn", + "useWorkspaces": true, + "packages": [ + "packages/*" + ], + "version": "7.5.5" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec4a5a6 --- /dev/null +++ b/package.json @@ -0,0 +1,322 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "private": true, + "name": "grafana", + "version": "7.5.5", + "repository": "github:grafana/grafana", + "scripts": { + "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", + "build": "node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js", + "dev": "webpack --progress --colors --config scripts/webpack/webpack.dev.js", + "e2e": "./e2e/start-and-run-suite", + "e2e:debug": "./e2e/start-and-run-suite debug", + "e2e:dev": "./e2e/start-and-run-suite dev", + "test": "jest --notify --watch", + "lint": "yarn run lint:ts && yarn run lint:sass", + "lint:ts": "eslint . --ext .js,.tsx,.ts --cache", + "lint:sass": "yarn run sass-lint -c public/sass/.sass-lint.yml 'public/sass/**/*.scss, packages/**/*.scss' -v -i '**/node_modules/**/*.scss'", + "test:ci": "mkdir -p reports/junit && export JEST_JUNIT_OUTPUT_DIR=reports/junit && jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", + "lint:fix": "yarn lint --fix", + "packages:build": "lerna run clean && lerna run build --ignore @grafana-plugins/input-datasource", + "packages:docsExtract": "rm -rf ./reports/docs && lerna run docsExtract", + "packages:docsToMarkdown": "api-documenter markdown --input-folder ./reports/docs/ --output-folder ./docs/sources/packages_api/ --hugo", + "packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact", + "packages:publish": "lerna publish from-package --contents dist", + "packages:publishCanary": "lerna publish from-package --contents dist --dist-tag canary --yes", + "packages:publishLatest": "lerna publish from-package --contents dist --yes", + "packages:publishNext": "lerna publish from-package --contents dist --dist-tag next --yes", + "packages:publishDev": "lerna publish from-package --contents dist --dist-tag dev --yes --registry http://grafana-npm.local:4873 --force-publish=*", + "packages:typecheck": "lerna run typecheck", + "packages:clean": "lerna run clean", + "precommit": "yarn run lint-staged", + "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", + "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss,js}\" --write", + "start": "grafana-toolkit core:start --watchTheme", + "start:hot": "grafana-toolkit core:start --hot --watchTheme", + "start:ignoreTheme": "grafana-toolkit core:start --hot", + "start:noTsCheck": "grafana-toolkit core:start --noTsCheck", + "stats": "webpack --mode production --config scripts/webpack/webpack.prod.js --profile --json > compilation-stats.json", + "storybook": "yarn workspace @grafana/ui storybook --ci", + "storybook:build": "yarn workspace @grafana/ui storybook:build", + "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "typecheck": "tsc --noEmit && yarn run packages:typecheck", + "plugins:build-bundled": "grafana-toolkit plugin:bundle-managed", + "watch": "yarn start -d watch,start core:start --watchTheme", + "ci:test-frontend": "yarn run prettier:check && yarn run typecheck && yarn run lint && yarn run test:ci && yarn grafana-toolkit node-version-check && ./scripts/ci-check-strict.sh" + }, + "grafana": { + "whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v7-5/", + "releaseNotesUrl": "https://grafana.com/docs/grafana/latest/release-notes/" + }, + "husky": { + "hooks": { + "pre-commit": "yarn run precommit" + } + }, + "lint-staged": { + "*.{js,ts,tsx}": [ + "eslint --ext .js,.tsx,.ts --cache --fix" + ], + "*.{json,scss}": [ + "prettier --write" + ], + "*pkg/**/*.go": [ + "gofmt -w -s" + ] + }, + "devDependencies": { + "@babel/core": "7.8.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.8.3", + "@babel/plugin-proposal-optional-chaining": "7.8.3", + "@babel/plugin-syntax-dynamic-import": "7.8.3", + "@babel/preset-env": "7.8.4", + "@babel/preset-react": "7.8.3", + "@babel/preset-typescript": "7.8.3", + "@grafana/api-documenter": "7.11.2", + "@grafana/api-extractor": "7.10.1", + "@grafana/eslint-config": "2.3.0", + "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", + "@testing-library/jest-dom": "5.11.5", + "@testing-library/react": "11.1.2", + "@testing-library/react-hooks": "^3.2.1", + "@testing-library/user-event": "^12.1.3", + "@types/angular": "1.6.56", + "@types/angular-route": "1.7.0", + "@types/classnames": "2.2.9", + "@types/clipboard": "2.0.1", + "@types/d3": "5.7.2", + "@types/d3-force": "^2.1.0", + "@types/d3-scale-chromatic": "1.3.1", + "@types/debounce-promise": "3.1.3", + "@types/enzyme": "3.10.5", + "@types/enzyme-adapter-react-16": "1.0.6", + "@types/file-saver": "2.0.1", + "@types/is-hotkey": "0.1.1", + "@types/jest": "26.0.15", + "@types/jquery": "3.3.38", + "@types/lodash": "4.14.149", + "@types/lru-cache": "^5.1.0", + "@types/moment-timezone": "0.5.13", + "@types/mousetrap": "1.6.3", + "@types/node": "13.7.0", + "@types/papaparse": "5.2.0", + "@types/prismjs": "1.16.0", + "@types/react": "16.9.9", + "@types/react-beautiful-dnd": "12.1.2", + "@types/react-dom": "16.9.9", + "@types/react-grid-layout": "1.1.1", + "@types/react-redux": "7.1.7", + "@types/react-select": "3.0.8", + "@types/react-test-renderer": "16.9.2", + "@types/react-transition-group": "4.4.0", + "@types/react-window": "1.8.1", + "@types/redux-logger": "3.0.7", + "@types/redux-mock-store": "1.0.2", + "@types/reselect": "2.2.0", + "@types/slate": "0.47.1", + "@types/slate-plain-serializer": "0.6.1", + "@types/slate-react": "0.22.5", + "@types/testing-library__jest-dom": "5.9.5", + "@types/testing-library__react-hooks": "^3.2.0", + "@types/tinycolor2": "1.4.2", + "@typescript-eslint/eslint-plugin": "4.15.0", + "@typescript-eslint/parser": "4.15.0", + "@wojtekmaj/enzyme-adapter-react-17": "0.3.1", + "angular-mocks": "1.6.6", + "autoprefixer": "9.7.4", + "axios": "0.21.1", + "babel-core": "7.0.0-bridge.0", + "babel-jest": "26.6.3", + "babel-loader": "8.0.6", + "babel-plugin-angularjs-annotate": "0.10.0", + "clean-webpack-plugin": "3.0.0", + "css-loader": "3.4.2", + "enzyme": "3.11.0", + "enzyme-to-json": "3.4.4", + "es-abstract": "1.18.0-next.1", + "es6-promise": "4.2.8", + "es6-shim": "0.35.5", + "eslint": "7.21.0", + "eslint-config-prettier": "7.2.0", + "eslint-plugin-jsdoc": "31.6.1", + "eslint-plugin-no-only-tests": "2.4.0", + "eslint-plugin-prettier": "3.3.1", + "eslint-plugin-react": "7.22.0", + "eslint-plugin-react-hooks": "4.2.0", + "expect.js": "0.3.1", + "expose-loader": "0.7.5", + "file-loader": "5.0.2", + "fork-ts-checker-webpack-plugin": "6.1.1", + "gaze": "1.1.3", + "glob": "7.1.6", + "html-loader": "0.5.5", + "html-webpack-harddisk-plugin": "1.0.1", + "html-webpack-plugin": "3.2.0", + "husky": "4.2.1", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", + "jest-date-mock": "1.0.8", + "jest-matcher-utils": "26.0.0", + "lerna": "^3.22.1", + "lint-staged": "10.0.7", + "load-grunt-tasks": "5.1.0", + "mini-css-extract-plugin": "0.9.0", + "mocha": "7.0.1", + "module-alias": "2.2.2", + "monaco-editor": "0.20.0", + "monaco-editor-webpack-plugin": "1.9.0", + "mutationobserver-shim": "0.3.3", + "ngtemplate-loader": "2.0.1", + "optimize-css-assets-webpack-plugin": "5.0.4", + "postcss-browser-reporter": "0.6.0", + "postcss-loader": "3.0.0", + "postcss-reporter": "6.0.1", + "prettier": "2.2.1", + "react-hot-loader": "4.8.0", + "react-select-event": "^5.1.0", + "react-test-renderer": "16.12.0", + "redux-mock-store": "1.5.4", + "regexp-replace-loader": "1.0.1", + "rimraf": "3.0.1", + "rxjs-spy": "^7.5.1", + "sass": "1.27.0", + "sass-lint": "1.12.1", + "sass-loader": "8.0.2", + "sinon": "8.1.1", + "style-loader": "1.1.3", + "terser-webpack-plugin": "2.3.7", + "ts-jest": "26.4.4", + "ts-node": "9.0.0", + "tslib": "2.0.3", + "typescript": "4.1.2", + "webpack": "4.41.5", + "webpack-bundle-analyzer": "3.6.0", + "webpack-cleanup-plugin": "0.5.1", + "webpack-cli": "3.3.10", + "webpack-dev-server": "3.11.1", + "webpack-merge": "4.2.2", + "zone.js": "0.7.8" + }, + "dependencies": { + "@emotion/core": "10.0.27", + "@grafana/aws-sdk": "0.0.3", + "@grafana/slate-react": "0.22.9-grafana", + "@popperjs/core": "2.5.4", + "@reduxjs/toolkit": "1.5.0", + "@sentry/browser": "5.25.0", + "@sentry/types": "5.24.2", + "@sentry/utils": "5.24.2", + "@torkelo/react-select": "3.0.8", + "@types/antlr4": "^4.7.1", + "@types/braintree__sanitize-url": "4.0.0", + "@types/common-tags": "^1.8.0", + "@types/hoist-non-react-statics": "3.3.1", + "@types/jsurl": "^1.2.28", + "@types/md5": "^2.1.33", + "@types/react-loadable": "5.5.2", + "@types/react-virtualized-auto-sizer": "1.0.0", + "@types/uuid": "8.3.0", + "@welldone-software/why-did-you-render": "4.0.6", + "abortcontroller-polyfill": "1.4.0", + "angular": "1.8.2", + "angular-bindonce": "0.3.1", + "angular-route": "1.8.2", + "angular-sanitize": "1.8.2", + "antlr4": "^4.8.0", + "baron": "3.0.3", + "brace": "0.11.1", + "calculate-size": "1.1.1", + "centrifuge": "^2.6.4", + "classnames": "2.2.6", + "clipboard": "2.0.4", + "common-tags": "^1.8.0", + "core-js": "3.6.4", + "d3": "5.15.0", + "d3-force": "^2.1.1", + "d3-scale-chromatic": "1.5.0", + "dangerously-set-html-content": "1.0.6", + "debounce-promise": "3.1.2", + "emotion": "10.0.27", + "eventemitter3": "4.0.0", + "fast-text-encoding": "^1.0.0", + "file-saver": "2.0.2", + "hoist-non-react-statics": "3.3.2", + "immutable": "3.8.2", + "is-hotkey": "0.1.6", + "jquery": "3.5.1", + "jsurl": "^0.1.5", + "lodash": "4.17.21", + "lru-cache": "^5.1.1", + "md5": "^2.2.1", + "memoize-one": "5.1.1", + "moment": "2.24.0", + "moment-timezone": "0.5.28", + "mousetrap": "1.6.5", + "mousetrap-global-bind": "1.1.0", + "nodemon": "2.0.2", + "papaparse": "5.3.0", + "prismjs": "1.21.0", + "prop-types": "15.7.2", + "rc-cascader": "1.0.1", + "re-resizable": "^6.2.0", + "react": "17.0.1", + "react-beautiful-dnd": "13.0.0", + "react-dom": "17.0.1", + "react-grid-layout": "1.2.0", + "react-highlight-words": "0.16.0", + "react-loadable": "5.5.0", + "react-popper": "2.2.4", + "react-redux": "7.2.0", + "react-reverse-portal": "^2.0.1", + "react-sizeme": "2.6.12", + "react-split-pane": "0.1.89", + "react-transition-group": "4.4.1", + "react-use": "13.27.0", + "react-virtualized-auto-sizer": "1.0.2", + "react-window": "1.8.5", + "redux": "4.0.5", + "redux-logger": "3.0.6", + "redux-thunk": "2.3.0", + "regenerator-runtime": "0.13.3", + "reselect": "4.0.0", + "rst2html": "github:thoward/rst2html#990cb89", + "rxjs": "6.6.3", + "search-query-parser": "1.5.4", + "slate": "0.47.8", + "slate-plain-serializer": "0.7.10", + "tether": "1.4.7", + "tether-drop": "https://github.com/torkelo/drop/tarball/master", + "tinycolor2": "1.4.1", + "tti-polyfill": "0.2.2", + "uuid": "8.3.0", + "visjs-network": "4.25.0", + "whatwg-fetch": "3.1.0" + }, + "resolutions": { + "caniuse-db": "1.0.30000772", + "react-use-measure": "https://github.com/mckn/react-use-measure.git#remove-cjs-export" + }, + "workspaces": { + "packages": [ + "packages/*", + "plugins-bundled/internal/*" + ], + "nohoist": [ + "**/@types/*", + "**/@types/*/**", + "@storybook", + "**/@storybook", + "**/@storybook/**" + ] + }, + "_moduleAliases": { + "puppeteer": "node_modules/puppeteer-core" + }, + "engines": { + "node": ">= 14" + }, + "volta": { + "node": "14.15.1" + } +} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..480716c --- /dev/null +++ b/packages/README.md @@ -0,0 +1,115 @@ +# Grafana frontend packages + +This document contains information about Grafana frontend package versioning and releases. + +## Versioning +We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases. + +All packages are versioned according to the current Grafana version: +- Grafana v6.3.0-alpha1 -> @grafana/* packages @ 6.3.0-alpha.1 +- Grafana v6.2.5 -> @grafana/* packages @ 6.2.5 +- Grafana - master branch version (based on package.json, i.e. 6.4.0-pre) -> @grafana/* packages @ 6.4.0-pre- (see details below about packages publishing channels) + +> Please note that @grafana/toolkit, @grafana/ui, @grafana/data, and @grafana/runtime packages are considered ALPHA even though they are not released as alpha versions. + +### Stable releases +> **Even though packages are released under a stable version, they are considered ALPHA until further notice!** + +Stable releases are published under the `latest` tag on npm. If there was alpha/beta version released previously, the `next` tag is updated to stable version. + +### Alpha and beta releases +Alpha and beta releases are published under the `next` tag on npm. + +### Automatic prereleases +Every commit to master that has changes within the `packages` directory is a subject of npm packages release. *ALL* packages must be released under version from lerna.json file with commit SHA added to it: + +``` +- +``` + +Automatic prereleases are published under the `canary` dist tag to the [github package registry](https://docs.github.com/en/free-pro-team@latest/packages/publishing-and-managing-packages/about-github-packages). + +#### Consuming prereleases + +As mentioned above the `canary` releases are published to the Github package registry rather than the NPM registry. If you wish to make use of these prereleases please follow these steps: + +1. You must use a personal access token to install packages from Github. To create an access token [click here](https://github.com/settings/tokens) and create a token with the `read:packages` scope. Make a copy of the token. +2. Create / modify your `~/.npmrc` file with the following: + +``` +@grafana:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken={INSERT_GH_TOKEN_HERE} +``` + +3. Update the package.json of your project to use either the `canary` channel or a version of the `canary` channel + +```json +// plugin's package.json +{ + ... + "@grafana/data": "canary" +} +``` + +### Manual release + +> All of the steps below must be performed on a release branch, according to Grafana Release Guide. + +> Make sure you are logged in to npm in your terminal and that you are a part of Grafana org on npm. + +1. Run `yarn packages:prepare` script from the root directory. This performs tests on the packages and prompts for the version of the packages. The version should be the same as the one being released. + - Make sure you use semver convention. So, *place a dot between prerelease id and prerelease number*, i.e. 6.3.0-alpha.1 + - Make sure you confirm the version bump when prompted! +2. Commit changes (lerna.json and package.json files) - *"Packages version update: \"* +3. Run `yarn packages:build` script that prepares distribution packages in `packages/grafana-*/dist`. These directories are going to be published to npm. +4. Depending whether or not it's a prerelease: + - When releasing a prerelease run `packages:publishNext` to publish new versions. + - When releasing a stable version run `packages:publishLatest` to publish new versions. + +5. Push version commit to the release branch. + +### Building individual packages +To build individual packages, run: + +``` +grafana-toolkit package:build --scope= +``` + +### Setting up @grafana/* packages for local development + +A known issue with @grafana/* packages is that a lot of times we discover problems on canary channel(see [versioning overview](#Versioning)) when the version was already pushed to npm. + +We can easily avoid that by setting up a local packages registry and test the packages before actually publishing to npm. + +In this guide you will set up [Verdaccio](https://verdaccio.org/) registry locally to fake npm registry. This will enable testing @grafana/* packages without the need for pushing to master. + +#### Setting up local npm registry + +From your terminal: +1. Modify `/etc/hosts` file and add the following entry: ```127.0.0.1 grafana-npm.local``` +2. Navigate to `devenv/local-npm` directory. +3. Run `docker-compose up`. This will start your local npm registry, available at http://grafana-npm.local:4873/ +4. Run `npm login --registry=http://grafana-npm.local:4873 --scope=@grafana` . This will allow you to publish any @grafana/* package into the local registry. +5. Run `npm config set @grafana:registry http://grafana-npm.local:4873`. This will config your npm to install @grafana scoped packages from your local registry. + +#### Publishing packages to local npm registry + +You need to follow [manual packages release procedure](#manual-release). The only difference is you need to run `yarn packages:publishDev` task in order to publish to you local registry. + +From your terminal: +1. Run `yarn packages:prepare`. +2. Commit changes in package.json and lerna.json files +3. Build packages: `yarn packages:build` +4. Run `yarn packages:publishDev`. +5. Navigate to http://grafana-npm.local:4873 and verify that version was published + +Locally published packages will be published under `dev` channel, so in your plugin package.json file you can use that channel. For example: + +``` +// plugin's package.json + +{ + ... + "@grafana/data": "dev" +} +``` diff --git a/packages/grafana-data/.eslintrc b/packages/grafana-data/.eslintrc new file mode 100644 index 0000000..41e607e --- /dev/null +++ b/packages/grafana-data/.eslintrc @@ -0,0 +1,13 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime", "@grafana/ui", "@grafana/data", "@grafana/e2e/*"] }] + }, + "overrides": [ + { + "files": ["**/*.test.{ts,tsx}"], + "rules": { + "no-restricted-imports": "off" + } + } + ] +} diff --git a/packages/grafana-data/CHANGELOG.md b/packages/grafana-data/CHANGELOG.md new file mode 100644 index 0000000..556d424 --- /dev/null +++ b/packages/grafana-data/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-data/README.md b/packages/grafana-data/README.md new file mode 100644 index 0000000..a1823ca --- /dev/null +++ b/packages/grafana-data/README.md @@ -0,0 +1,5 @@ +# Grafana Data Library + +> **@grafana/data is currently in BETA**. + +This package holds the root data types and functions used within Grafana. diff --git a/packages/grafana-data/api-extractor.json b/packages/grafana-data/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-data/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-data/index.js b/packages/grafana-data/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-data/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json new file mode 100644 index 0000000..e80ffbf --- /dev/null +++ b/packages/grafana-data/package.json @@ -0,0 +1,58 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/data", + "version": "7.5.5", + "description": "Grafana Data Library", + "keywords": [ + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-data" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=data", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@braintree/sanitize-url": "4.0.0", + "@types/d3-interpolate": "^1.3.1", + "apache-arrow": "0.16.0", + "eventemitter3": "4.0.7", + "lodash": "4.17.21", + "marked": "2.0.1", + "rxjs": "6.6.3", + "xss": "1.0.6" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-json": "4.1.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/braintree__sanitize-url": "4.0.0", + "@types/jest": "26.0.15", + "@types/jquery": "3.3.38", + "@types/lodash": "4.14.123", + "@types/marked": "1.1.0", + "@types/node": "10.14.1", + "@types/papaparse": "5.2.0", + "@types/react": "16.9.9", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/sinon": "^7.5.2", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "sinon": "8.1.1", + "typescript": "4.1.2" + } +} diff --git a/packages/grafana-data/rollup.config.ts b/packages/grafana-data/rollup.config.ts new file mode 100644 index 0000000..10818c6 --- /dev/null +++ b/packages/grafana-data/rollup.config.ts @@ -0,0 +1,38 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import json from '@rollup/plugin-json'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + external: ['lodash', 'rxjs', 'apache-arrow'], // Use Lodash, rxjs & arrow from grafana + plugins: [ + json({ + include: ['../../node_modules/moment-timezone/data/packed/latest.json'], + }), + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts new file mode 100644 index 0000000..bdc0679 --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.test.ts @@ -0,0 +1,115 @@ +import { ArrayDataFrame } from './ArrayDataFrame'; +import { toDataFrameDTO } from './processDataFrame'; +import { FieldType, DataFrame } from '../types'; + +describe('Array DataFrame', () => { + const input = [ + { name: 'first', value: 1, time: 123 }, + { name: 'second', value: 2, time: 456, extra: 'here' }, + { name: 'third', value: 3, time: 789 }, + { name: '4th (NaN)', value: NaN, time: 1000 }, + { name: '5th (Null)', value: null, time: 1100 }, + ]; + + const frame = new ArrayDataFrame(input); + frame.name = 'Hello'; + frame.refId = 'Z'; + frame.setFieldType('phantom', FieldType.string, (v) => '🦥'); + const field = frame.fields.find((f) => f.name === 'value'); + field!.config.unit = 'kwh'; + + test('Should support functional methods', () => { + const expectedNames = input.map((row) => row.name); + + // Check map + expect(frame.map((row) => row.name)).toEqual(expectedNames); + + let names: string[] = []; + for (const row of frame) { + names.push(row.name); + } + expect(names).toEqual(expectedNames); + + names = []; + frame.forEach((row) => { + names.push(row.name); + }); + expect(names).toEqual(expectedNames); + }); + + test('Should convert an array of objects to a dataframe', () => { + expect(toDataFrameDTO(frame)).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "name", + "type": "string", + "values": Array [ + "first", + "second", + "third", + "4th (NaN)", + "5th (Null)", + ], + }, + Object { + "config": Object { + "unit": "kwh", + }, + "labels": undefined, + "name": "value", + "type": "number", + "values": Array [ + 1, + 2, + 3, + NaN, + null, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 123, + 456, + 789, + 1000, + 1100, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "phantom", + "type": "string", + "values": Array [ + "🦥", + "🦥", + "🦥", + "🦥", + "🦥", + ], + }, + ], + "meta": undefined, + "name": "Hello", + "refId": "Z", + } + `); + }); + + test('Survives ES6 operations', () => { + const copy: DataFrame = { + ...frame, + name: 'hello', + }; + expect(copy.fields).toEqual(frame.fields); + expect(copy.length).toEqual(frame.length); + expect(copy.length).toEqual(input.length); + }); +}); diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts new file mode 100644 index 0000000..ed05596 --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts @@ -0,0 +1,112 @@ +import { Field, FieldType, DataFrame } from '../types/dataFrame'; +import { vectorToArray } from '../vector/vectorToArray'; +import { Vector, QueryResultMeta } from '../types'; +import { guessFieldTypeFromNameAndValue, toDataFrameDTO } from './processDataFrame'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +export type ValueConverter = (val: any) => T; + +const NOOP: ValueConverter = (v) => v; + +class ArrayPropertyVector implements Vector { + converter = NOOP; + + constructor(private source: any[], private prop: string) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.converter(this.source[index][this.prop]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} + +/** + * The ArrayDataFrame takes an array of objects and presents it as a DataFrame + * + * @alpha + */ +export class ArrayDataFrame extends FunctionalVector implements DataFrame { + name?: string; + refId?: string; + meta?: QueryResultMeta; + + fields: Field[] = []; + length = 0; + + constructor(private source: T[], names?: string[]) { + super(); + + this.length = source.length; + const first: any = source.length ? source[0] : {}; + if (names) { + this.fields = names.map((name) => { + return { + name, + type: guessFieldTypeFromNameAndValue(name, first[name]), + config: {}, + values: new ArrayPropertyVector(source, name), + }; + }); + } else { + this.setFieldsFromObject(first); + } + } + + /** + * Add a field for each property in the object. This will guess the type + */ + setFieldsFromObject(obj: any) { + this.fields = Object.keys(obj).map((name) => { + return { + name, + type: guessFieldTypeFromNameAndValue(name, obj[name]), + config: {}, + values: new ArrayPropertyVector(this.source, name), + }; + }); + } + + /** + * Configure how the object property is passed to the data frame + */ + setFieldType(name: string, type: FieldType, converter?: ValueConverter): Field { + let field = this.fields.find((f) => f.name === name); + if (field) { + field.type = type; + } else { + field = { + name, + type, + config: {}, + values: new ArrayPropertyVector(this.source, name), + }; + this.fields.push(field); + } + (field.values as any).converter = converter ?? NOOP; + return field; + } + + /** + * Get an object with a property for each field in the DataFrame + */ + get(idx: number): T { + return this.source[idx]; + } + + /** + * The simplified JSON values used in JSON.stringify() + */ + toJSON() { + return toDataFrameDTO(this); + } +} diff --git a/packages/grafana-data/src/dataframe/ArrowDataFrame.test.ts b/packages/grafana-data/src/dataframe/ArrowDataFrame.test.ts new file mode 100644 index 0000000..96efacc --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrowDataFrame.test.ts @@ -0,0 +1,85 @@ +import fs from 'fs'; +import path from 'path'; + +import { grafanaDataFrameToArrowTable, arrowTableToDataFrame } from './ArrowDataFrame'; +import { toDataFrameDTO, toDataFrame } from './processDataFrame'; +import { FieldType, DataFrame } from '../types'; +import { Table } from 'apache-arrow'; +import { ArrayVector } from '../vector'; + +describe('Read/Write arrow Table to DataFrame', () => { + test('should parse output with dataframe', () => { + const frame = toDataFrame({ + name: 'Hello', + refId: 'XYZ', + meta: { + aaa: 'xyz', + anything: 'xxx', + }, + fields: [ + { name: 'time', config: {}, type: FieldType.time, values: [1, 2, 3] }, + { name: 'value', config: { min: 0, max: 50, unit: 'something' }, type: FieldType.number, values: [1, 2, 3] }, + { name: 'str', config: {}, type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const table = grafanaDataFrameToArrowTable(frame); + expect(table.length).toEqual(frame.length); + + // Now back to DataFrame + const before = JSON.stringify(toDataFrameDTO(frame), null, 2); + const after = JSON.stringify(toDataFrameDTO(arrowTableToDataFrame(table)), null, 2); + expect(after).toEqual(before); + }); + + test('should support duplicate field names', () => { + const frame = toDataFrame({ + name: 'Hello', + refId: 'XYZ', + fields: [ + { name: 'time', config: {}, type: FieldType.time, values: [1, 2, 3] }, + { name: 'a', values: [1, 2, 3] }, + { name: 'a', values: ['a', 'b', 'c'] }, + ], + }); + + const table = grafanaDataFrameToArrowTable(frame, true); + expect(table.length).toEqual(frame.length); + + // Now back to DataFrame + const before = JSON.stringify(toDataFrameDTO(frame), null, 2); + const after = JSON.stringify(toDataFrameDTO(arrowTableToDataFrame(table)), null, 2); + expect(after).toEqual(before); + }); + + test('should read all types', () => { + const fullpath = path.resolve(__dirname, './__snapshots__/all_types.golden.arrow'); + const arrow = fs.readFileSync(fullpath); + const table = Table.from([arrow]); + const frame = arrowTableToDataFrame(table); + expect(toDataFrameDTO(frame)).toMatchSnapshot(); + }); + + test('Export arrow table names', () => { + const simple: DataFrame = { + name: 'hello', + fields: [ + { + name: 'fname', + labels: { + a: 'AAA', + b: 'BBB', + }, + config: {}, + type: FieldType.number, + values: new ArrayVector([1, 2]), + }, + ], + length: 2, + }; + const t1 = grafanaDataFrameToArrowTable(simple); + const t2 = grafanaDataFrameToArrowTable(simple, true); + expect(t1.getColumnAt(0)?.name).toEqual('fname {a="AAA", b="BBB"}'); + expect(t2.getColumnAt(0)?.name).toEqual('fname'); + }); +}); diff --git a/packages/grafana-data/src/dataframe/ArrowDataFrame.ts b/packages/grafana-data/src/dataframe/ArrowDataFrame.ts new file mode 100644 index 0000000..a2bc1b7 --- /dev/null +++ b/packages/grafana-data/src/dataframe/ArrowDataFrame.ts @@ -0,0 +1,227 @@ +import { DataFrame, FieldType, Field, Vector } from '../types'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +import { + Table, + ArrowType, + Builder, + Vector as ArrowVector, + Float64, + DataType, + Utf8, + TimestampMillisecond, + Bool, + Column, +} from 'apache-arrow'; +import { getFieldDisplayName } from '../field'; + +export interface ArrowDataFrame extends DataFrame { + table: Table; +} + +export function base64StringToArrowTable(text: string): Table { + const b64 = atob(text); + const arr = Uint8Array.from(b64, (c) => { + return c.charCodeAt(0); + }); + return Table.from(arr); +} + +function valueOrUndefined(val?: string) { + return val ? val : undefined; +} + +function parseOptionalMeta(str?: string): any { + if (str && str.length && str !== '{}') { + try { + return JSON.parse(str); + } catch (err) { + console.warn('Error reading JSON from arrow metadata: ', str); + } + } + return undefined; +} + +export function arrowTableToDataFrame(table: Table): ArrowDataFrame { + const fields: Field[] = []; + + for (let i = 0; i < table.numCols; i++) { + const col = table.getColumnAt(i); + if (col) { + const schema = table.schema.fields[i]; + let type = FieldType.other; + let values: Vector = col; + switch ((schema.typeId as unknown) as ArrowType) { + case ArrowType.Decimal: + case ArrowType.FloatingPoint: { + type = FieldType.number; + if (col.nullCount) { + values = new WrappedColumn(col); + } + break; + } + case ArrowType.Int: { + type = FieldType.number; + values = new NumberColumn(col); // Cast to number + break; + } + case ArrowType.Bool: { + type = FieldType.boolean; + if (col.nullCount) { + values = new WrappedColumn(col); + } + break; + } + case ArrowType.Timestamp: { + type = FieldType.time; + values = new NumberColumn(col); // Cast to number + break; + } + case ArrowType.Utf8: { + type = FieldType.string; + break; + } + default: + console.error('UNKNOWN Type:', schema); + } + + fields.push({ + name: col.name, + type, + values, + config: parseOptionalMeta(col.metadata.get('config')) || {}, + labels: parseOptionalMeta(col.metadata.get('labels')), + }); + } + } + const meta = table.schema.metadata; + return { + fields, + length: table.length, + refId: valueOrUndefined(meta.get('refId')), + name: valueOrUndefined(meta.get('name')), + meta: parseOptionalMeta(meta.get('meta')), + table, + }; +} + +function toArrowVector(field: Field): ArrowVector { + // OR: Float64Vector.from([1, 2, 3])); + + let type: DataType; + if (field.type === FieldType.number) { + type = new Float64(); + } else if (field.type === FieldType.time) { + type = new TimestampMillisecond(); + } else if (field.type === FieldType.boolean) { + type = new Bool(); + } else if (field.type === FieldType.string) { + type = new Utf8(); + } else { + type = new Utf8(); + } + const builder = Builder.new({ type, nullValues: [null] }); + field.values.toArray().forEach(builder.append.bind(builder)); + return builder.finish().toVector(); +} + +/** + * @param keepOriginalNames by default, the exported Table will get names that match the + * display within grafana. This typically includes any labels defined in the metadata. + * + * When using this function to round-trip data, be sure to set `keepOriginalNames=true` + */ +export function grafanaDataFrameToArrowTable(data: DataFrame, keepOriginalNames?: boolean): Table { + // Return the original table + let table = (data as any).table; + if (table instanceof Table && table.numCols === data.fields.length) { + if (!keepOriginalNames) { + table = updateArrowTableNames(table, data); + } + if (table) { + return table as Table; + } + } + + table = Table.new( + data.fields.map((field, index) => { + let name = field.name; + // when used directly as an arrow table the name should match the arrow schema + if (!keepOriginalNames) { + name = getFieldDisplayName(field, data); + } + const column = Column.new(name, toArrowVector(field)); + if (field.labels) { + column.metadata.set('labels', JSON.stringify(field.labels)); + } + if (field.config) { + column.metadata.set('config', JSON.stringify(field.config)); + } + return column; + }) + ); + const metadata = table.schema.metadata; + if (data.name) { + metadata.set('name', data.name); + } + if (data.refId) { + metadata.set('refId', data.refId); + } + if (data.meta) { + metadata.set('meta', JSON.stringify(data.meta)); + } + return table; +} + +function updateArrowTableNames(table: Table, frame: DataFrame): Table | undefined { + const cols: Column[] = []; + for (let i = 0; i < table.numCols; i++) { + const col = table.getColumnAt(i); + if (!col) { + return undefined; + } + const name = getFieldDisplayName(frame.fields[i], frame); + cols.push(Column.new(col.field.clone({ name: name }), ...col.chunks)); + } + return Table.new(cols); +} + +class NumberColumn extends FunctionalVector { + constructor(private col: Column) { + super(); + } + + get length() { + return this.col.length; + } + + get(index: number): number { + const v = this.col.get(index); + if (v === null || isNaN(v)) { + return v; + } + + // The conversion operations are always silent, never give errors, + // but if the bigint is too huge and won’t fit the number type, + // then extra bits will be cut off, so we should be careful doing such conversion. + // See https://javascript.info/bigint + return Number(v); + } +} + +// The `toArray()` arrow function will return a native ArrayBuffer -- this works fine +// if there are no null values, but the native arrays do not support nulls. This +// class simply wraps the vector so `toArray` creates a new array +class WrappedColumn extends FunctionalVector { + constructor(private col: Column) { + super(); + } + + get length() { + return this.col.length; + } + + get(index: number): number { + return this.col.get(index); + } +} diff --git a/packages/grafana-data/src/dataframe/CircularDataFrame.ts b/packages/grafana-data/src/dataframe/CircularDataFrame.ts new file mode 100644 index 0000000..890be6c --- /dev/null +++ b/packages/grafana-data/src/dataframe/CircularDataFrame.ts @@ -0,0 +1,22 @@ +import { MutableDataFrame } from './MutableDataFrame'; +import { CircularVector } from '../vector/CircularVector'; + +interface CircularOptions { + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * This dataframe can have values constantly added, and will never + * exceed the given capacity + */ +export class CircularDataFrame extends MutableDataFrame { + constructor(options: CircularOptions) { + super(undefined, (buffer?: any[]) => { + return new CircularVector({ + ...options, + buffer, + }); + }); + } +} diff --git a/packages/grafana-data/src/dataframe/DataFrameView.test.ts b/packages/grafana-data/src/dataframe/DataFrameView.test.ts new file mode 100644 index 0000000..a13471c --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameView.test.ts @@ -0,0 +1,84 @@ +import { FieldType, DataFrameDTO } from '../types/dataFrame'; +import { DateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { DataFrameView } from './DataFrameView'; + +interface MySpecialObject { + time: DateTime; + name: string; + value: number; + more: string; // MISSING +} + +describe('dataFrameView', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + const ext = new MutableDataFrame(frame); + const vector = new DataFrameView(ext); + + it('Should get a typed vector', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0); + expect(first.time).toEqual(100); + expect(first.name).toEqual('a'); + expect(first.value).toEqual(1); + expect(first.more).toBeUndefined(); + }); + + it('Should support the spread operator', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0); + const copy = { ...first }; + expect(copy.time).toEqual(100); + expect(copy.name).toEqual('a'); + expect(copy.value).toEqual(1); + expect(copy.more).toBeUndefined(); + }); + + it('Should support array indexes', () => { + expect(vector.length).toEqual(3); + + const first = vector.get(0) as any; + expect(first[0]).toEqual(100); + expect(first[1]).toEqual('a'); + expect(first[2]).toEqual(1); + expect(first[3]).toBeUndefined(); + }); + + it('Should advertise the property names for each field', () => { + expect(vector.length).toEqual(3); + const first = vector.get(0); + const keys = Object.keys(first); + expect(keys).toEqual(['time', 'name', 'value']); + }); + + it('has a weird side effect that the object values change after interaction', () => { + expect(vector.length).toEqual(3); + + // Get the first value + const first = vector.get(0); + expect(first.name).toEqual('a'); + + // Then get the second one + const second = vector.get(1); + + // the values for 'first' have changed + expect(first.name).toEqual('b'); + expect(first.name).toEqual(second.name); + }); + + it('toJSON returns plain object', () => { + expect(vector.toJSON()[0]).toEqual({ + time: 100, + name: 'a', + value: 1, + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/DataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts new file mode 100644 index 0000000..b960558 --- /dev/null +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -0,0 +1,99 @@ +import { DataFrame } from '../types/dataFrame'; +import { DisplayProcessor } from '../types'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +/** + * This abstraction will present the contents of a DataFrame as if + * it were a well typed javascript object Vector. + * + * @remarks + * The {@link DataFrameView.get} is optimized for use in a loop and will return same object. + * See function for more details. + * + * @typeParam T - Type of object stored in the DataFrame. + * @beta + */ +export class DataFrameView extends FunctionalVector { + private index = 0; + private obj: T; + + constructor(private data: DataFrame) { + super(); + const obj = ({} as unknown) as T; + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + const getter = () => field.values.get(this.index); + + if (!(obj as any).hasOwnProperty(field.name)) { + Object.defineProperty(obj, field.name, { + enumerable: true, // Shows up as enumerable property + get: getter, + }); + } + + Object.defineProperty(obj, i, { + enumerable: false, // Don't enumerate array index + get: getter, + }); + } + + this.obj = obj; + } + + get dataFrame() { + return this.data; + } + + get length() { + return this.data.length; + } + + /** + * Helper function to return the {@link DisplayProcessor} for a given field column. + * @param colIndex - the field column index for the data frame. + */ + getFieldDisplayProcessor(colIndex: number): DisplayProcessor | undefined { + if (!this.dataFrame || !this.dataFrame.fields) { + return undefined; + } + + const field = this.dataFrame.fields[colIndex]; + + if (!field || !field.display) { + return undefined; + } + + return field.display; + } + + /** + * The contents of the object returned from this function + * are optimized for use in a loop. All calls return the same object + * but the index has changed. + * + * @example + * ```typescript + * // `first`, `second` and `third` will all point to the same contents at index 2: + * const first = view.get(0); + * const second = view.get(1); + * const third = view.get(2); + * + * // If you need three different objects, consider something like: + * const first = { ...view.get(0) }; + * const second = { ...view.get(1) }; + * const third = { ...view.get(2) }; + * ``` + * @param idx - The index of the object you currently are inspecting + */ + get(idx: number) { + this.index = idx; + return this.obj; + } + + toArray(): T[] { + return new Array(this.data.length) + .fill(0) // Needs to make a full copy + .map((_, i) => ({ ...this.get(i) })); + } +} diff --git a/packages/grafana-data/src/dataframe/FieldCache.test.ts b/packages/grafana-data/src/dataframe/FieldCache.test.ts new file mode 100644 index 0000000..c35d899 --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.test.ts @@ -0,0 +1,93 @@ +import { FieldCache } from './FieldCache'; +import { FieldType } from '../types/dataFrame'; +import { toDataFrame } from './processDataFrame'; + +describe('FieldCache', () => { + it('when creating a new FieldCache from fields should be able to query cache', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'string', type: FieldType.string }, + { name: 'number', type: FieldType.number }, + { name: 'boolean', type: FieldType.boolean }, + { name: 'other', type: FieldType.other }, + { name: 'undefined' }, + ], + }); + const fieldCache = new FieldCache(frame); + const allFields = fieldCache.getFields(); + expect(allFields).toHaveLength(6); + + const expectedFieldNames = ['time', 'string', 'number', 'boolean', 'other', 'undefined']; + + expect(allFields.map((f) => f.name)).toEqual(expectedFieldNames); + + expect(fieldCache.hasFieldOfType(FieldType.time)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.string)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.number)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.boolean)).toBeTruthy(); + expect(fieldCache.hasFieldOfType(FieldType.other)).toBeTruthy(); + + expect(fieldCache.getFields(FieldType.time).map((f) => f.name)).toEqual([expectedFieldNames[0]]); + expect(fieldCache.getFields(FieldType.string).map((f) => f.name)).toEqual([expectedFieldNames[1]]); + expect(fieldCache.getFields(FieldType.number).map((f) => f.name)).toEqual([expectedFieldNames[2]]); + expect(fieldCache.getFields(FieldType.boolean).map((f) => f.name)).toEqual([expectedFieldNames[3]]); + expect(fieldCache.getFields(FieldType.other).map((f) => f.name)).toEqual([ + expectedFieldNames[4], + expectedFieldNames[5], + ]); + + expect(fieldCache.fields[0].name).toEqual(expectedFieldNames[0]); + expect(fieldCache.fields[1].name).toEqual(expectedFieldNames[1]); + expect(fieldCache.fields[2].name).toEqual(expectedFieldNames[2]); + expect(fieldCache.fields[3].name).toEqual(expectedFieldNames[3]); + expect(fieldCache.fields[4].name).toEqual(expectedFieldNames[4]); + expect(fieldCache.fields[5].name).toEqual(expectedFieldNames[5]); + expect(fieldCache.fields[6]).toBeUndefined(); + + expect(fieldCache.getFirstFieldOfType(FieldType.time)!.name).toEqual(expectedFieldNames[0]); + expect(fieldCache.getFirstFieldOfType(FieldType.string)!.name).toEqual(expectedFieldNames[1]); + expect(fieldCache.getFirstFieldOfType(FieldType.number)!.name).toEqual(expectedFieldNames[2]); + expect(fieldCache.getFirstFieldOfType(FieldType.boolean)!.name).toEqual(expectedFieldNames[3]); + expect(fieldCache.getFirstFieldOfType(FieldType.other)!.name).toEqual(expectedFieldNames[4]); + + expect(fieldCache.hasFieldNamed('tim')).toBeFalsy(); + expect(fieldCache.hasFieldNamed('time')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('string')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('number')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('boolean')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('other')).toBeTruthy(); + expect(fieldCache.hasFieldNamed('undefined')).toBeTruthy(); + + expect(fieldCache.getFieldByName('time')!.name).toEqual(expectedFieldNames[0]); + expect(fieldCache.getFieldByName('string')!.name).toEqual(expectedFieldNames[1]); + expect(fieldCache.getFieldByName('number')!.name).toEqual(expectedFieldNames[2]); + expect(fieldCache.getFieldByName('boolean')!.name).toEqual(expectedFieldNames[3]); + expect(fieldCache.getFieldByName('other')!.name).toEqual(expectedFieldNames[4]); + expect(fieldCache.getFieldByName('undefined')!.name).toEqual(expectedFieldNames[5]); + expect(fieldCache.getFieldByName('null')).toBeUndefined(); + }); + + describe('field retrieval', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [4, 5, 6] }, + ], + }); + const ext = new FieldCache(frame); + + it('should get the first field with a duplicate name', () => { + const field = ext.getFieldByName('value'); + expect(field!.name).toEqual('value'); + expect(field!.values.toArray()).toEqual([1, 2, 3]); + }); + + it('should return index of the field', () => { + const field = ext.getFirstFieldOfType(FieldType.number); + expect(field!.index).toEqual(2); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/FieldCache.ts b/packages/grafana-data/src/dataframe/FieldCache.ts new file mode 100644 index 0000000..2834d47 --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.ts @@ -0,0 +1,89 @@ +import { Field, DataFrame, FieldType, guessFieldTypeForField } from '../index'; + +export interface FieldWithIndex extends Field { + index: number; +} + +export class FieldCache { + fields: FieldWithIndex[] = []; + + private fieldByName: { [key: string]: FieldWithIndex } = {}; + private fieldByType: { [key: string]: FieldWithIndex[] } = {}; + + constructor(data: DataFrame) { + this.fields = data.fields.map((field, idx) => ({ + ...field, + index: idx, + })); + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + // Make sure it has a type + if (field.type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + field.type = t; + } + } + if (!this.fieldByType[field.type]) { + this.fieldByType[field.type] = []; + } + this.fieldByType[field.type].push({ + ...field, + index: i, + }); + + if (this.fieldByName[field.name]) { + console.warn('Duplicate field names in DataFrame: ', field.name); + } else { + this.fieldByName[field.name] = { ...field, index: i }; + } + } + } + + getFields(type?: FieldType): FieldWithIndex[] { + if (!type) { + return [...this.fields]; // All fields + } + const fields = this.fieldByType[type]; + if (fields) { + return [...fields]; + } + return []; + } + + hasFieldOfType(type: FieldType): boolean { + const types = this.fieldByType[type]; + return types && types.length > 0; + } + + getFirstFieldOfType(type: FieldType, includeHidden = false): FieldWithIndex | undefined { + const fields = this.fieldByType[type]; + const firstField = fields.find((field) => includeHidden || !field.config.custom?.hidden); + return firstField; + } + + hasFieldNamed(name: string): boolean { + return !!this.fieldByName[name]; + } + + hasFieldWithNameAndType(name: string, type: FieldType): boolean { + return !!this.fieldByName[name] && this.fieldByType[type].filter((field) => field.name === name).length > 0; + } + + /** + * Returns the first field with the given name. + */ + getFieldByName(name: string): FieldWithIndex | undefined { + return this.fieldByName[name]; + } + + /** + * Returns the fields with the given label. + */ + getFieldsByLabel(label: string, value: string): FieldWithIndex[] { + return Object.values(this.fieldByName).filter((f) => { + return f.labels && f.labels[label] === value; + }); + } +} diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts new file mode 100644 index 0000000..3688161 --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts @@ -0,0 +1,66 @@ +import { DataFrameDTO, FieldType } from '../types/dataFrame'; +import { MutableDataFrame } from './MutableDataFrame'; + +describe('Reversing DataFrame', () => { + describe('when called with a DataFrame', () => { + it('then it should reverse the order of values in all fields', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const helper = new MutableDataFrame(frame); + + expect(helper.fields[0].values.toArray()).toEqual([100, 200, 300]); + expect(helper.fields[1].values.toArray()).toEqual(['a', 'b', 'c']); + expect(helper.fields[2].values.toArray()).toEqual([1, 2, 3]); + + helper.reverse(); + + expect(helper.fields[0].values.toArray()).toEqual([300, 200, 100]); + expect(helper.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + expect(helper.fields[2].values.toArray()).toEqual([3, 2, 1]); + }); + }); +}); + +describe('Apending DataFrame', () => { + it('Should append values', () => { + const dto: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100] }, + { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const frame = new MutableDataFrame(dto); + expect(frame.fields[0].values.toArray()).toEqual([100, undefined, undefined]); + + // Set a value on the second row + frame.set(1, { time: 200, name: 'BB', value: 20 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: undefined, name: undefined, value: 3 }, // 3 + ]); + + // Add a time value that has an array type + frame.add({ time: 300 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: undefined, name: undefined, value: 3 }, // 3 + { time: 300, name: undefined, value: undefined }, // 5 + ]); + + // Make sure length survives a spread operator + const keys = Object.keys(frame); + const copy = { ...frame } as any; + expect(keys).toContain('length'); + expect(copy.length).toEqual(frame.length); + }); +}); diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts new file mode 100644 index 0000000..81d17a8 --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -0,0 +1,245 @@ +import { Field, DataFrame, DataFrameDTO, FieldDTO, FieldType } from '../types/dataFrame'; +import { QueryResultMeta } from '../types/data'; +import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from './processDataFrame'; +import isString from 'lodash/isString'; +import { makeFieldParser } from '../utils/fieldParser'; +import { MutableVector, Vector } from '../types/vector'; +import { ArrayVector } from '../vector/ArrayVector'; +import { FunctionalVector } from '../vector/FunctionalVector'; + +export type MutableField = Field>; + +type MutableVectorCreator = (buffer?: any[]) => MutableVector; + +export const MISSING_VALUE: any = undefined; // Treated as connected in new graph panel + +export class MutableDataFrame extends FunctionalVector implements DataFrame, MutableVector { + name?: string; + refId?: string; + meta?: QueryResultMeta; + fields: MutableField[] = []; + + private first: Vector = new ArrayVector(); + private creator: MutableVectorCreator; + + constructor(source?: DataFrame | DataFrameDTO, creator?: MutableVectorCreator) { + super(); + + // This creates the underlying storage buffers + this.creator = creator + ? creator + : (buffer?: any[]) => { + return new ArrayVector(buffer); + }; + + // Copy values from + if (source) { + const { name, refId, meta, fields } = source; + if (name) { + this.name = name; + } + if (refId) { + this.refId = refId; + } + if (meta) { + this.meta = meta; + } + if (fields) { + for (const f of fields) { + this.addField(f); + } + } + } + + // Get Length to show up if you use spread + Object.defineProperty(this, 'length', { + enumerable: true, + get: () => { + return this.first.length; + }, + }); + } + + // Defined for Vector interface + get length() { + return this.first.length; + } + + addFieldFor(value: any, name?: string): MutableField { + return this.addField({ + name: name || '', // Will be filled in + type: guessFieldTypeFromValue(value), + }); + } + + addField(f: Field | FieldDTO, startLength?: number): MutableField { + let buffer: any[] | undefined = undefined; + + if (f.values) { + if (Array.isArray(f.values)) { + buffer = f.values as any[]; + } else { + buffer = (f.values as Vector).toArray(); + } + } + + let type = f.type; + + if (!type && ('time' === f.name || 'Time' === f.name)) { + type = FieldType.time; + } else { + if (!type && buffer && buffer.length) { + type = guessFieldTypeFromValue(buffer[0]); + } + if (!type) { + type = FieldType.other; + } + } + + // Make sure it has a name + let name = f.name; + if (!name) { + name = `Field ${this.fields.length + 1}`; + } + + const field: MutableField = { + ...f, + name, + type, + config: f.config || {}, + values: this.creator(buffer), + }; + + if (type === FieldType.other) { + type = guessFieldTypeForField(field); + if (type) { + field.type = type; + } + } + + this.fields.push(field); + this.first = this.fields[0].values; + + // Make sure the field starts with a given length + if (startLength) { + while (field.values.length < startLength) { + field.values.add(MISSING_VALUE); + } + } else { + this.validate(); + } + + return field; + } + + validate() { + // Make sure all arrays are the same length + const length = this.fields.reduce((v: number, f) => { + return Math.max(v, f.values.length); + }, 0); + + // Add empty elements until everything matches + for (const field of this.fields) { + while (field.values.length !== length) { + field.values.add(MISSING_VALUE); + } + } + } + + /** + * Reverse all values + */ + reverse() { + for (const f of this.fields) { + f.values.reverse(); + } + } + + /** + * This will add each value to the corresponding column + */ + appendRow(row: any[]) { + // Add any extra columns + for (let i = this.fields.length; i < row.length; i++) { + this.addField({ + name: `Field ${i + 1}`, + type: guessFieldTypeFromValue(row[i]), + }); + } + + // The first line may change the field types + if (this.length < 1) { + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]; + if (!f.type || f.type === FieldType.other) { + f.type = guessFieldTypeFromValue(row[i]); + } + } + } + + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]; + let v = row[i]; + if (f.type !== FieldType.string && isString(v)) { + if (!f.parse) { + f.parse = makeFieldParser(v, f); + } + v = f.parse(v); + } + f.values.add(v); + } + } + + /** + * Add values from an object to corresponding fields. Similar to appendRow but does not create new fields. + */ + add(value: T) { + // Will add one value for every field + const obj = value as any; + for (const field of this.fields) { + let val = obj[field.name]; + + if (field.type !== FieldType.string && isString(val)) { + if (!field.parse) { + field.parse = makeFieldParser(val, field); + } + val = field.parse(val); + } + + if (val === undefined) { + val = MISSING_VALUE; + } + + field.values.add(val); + } + } + + set(index: number, value: T) { + if (index > this.length) { + throw new Error('Unable ot set value beyond current length'); + } + + const obj = (value as any) || {}; + for (const field of this.fields) { + field.values.set(index, obj[field.name]); + } + } + + /** + * Get an object with a property for each field in the DataFrame + */ + get(idx: number): T { + const v: any = {}; + for (const field of this.fields) { + v[field.name] = field.values.get(idx); + } + return v as T; + } + + /** + * The simplified JSON values used in JSON.stringify() + */ + toJSON() { + return toDataFrameDTO(this); + } +} diff --git a/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap b/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap new file mode 100644 index 0000000..4b7e781 --- /dev/null +++ b/packages/grafana-data/src/dataframe/__snapshots__/ArrowDataFrame.test.ts.snap @@ -0,0 +1,389 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Read/Write arrow Table to DataFrame should read all types 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": Object { + "aLabelKey": "aLabelValue", + }, + "name": "string_values", + "type": "string", + "values": Array [ + "Go Min", + "JS Min (for >= 64)", + "0 / nil / misc", + "JS Max (for >= 64)", + "Go Max", + ], + }, + Object { + "config": Object { + "decimals": 2, + "displayName": "Grafana ❤️ (Previous should be heart emoji) 🦥 (Previous should be sloth emoji)", + "filterable": false, + "links": Array [ + Object { + "targetBlank": true, + "title": "Donate - The Sloth Conservation Foundation", + "url": "https://slothconservation.com/how-to-help/donate/", + }, + ], + "max": null, + "min": null, + "noValue": "😤", + "nullValueMode": "null", + }, + "labels": Object { + "aLabelKey": "aLabelValue", + "bLabelKey": "bLabelValue", + }, + "name": "nullable_string_values", + "type": "string", + "values": Array [ + "Grafana", + "❤️", + null, + "🦥", + "update your unicode/font if no sloth, is 2019.", + ], + }, + Object { + "config": Object { + "max": 1, + "min": 0, + }, + "labels": undefined, + "name": "int8_values", + "type": "number", + "values": Array [ + -128, + -128, + 0, + 127, + 127, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_int8_values", + "type": "number", + "values": Array [ + -128, + -128, + null, + 127, + 127, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "int16_values", + "type": "number", + "values": Array [ + -32768, + -32768, + 0, + 32767, + 32767, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_int16_values", + "type": "number", + "values": Array [ + -32768, + -32768, + null, + 32767, + 32767, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "int32_values", + "type": "number", + "values": Array [ + -2147483648, + -2147483648, + 1, + 2147483647, + 2147483647, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_int32_values", + "type": "number", + "values": Array [ + -2147483648, + -2147483648, + null, + 2147483647, + 2147483647, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "int64_values", + "type": "number", + "values": Array [ + -9223372036854776000, + -9007199254740991, + 1, + 9007199254740991, + 9223372036854776000, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_int64_values", + "type": "number", + "values": Array [ + -9223372036854776000, + -9007199254740991, + null, + 9007199254740991, + 9223372036854776000, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "uint8_values", + "type": "number", + "values": Array [ + 0, + 0, + 1, + 255, + 255, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_uint8_values", + "type": "number", + "values": Array [ + 0, + 0, + null, + 255, + 255, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "uint16_values", + "type": "number", + "values": Array [ + 0, + 0, + 1, + 65535, + 65535, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_uint16_values", + "type": "number", + "values": Array [ + 0, + 0, + null, + 65535, + 65535, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "uint32_values", + "type": "number", + "values": Array [ + 0, + 0, + 1, + 4294967295, + 4294967295, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_uint32_values", + "type": "number", + "values": Array [ + 0, + 0, + null, + 4294967295, + 4294967295, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "uint64_values", + "type": "number", + "values": Array [ + 0, + 0, + 1, + 9007199254740991, + 18446744073709552000, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_uint64_values", + "type": "number", + "values": Array [ + 0, + 0, + null, + 9007199254740991, + 18446744073709552000, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "float32_values", + "type": "number", + "values": Float32Array [ + 1.401298464324817e-45, + 1.401298464324817e-45, + 1, + 3.4028234663852886e+38, + 3.4028234663852886e+38, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_float32_values", + "type": "number", + "values": Array [ + 1.401298464324817e-45, + 1.401298464324817e-45, + null, + 3.4028234663852886e+38, + 3.4028234663852886e+38, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "float64_values", + "type": "number", + "values": Array [ + 5e-324, + -9007199254740991, + 1, + 9007199254740991, + 1.7976931348623157e+308, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_float64_values", + "type": "number", + "values": Array [ + 5e-324, + -9007199254740991, + null, + 1.7976931348623157e+308, + 9007199254740991, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "bool_values", + "type": "boolean", + "values": Array [ + true, + false, + true, + true, + false, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_bool_values", + "type": "boolean", + "values": Array [ + true, + false, + null, + true, + false, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "timestamps", + "type": "time", + "values": Array [ + 0, + 1568039445000, + 1568039450000, + 9007199254.740992, + 9223372036854.775, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "timestamps", + "type": "time", + "values": Array [ + 0, + 1568039445000, + 1568039450000, + 9007199254.740992, + 9223372036854.775, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "nullable_timestamps", + "type": "time", + "values": Array [ + 0, + 1568039445000, + null, + 9007199254.740992, + 9223372036854.775, + ], + }, + ], + "meta": Object { + "custom": Object { + "Hi": "there", + }, + }, + "name": "many_types", + "refId": "A", +} +`; diff --git a/packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow b/packages/grafana-data/src/dataframe/__snapshots__/all_types.golden.arrow new file mode 100644 index 0000000000000000000000000000000000000000..045140591306943bb61cca04314e176eb28a832d GIT binary patch literal 10578 zcmeHNU2Ggz6~1e4U6YzB1UDF{#Ap;%uH?i{2vM*gc1YBQl0wu(#cGR^u|1m|vO8n# z&IVg1vL7lGMM#7wLU{;kg#u3%iclY-K1IMoB>I3V9xBv_sOm%1ml!A^l%M7M?%cD! zduMko5RZGEvvs$Y3S5T{na2|Ii0R7h4oB6 z4yqxV)w_mo7oI;0SoIeg^)#va)2sfm$e;Gpa##!f3l~76-D;QysMO~HXC6lzI&kbU zrG5(|Cjf7MLa8~xAJDlofcG9%Y7y|kkWwk&BXsgrKoM{i3P%Cg;oOseHwKiN0NjB? z#{nO}#`giVd;X(llfE1?0Iv#s2Aih8{-)!An5dHj5vzISM*l)bEaCY?;d0dSGn1d=Iv;vHV}yc`XnYwl_W?H>=)NuTR;0Z z$8n&i%r8wm9)(^4${=g8Jq0>_8v`Islw&d%A3HfWz-A}j+TVKndEMCZU?=bA7x#7Z z^OCk%=qd9P6Fc-Riybv+kMS7jIX^c|=KMtLVQw43A4_4=UrT2Gq3iEJ+k5??vFSm> zPC)CuHlo;itvTE8AO~l+kokd$DeJus*za{1bn*9-(B+uS*?a~$Nt>N`pZj}nKT~7N zgM7|UYrKk}+qv=!*l zo3qJ08%yRz6HDFveAn1!o=V>>@tn<_$Qjz~#QV8_^!D>8`XYWl0Ud2|d$8qZ z&L;Dea=l~@nOLHqCQk?KJiTpf)6WqAt|+{{<{1hZ{hDQ z#CbP=?R+Rgx1F!c#;%8U*I!%CH}TcY-y6oh<*zNC^LN9tYy55F{}A+E?{^ga>E>^* z94hPeRTY>=uD`b24l~}}{Jm=ITmIVOIe+h3c8$Mn{C@^Y-|uQ(8d73^f+HacIyM0FCd>Mut1rAn~!O&p4#_1Rib3gWb}TQZK9~-;JL3HeUP1&pUF}D@j^5Tl!ue6q;i5r?u$6rY3G%d37GrPSyjP2wO8 zz1`laa_F7ZrjI0XJ*=G%(kO|&XOc#|s2?Go2DP(c`n5_BpYx~FS_6G))GE+YPSd6O z^yH-OJEEf{zh@z-PL`AJ?M{>3<*>3exv1?;`WL=4;m1if)YJaPYu9f07jm&RiX7fB&FvX2b)RRoutM@3j5h$!1in4fr?f+*m}x8a9miVp4Ax6>83XMuFe=-_?8VmYwBC3mP&AuzikMD-*NEY zi=e3m-)```rWW~o1{eB6gG(j&nDBAqfgyR^jw{NQ)2VD!Ns2`hdyU; zQ6aeKUvlJ^4KDP{j=bP9{F{LRQL8x?fvo|wZ5*_)}V(m$0E2| z$DeAhHAzGfy4w!YI)QPAWpRyw$4!1iXPZqYj-ut1y4#ev1rN#>^!kQ}wU%9nKSQ&1 z(B{XE!L_-%jsk`{o?4(CzSZ6w0{_Anc zbgj=|IMxW{6FsDD+S;UVwo&8EZz!YG26V8@9LehYt?}*5Fa5c{mhsOVVeGMpeSD^G zFIR>R|M+*menP!Ea_$Q1Os?gR?z|D^lJIOMme|KVkqinw;>ffE1ds62jqlnOv zGc%*8e|OTlMpON}lWczN-<@>67w_Ml^zTmkcPH(4f&IG^{ui45-AVuMq { + // Name of the dimension + name: string; + // Collection of fields representing dimension + // I.e. in 2d graph we have two dimension- X and Y axes. Both dimensions can represent + // multiple fields being drawn on the graph. + // For instance y-axis dimension is a collection of series value fields, + // and x-axis dimension is a collection of corresponding time fields + columns: Array>; +} + +export type Dimensions = KeyValue; + +export const createDimension = (name: string, columns: Field[]): Dimension => { + return { + name, + columns, + }; +}; + +export const getColumnsFromDimension = (dimension: Dimension) => { + return dimension.columns; +}; +export const getColumnFromDimension = (dimension: Dimension, column: number) => { + return dimension.columns[column]; +}; + +export const getValueFromDimension = (dimension: Dimension, column: number, row: number) => { + return dimension.columns[column].values.get(row); +}; + +export const getAllValuesFromDimension = (dimension: Dimension, column: number, row: number) => { + return dimension.columns.map((c) => c.values.get(row)); +}; + +export const getDimensionByName = (dimensions: Dimensions, name: string) => dimensions[name]; diff --git a/packages/grafana-data/src/dataframe/frameComparisons.test.ts b/packages/grafana-data/src/dataframe/frameComparisons.test.ts new file mode 100644 index 0000000..e88a9d5 --- /dev/null +++ b/packages/grafana-data/src/dataframe/frameComparisons.test.ts @@ -0,0 +1,238 @@ +import { FieldType } from '../types/dataFrame'; +import { compareDataFrameStructures, compareArrayValues } from './frameComparisons'; +import { toDataFrame } from './processDataFrame'; + +describe('test comparisons', () => { + const frameA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const frameB = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { + name: 'value', + type: FieldType.number, + values: [1, 2, 3], + config: { + decimals: 4, + }, + }, + ], + }); + const field0 = frameB.fields[0]; + const field1 = frameB.fields[1]; + + it('should support null/undefined without crash', () => { + expect(compareDataFrameStructures(frameA, frameA)).toBeTruthy(); + expect(compareDataFrameStructures(frameA, { ...frameA })).toBeTruthy(); + expect(compareDataFrameStructures(frameA, frameB)).toBeFalsy(); + expect(compareDataFrameStructures(frameA, null as any)).toBeFalsy(); + expect(compareDataFrameStructures(undefined as any, frameA)).toBeFalsy(); + + expect(compareArrayValues([frameA], [frameA], compareDataFrameStructures)).toBeTruthy(); + expect(compareArrayValues([frameA], null as any, compareDataFrameStructures)).toBeFalsy(); + expect(compareArrayValues(null as any, [frameA], compareDataFrameStructures)).toBeFalsy(); + }); + + it('name change and field copy is not a structure change', () => { + expect(compareDataFrameStructures(frameB, { ...frameB, name: 'AA' })).toBeTruthy(); + expect(compareDataFrameStructures(frameB, { ...frameB, fields: [field0, field1] })).toBeTruthy(); + }); + + it('changing type should change the config', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + type: FieldType.trace, // Change the type + }, + ], + }) + ).toBeFalsy(); + }); + + it('full copy of config will not change structure', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, // no change + }, + }, + ], + }) + ).toBeTruthy(); // no change + }); + + it('adding an additional config field', () => { + expect( + compareDataFrameStructures(frameB, { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, + unit: 'rpm', + }, + }, + ], + }) + ).toBeFalsy(); + }); + + it('should skip provided properties', () => { + expect( + compareDataFrameStructures( + { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, + }, + }, + ], + }, + { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + ...field1.config, + unit: 'rpm', + }, + }, + ], + }, + ['unit'] + ) + ).toBeTruthy(); + }); + + describe('custom config comparison', () => { + it('handles custom config shallow equality', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + b: 'test', + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + b: 'test', + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeTruthy(); + }); + + it('handles custom config shallow inequality', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 1, + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: 2, + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeFalsy(); + }); + + it('does not compare deeply', () => { + const a = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: { + b: 1, + }, + }, + }, + }, + ], + }; + + const b = { + ...frameB, + fields: [ + field0, + { + ...field1, + config: { + custom: { + a: { + b: 1, + }, + }, + }, + }, + ], + }; + + expect(compareDataFrameStructures(a, b)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/dataframe/frameComparisons.ts b/packages/grafana-data/src/dataframe/frameComparisons.ts new file mode 100644 index 0000000..88fbe54 --- /dev/null +++ b/packages/grafana-data/src/dataframe/frameComparisons.ts @@ -0,0 +1,118 @@ +import { DataFrame } from '../types/dataFrame'; + +/** + * Returns true if both frames have the same list of fields and configs. + * Field may have diferent names, labels and values but share the same structure + * + * To compare multiple frames use: + * ``` + * compareArrayValues(a, b, framesHaveSameStructure); + * ``` + * NOTE: this does a shallow check on the FieldConfig properties, when using the query + * editor, this should be sufficient, however if applicaitons are mutating properties + * deep in the FieldConfig this will not recognize a change + * + * @beta + */ +export function compareDataFrameStructures(a: DataFrame, b: DataFrame, skipProperties?: string[]): boolean { + if (a === b) { + return true; + } + if (a?.fields?.length !== b?.fields?.length) { + return false; + } + + for (let i = 0; i < a.fields.length; i++) { + const fA = a.fields[i]; + const fB = b.fields[i]; + if (fA.type !== fB.type) { + return false; + } + const cfgA = fA.config as any; + const cfgB = fB.config as any; + + let aKeys = Object.keys(cfgA); + let bKeys = Object.keys(cfgB); + + if (skipProperties) { + aKeys = aKeys.filter((k) => skipProperties.indexOf(k) < 0); + bKeys = aKeys.filter((k) => skipProperties.indexOf(k) < 0); + } + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + if (skipProperties && skipProperties.indexOf(key) > -1) { + continue; + } + + if (!cfgB.hasOwnProperty(key)) { + return false; + } + + if (key === 'custom') { + if (!shallowCompare(cfgA[key], cfgB[key])) { + return false; + } else { + continue; + } + } + + if (cfgA[key] !== cfgB[key]) { + return false; + } + } + } + return true; +} + +/** + * Check if all values in two arrays match the compare funciton + * + * @beta + */ +export function compareArrayValues(a: T[], b: T[], cmp: (a: T, b: T) => boolean) { + if (a === b) { + return true; + } + if (a?.length !== b?.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!cmp(a[i], b[i])) { + return false; + } + } + return true; +} + +/** + * Checks if two objects are equal shallowly + * + * @beta + */ +export function shallowCompare(a: T, b: T, cmp?: (valA: any, valB: any) => boolean) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (a === b) { + return true; + } + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (let key of aKeys) { + if (cmp) { + //@ts-ignore + return cmp(a[key], b[key]); + } + //@ts-ignore + if (a[key] !== b[key]) { + return false; + } + } + + return true; +} diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts new file mode 100644 index 0000000..05064d4 --- /dev/null +++ b/packages/grafana-data/src/dataframe/index.ts @@ -0,0 +1,9 @@ +export * from './DataFrameView'; +export * from './FieldCache'; +export * from './CircularDataFrame'; +export * from './MutableDataFrame'; +export * from './processDataFrame'; +export * from './dimensions'; +export * from './ArrowDataFrame'; +export * from './ArrayDataFrame'; +export * from './frameComparisons'; diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts new file mode 100644 index 0000000..7de45a2 --- /dev/null +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -0,0 +1,336 @@ +import { + guessFieldTypeFromValue, + guessFieldTypes, + isDataFrame, + isTableData, + sortDataFrame, + toDataFrame, + toLegacyResponseData, +} from './processDataFrame'; +import { DataFrameDTO, FieldType, TableData, TimeSeries } from '../types/index'; +import { dateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { ArrayDataFrame } from './ArrayDataFrame'; + +describe('toDataFrame', () => { + it('converts timeseries to series', () => { + const input1 = { + target: 'Field Name', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + let series = toDataFrame(input1); + expect(series.name).toBe(input1.target); + expect(series.fields[1].name).toBe('Value'); + + const v0 = series.fields[0].values; + const v1 = series.fields[1].values; + expect(v0.length).toEqual(2); + expect(v0.get(0)).toEqual(1); + expect(v0.get(1)).toEqual(2); + + expect(v1.length).toEqual(2); + expect(v1.get(0)).toEqual(100); + expect(v1.get(1)).toEqual(200); + + // Should fill a default name if target is empty + const input2 = { + // without target + target: '', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + series = toDataFrame(input2); + expect(series.fields[1].name).toEqual('Value'); + }); + + it('assumes TimeSeries values are numbers', () => { + const input1 = { + target: 'time', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const data = toDataFrame(input1); + expect(data.fields[0].type).toBe(FieldType.time); + expect(data.fields[1].type).toBe(FieldType.number); + }); + + it('keeps dataFrame unchanged', () => { + const input = toDataFrame({ + datapoints: [ + [100, 1], + [200, 2], + ], + }); + expect(input.length).toEqual(2); + + // If the object is already a DataFrame, it should not change + const again = toDataFrame(input); + expect(again).toBe(input); + }); + + it('Make sure ArrayDataFrame is used as a DataFrame without modification', () => { + const orig = [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ]; + const array = new ArrayDataFrame(orig); + const frame = toDataFrame(array); + expect(frame).toEqual(array); + expect(frame instanceof ArrayDataFrame).toEqual(true); + expect(frame.length).toEqual(orig.length); + expect(frame.fields.map((f) => f.name)).toEqual(['a', 'b']); + }); + + it('throws when table rows is not array', () => { + expect(() => + toDataFrame({ + columns: [], + rows: {}, + }) + ).toThrowError('Expected table rows to be array, got object.'); + }); + + it('Guess Column Types from value', () => { + expect(guessFieldTypeFromValue(1)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(1.234)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(3.125e7)).toBe(FieldType.number); + expect(guessFieldTypeFromValue(true)).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue(false)).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue(new Date())).toBe(FieldType.time); + expect(guessFieldTypeFromValue(dateTime())).toBe(FieldType.time); + }); + + it('Guess Column Types from strings', () => { + expect(guessFieldTypeFromValue('1')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('1.234')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('NaN')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('3.125e7')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('True')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('FALSE')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('true')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('xxxx')).toBe(FieldType.string); + }); + + it('Guess Column Types from strings', () => { + expect(guessFieldTypeFromValue('1')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('1.234')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('NaN')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('3.125e7')).toBe(FieldType.number); + expect(guessFieldTypeFromValue('True')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('FALSE')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('true')).toBe(FieldType.boolean); + expect(guessFieldTypeFromValue('xxxx')).toBe(FieldType.string); + }); + + it('Guess Column Types from series', () => { + const series = new MutableDataFrame({ + fields: [ + { name: 'A (number)', values: [123, null] }, + { name: 'B (strings)', values: [null, 'Hello'] }, + { name: 'C (nulls)', values: [null, null] }, + { name: 'Time', values: ['2000', 1967] }, + { name: 'D (number strings)', values: ['NaN', null, 1] }, + ], + }); + const norm = guessFieldTypes(series); + expect(norm.fields[0].type).toBe(FieldType.number); + expect(norm.fields[1].type).toBe(FieldType.string); + expect(norm.fields[2].type).toBe(FieldType.other); + expect(norm.fields[3].type).toBe(FieldType.time); // based on name + expect(norm.fields[4].type).toBe(FieldType.number); + }); + + it('converts JSON document data to series', () => { + const input1 = { + datapoints: [ + { + _id: 'W5rvjW0BKe0cA-E1aHvr', + _type: '_doc', + _index: 'logs-2019.10.02', + '@message': 'Deployed website', + '@timestamp': [1570044340458], + tags: ['deploy', 'website-01'], + description: 'Torkel deployed website', + coordinates: { latitude: 12, longitude: 121, level: { depth: 3, coolness: 'very' } }, + 'unescaped-content': 'breaking
the
row', + }, + ], + filterable: true, + target: 'docs', + total: 206, + type: 'docs', + }; + const dataFrame = toDataFrame(input1); + expect(dataFrame.fields[0].name).toBe(input1.target); + + const v0 = dataFrame.fields[0].values; + expect(v0.length).toEqual(1); + expect(v0.get(0)).toEqual(input1.datapoints[0]); + }); +}); + +describe('SeriesData backwards compatibility', () => { + it('can convert TimeSeries to series and back again', () => { + const timeseries = { + target: 'Field Name', + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.target).toBe(timeseries.target); + }); + + it('can convert TimeSeries to series and back again with tags should render name with tags', () => { + const timeseries = { + target: 'Series A', + tags: { server: 'ServerA', job: 'app' }, + datapoints: [ + [100, 1], + [200, 2], + ], + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.target).toBe('{job="app", server="ServerA"}'); + }); + + it('can convert empty table to DataFrame then back to legacy', () => { + const table = { + columns: [], + rows: [], + type: 'table', + }; + + const series = toDataFrame(table); + const roundtrip = toLegacyResponseData(series) as TableData; + expect(roundtrip.columns.length).toBe(0); + expect(roundtrip.type).toBe('table'); + }); + + it('converts TableData to series and back again', () => { + const table = { + columns: [ + { text: 'a', unit: 'ms' }, + { text: 'b', unit: 'zz' }, + { text: 'c', unit: 'yy' }, + ], + rows: [ + [100, 1, 'a'], + [200, 2, 'a'], + ], + }; + const series = toDataFrame(table); + expect(isTableData(table)).toBeTruthy(); + expect(isDataFrame(series)).toBeTruthy(); + expect(series.fields[0].config.unit).toEqual('ms'); + + const roundtrip = toLegacyResponseData(series) as TimeSeries; + expect(isTableData(roundtrip)).toBeTruthy(); + expect(roundtrip).toMatchObject(table); + }); + + it('can convert empty TableData to DataFrame', () => { + const table = { + columns: [], + rows: [], + }; + + const series = toDataFrame(table); + expect(series.fields.length).toBe(0); + }); + + it('can convert DataFrame to TableData to series and back again', () => { + const json: DataFrameDTO = { + refId: 'Z', + meta: { + custom: { + something: 8, + }, + }, + fields: [ + { name: 'T', type: FieldType.time, values: [1, 2, 3] }, + { name: 'N', type: FieldType.number, config: { filterable: true }, values: [100, 200, 300] }, + { name: 'S', type: FieldType.string, config: { filterable: true }, values: ['1', '2', '3'] }, + ], + }; + const series = toDataFrame(json); + const table = toLegacyResponseData(series) as TableData; + expect(table.refId).toBe(series.refId); + expect(table.meta).toEqual(series.meta); + + const names = table.columns.map((c) => c.text); + expect(names).toEqual(['T', 'N', 'S']); + }); + + it('can convert TimeSeries to JSON document and back again', () => { + const timeseries = { + datapoints: [ + { + _id: 'W5rvjW0BKe0cA-E1aHvr', + _type: '_doc', + _index: 'logs-2019.10.02', + '@message': 'Deployed website', + '@timestamp': [1570044340458], + tags: ['deploy', 'website-01'], + description: 'Torkel deployed website', + coordinates: { latitude: 12, longitude: 121, level: { depth: 3, coolness: 'very' } }, + 'unescaped-content': 'breaking
the
row', + }, + ], + filterable: true, + target: 'docs', + total: 206, + type: 'docs', + }; + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); + + const roundtrip = toLegacyResponseData(series) as any; + expect(isDataFrame(roundtrip)).toBeFalsy(); + expect(roundtrip.type).toBe('docs'); + expect(roundtrip.target).toBe('docs'); + expect(roundtrip.filterable).toBeTruthy(); + }); +}); + +describe('sorted DataFrame', () => { + const frame = toDataFrame({ + fields: [ + { name: 'fist', type: FieldType.time, values: [1, 2, 3] }, + { name: 'second', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'third', type: FieldType.number, values: [2000, 3000, 1000] }, + ], + }); + it('Should sort numbers', () => { + const sorted = sortDataFrame(frame, 0, true); + expect(sorted.length).toEqual(3); + expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]); + expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + }); + + it('Should sort strings', () => { + const sorted = sortDataFrame(frame, 1, true); + expect(sorted.length).toEqual(3); + expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]); + expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']); + }); +}); diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts new file mode 100644 index 0000000..33fe81a --- /dev/null +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -0,0 +1,479 @@ +// Libraries +import { isArray, isBoolean, isNumber, isString } from 'lodash'; + +// Types +import { + DataFrame, + Field, + FieldConfig, + TimeSeries, + FieldType, + TableData, + Column, + GraphSeriesXY, + TimeSeriesValue, + FieldDTO, + DataFrameDTO, + TIME_SERIES_VALUE_FIELD_NAME, + TIME_SERIES_TIME_FIELD_NAME, +} from '../types/index'; +import { isDateTime } from '../datetime/moment_wrapper'; +import { ArrayVector } from '../vector/ArrayVector'; +import { MutableDataFrame } from './MutableDataFrame'; +import { SortedVector } from '../vector/SortedVector'; +import { ArrayDataFrame } from './ArrayDataFrame'; +import { getFieldDisplayName } from '../field/fieldState'; +import { fieldIndexComparer } from '../field/fieldComparers'; +import { vectorToArray } from '../vector/vectorToArray'; + +function convertTableToDataFrame(table: TableData): DataFrame { + const fields = table.columns.map((c) => { + // TODO: should be Column but type does not exists there so not sure whats up here. + const { text, type, ...disp } = c as any; + return { + name: text, // rename 'text' to the 'name' field + config: (disp || {}) as FieldConfig, + values: new ArrayVector(), + type: type && Object.values(FieldType).includes(type as FieldType) ? (type as FieldType) : FieldType.other, + }; + }); + + if (!isArray(table.rows)) { + throw new Error(`Expected table rows to be array, got ${typeof table.rows}.`); + } + + for (const row of table.rows) { + for (let i = 0; i < fields.length; i++) { + fields[i].values.buffer.push(row[i]); + } + } + + for (const f of fields) { + if (f.type === FieldType.other) { + const t = guessFieldTypeForField(f); + if (t) { + f.type = t; + } + } + } + + return { + fields, + refId: table.refId, + meta: table.meta, + name: table.name, + length: table.rows.length, + }; +} + +function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame { + const times: number[] = []; + const values: TimeSeriesValue[] = []; + + // Sometimes the points are sent as datapoints + const points = timeSeries.datapoints || (timeSeries as any).points; + for (const point of points) { + values.push(point[0]); + times.push(point[1] as number); + } + + const fields = [ + { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: {}, + values: new ArrayVector(times), + }, + { + name: TIME_SERIES_VALUE_FIELD_NAME, + type: FieldType.number, + config: { + unit: timeSeries.unit, + }, + values: new ArrayVector(values), + labels: timeSeries.tags, + }, + ]; + + if (timeSeries.title) { + (fields[1].config as FieldConfig).displayNameFromDS = timeSeries.title; + } + + return { + name: timeSeries.target || (timeSeries as any).name, + refId: timeSeries.refId, + meta: timeSeries.meta, + fields, + length: values.length, + }; +} + +/** + * This is added temporarily while we convert the LogsModel + * to DataFrame. See: https://github.com/grafana/grafana/issues/18528 + */ +function convertGraphSeriesToDataFrame(graphSeries: GraphSeriesXY): DataFrame { + const x = new ArrayVector(); + const y = new ArrayVector(); + + for (let i = 0; i < graphSeries.data.length; i++) { + const row = graphSeries.data[i]; + x.buffer.push(row[1]); + y.buffer.push(row[0]); + } + + return { + name: graphSeries.label, + fields: [ + { + name: graphSeries.label || TIME_SERIES_VALUE_FIELD_NAME, + type: FieldType.number, + config: {}, + values: x, + }, + { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: { + unit: 'dateTimeAsIso', + }, + values: y, + }, + ], + length: x.buffer.length, + }; +} + +function convertJSONDocumentDataToDataFrame(timeSeries: TimeSeries): DataFrame { + const fields = [ + { + name: timeSeries.target, + type: FieldType.other, + labels: timeSeries.tags, + config: { + unit: timeSeries.unit, + filterable: (timeSeries as any).filterable, + }, + values: new ArrayVector(), + }, + ]; + + for (const point of timeSeries.datapoints) { + fields[0].values.buffer.push(point); + } + + return { + name: timeSeries.target, + refId: timeSeries.target, + meta: { json: true }, + fields, + length: timeSeries.datapoints.length, + }; +} + +// PapaParse Dynamic Typing regex: +// https://github.com/mholt/PapaParse/blob/master/papaparse.js#L998 +const NUMBER = /^\s*(-?(\d*\.?\d+|\d+\.?\d*)(e[-+]?\d+)?|NAN)\s*$/i; + +/** + * Given a name and value, this will pick a reasonable field type + */ +export function guessFieldTypeFromNameAndValue(name: string, v: any): FieldType { + if (name) { + name = name.toLowerCase(); + if (name === 'date' || name === 'time') { + return FieldType.time; + } + } + return guessFieldTypeFromValue(v); +} + +/** + * Given a value this will guess the best column type + * + * TODO: better Date/Time support! Look for standard date strings? + */ +export function guessFieldTypeFromValue(v: any): FieldType { + if (v instanceof Date || isDateTime(v)) { + return FieldType.time; + } + + if (isNumber(v)) { + return FieldType.number; + } + + if (isString(v)) { + if (NUMBER.test(v)) { + return FieldType.number; + } + + if (v === 'true' || v === 'TRUE' || v === 'True' || v === 'false' || v === 'FALSE' || v === 'False') { + return FieldType.boolean; + } + + return FieldType.string; + } + + if (isBoolean(v)) { + return FieldType.boolean; + } + + return FieldType.other; +} + +/** + * Looks at the data to guess the column type. This ignores any existing setting + */ +export function guessFieldTypeForField(field: Field): FieldType | undefined { + // 1. Use the column name to guess + if (field.name) { + const name = field.name.toLowerCase(); + if (name === 'date' || name === 'time') { + return FieldType.time; + } + } + + // 2. Check the first non-null value + for (let i = 0; i < field.values.length; i++) { + const v = field.values.get(i); + if (v !== null) { + return guessFieldTypeFromValue(v); + } + } + + // Could not find anything + return undefined; +} + +/** + * @returns A copy of the series with the best guess for each field type. + * If the series already has field types defined, they will be used, unless `guessDefined` is true. + * @param series The DataFrame whose field's types should be guessed + * @param guessDefined Whether to guess types of fields with already defined types + */ +export const guessFieldTypes = (series: DataFrame, guessDefined = false): DataFrame => { + for (const field of series.fields) { + if (!field.type || field.type === FieldType.other || guessDefined) { + // Something is missing a type, return a modified copy + return { + ...series, + fields: series.fields.map((field) => { + if (field.type && field.type !== FieldType.other && !guessDefined) { + return field; + } + // Calculate a reasonable schema value + return { + ...field, + type: guessFieldTypeForField(field) || FieldType.other, + }; + }), + }; + } + } + // No changes necessary + return series; +}; + +export const isTableData = (data: any): data is DataFrame => data && data.hasOwnProperty('columns'); + +export const isDataFrame = (data: any): data is DataFrame => data && data.hasOwnProperty('fields'); + +/** + * Inspect any object and return the results as a DataFrame + */ +export function toDataFrame(data: any): DataFrame { + if ('fields' in data) { + // DataFrameDTO does not have length + if ('length' in data) { + return data as DataFrame; + } + + // This will convert the array values into Vectors + return new MutableDataFrame(data as DataFrameDTO); + } + + // Handle legacy docs/json type + if (data.hasOwnProperty('type') && data.type === 'docs') { + return convertJSONDocumentDataToDataFrame(data); + } + + if (data.hasOwnProperty('datapoints') || data.hasOwnProperty('points')) { + return convertTimeSeriesToDataFrame(data); + } + + if (data.hasOwnProperty('data')) { + return convertGraphSeriesToDataFrame(data); + } + + if (data.hasOwnProperty('columns')) { + return convertTableToDataFrame(data); + } + + if (Array.isArray(data)) { + return new ArrayDataFrame(data); + } + + console.warn('Can not convert', data); + throw new Error('Unsupported data format'); +} + +export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData => { + const { fields } = frame; + + const rowCount = frame.length; + const rows: any[][] = []; + + if (fields.length === 2) { + const { timeField, timeIndex } = getTimeField(frame); + if (timeField) { + const valueIndex = timeIndex === 0 ? 1 : 0; + const valueField = fields[valueIndex]; + const timeField = fields[timeIndex!]; + + // Make sure it is [value,time] + for (let i = 0; i < rowCount; i++) { + rows.push([ + valueField.values.get(i), // value + timeField.values.get(i), // time + ]); + } + + return { + alias: frame.name, + target: getFieldDisplayName(valueField, frame), + datapoints: rows, + unit: fields[0].config ? fields[0].config.unit : undefined, + refId: frame.refId, + meta: frame.meta, + } as TimeSeries; + } + } + + for (let i = 0; i < rowCount; i++) { + const row: any[] = []; + for (let j = 0; j < fields.length; j++) { + row.push(fields[j].values.get(i)); + } + rows.push(row); + } + + if (frame.meta && frame.meta.json) { + return { + alias: fields[0].name || frame.name, + target: fields[0].name || frame.name, + datapoints: fields[0].values.toArray(), + filterable: fields[0].config ? fields[0].config.filterable : undefined, + type: 'docs', + } as TimeSeries; + } + + return { + columns: fields.map((f) => { + const { name, config } = f; + if (config) { + // keep unit etc + const { ...column } = config; + (column as Column).text = name; + return column as Column; + } + return { text: name }; + }), + type: 'table', + refId: frame.refId, + meta: frame.meta, + rows, + }; +}; + +export function sortDataFrame(data: DataFrame, sortIndex?: number, reverse = false): DataFrame { + const field = data.fields[sortIndex!]; + if (!field) { + return data; + } + + // Natural order + const index: number[] = []; + for (let i = 0; i < data.length; i++) { + index.push(i); + } + + const fieldComparer = fieldIndexComparer(field, reverse); + index.sort(fieldComparer); + + return { + ...data, + fields: data.fields.map((f) => { + return { + ...f, + values: new SortedVector(f.values, index), + }; + }), + }; +} + +/** + * Returns a copy with all values reversed + */ +export function reverseDataFrame(data: DataFrame): DataFrame { + return { + ...data, + fields: data.fields.map((f) => { + const copy = [...f.values.toArray()]; + copy.reverse(); + return { + ...f, + values: new ArrayVector(copy), + }; + }), + }; +} + +/** + * Wrapper to get an array from each field value + */ +export function getDataFrameRow(data: DataFrame, row: number): any[] { + const values: any[] = []; + for (const field of data.fields) { + values.push(field.values.get(row)); + } + return values; +} + +/** + * Returns a copy that does not include functions + */ +export function toDataFrameDTO(data: DataFrame): DataFrameDTO { + const fields: FieldDTO[] = data.fields.map((f) => { + let values = f.values.toArray(); + // The byte buffers serialize like objects + if (values instanceof Float64Array) { + values = vectorToArray(f.values); + } + return { + name: f.name, + type: f.type, + config: f.config, + values, + labels: f.labels, + }; + }); + + return { + fields, + refId: data.refId, + meta: data.meta, + name: data.name, + }; +} + +export const getTimeField = (series: DataFrame): { timeField?: Field; timeIndex?: number } => { + for (let i = 0; i < series.fields.length; i++) { + if (series.fields[i].type === FieldType.time) { + return { + timeField: series.fields[i], + timeIndex: i, + }; + } + } + return {}; +}; diff --git a/packages/grafana-data/src/dataframe/utils.ts b/packages/grafana-data/src/dataframe/utils.ts new file mode 100644 index 0000000..5e9f130 --- /dev/null +++ b/packages/grafana-data/src/dataframe/utils.ts @@ -0,0 +1,12 @@ +import { DataFrame, FieldType } from '../types/dataFrame'; + +export const isTimeSerie = (frame: DataFrame): boolean => { + if (frame.fields.length > 2) { + return false; + } + return !!frame.fields.find((field) => field.type === FieldType.time); +}; + +export const isTimeSeries = (data: DataFrame[]): boolean => { + return !data.find((frame) => !isTimeSerie(frame)); +}; diff --git a/packages/grafana-data/src/datetime/common.ts b/packages/grafana-data/src/datetime/common.ts new file mode 100644 index 0000000..a1a8445 --- /dev/null +++ b/packages/grafana-data/src/datetime/common.ts @@ -0,0 +1,53 @@ +import { TimeZone, DefaultTimeZone } from '../types/time'; + +/** + * The type describing date and time options. Used for all the helper functions + * available to parse or format date and time values. + * + * @public + */ +export interface DateTimeOptions { + /** + * Specify this if you want to override the timeZone used when parsing or formatting + * a date and time value. If no timeZone is set, the default timeZone for the current + * user is used. + */ + timeZone?: TimeZone; + + /** + * Specify a {@link https://momentjs.com/docs/#/displaying/format | momentjs} format to + * use a custom formatting pattern or parsing pattern. If no format is set, + * then system configured default format is used. + */ + format?: string; +} + +/** + * The type to describe the time zone resolver function that will be used to access + * the default time zone of a user. + * + * @public + */ +export type TimeZoneResolver = () => TimeZone | undefined; + +let defaultTimeZoneResolver: TimeZoneResolver = () => DefaultTimeZone; + +/** + * Used by Grafana internals to set the {@link TimeZoneResolver} to access the current + * user timeZone. + * + * @internal + */ +export const setTimeZoneResolver = (resolver: TimeZoneResolver) => { + defaultTimeZoneResolver = resolver ?? defaultTimeZoneResolver; +}; + +/** + * Used within this package to get timeZone from an options value. If timezone + * is not set in the options, then a default timeZone is be resolved instead. + * + * @internal + */ +export const getTimeZone = (options?: T): TimeZone => { + return options?.timeZone ?? defaultTimeZoneResolver() ?? DefaultTimeZone; +}; diff --git a/packages/grafana-data/src/datetime/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts new file mode 100644 index 0000000..e1f8cbd --- /dev/null +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -0,0 +1,138 @@ +import sinon, { SinonFakeTimers } from 'sinon'; +import each from 'lodash/each'; + +import * as dateMath from './datemath'; +import { dateTime, DurationUnit, DateTime } from './moment_wrapper'; + +describe('DateMath', () => { + const spans: DurationUnit[] = ['s', 'm', 'h', 'd', 'w', 'M', 'y']; + const anchor = '2014-01-01T06:06:06.666Z'; + const unix = dateTime(anchor).valueOf(); + const format = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; + let clock: SinonFakeTimers; + + describe('errors', () => { + it('should return undefined if passed empty string', () => { + expect(dateMath.parse('')).toBe(undefined); + }); + + it('should return undefined if I pass an operator besides [+-/]', () => { + expect(dateMath.parse('now&1d')).toBe(undefined); + }); + + it('should return undefined if I pass a unit besides' + spans.toString(), () => { + expect(dateMath.parse('now+5f')).toBe(undefined); + }); + + it('should return undefined if rounding unit is not 1', () => { + expect(dateMath.parse('now/2y')).toBe(undefined); + expect(dateMath.parse('now/0.5y')).toBe(undefined); + }); + + it('should not go into an infinite loop when missing a unit', () => { + expect(dateMath.parse('now-0')).toBe(undefined); + expect(dateMath.parse('now-00')).toBe(undefined); + }); + }); + + it('now/d should set to start of current day', () => { + const expected = new Date(); + expected.setHours(0); + expected.setMinutes(0); + expected.setSeconds(0); + expected.setMilliseconds(0); + + const startOfDay = dateMath.parse('now/d', false)!.valueOf(); + expect(startOfDay).toBe(expected.getTime()); + }); + + it('now/d on a utc dashboard should be start of the current day in UTC time', () => { + const today = new Date(); + const expected = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate(), 0, 0, 0, 0)); + + const startOfDay = dateMath.parse('now/d', false, 'utc')!.valueOf(); + expect(startOfDay).toBe(expected.getTime()); + }); + + describe('subtraction', () => { + let now: DateTime; + let anchored: DateTime; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + now = dateTime(); + anchored = dateTime(anchor); + }); + + each(spans, (span) => { + const nowEx = 'now-5' + span; + const thenEx = anchor + '||-5' + span; + + it('should return 5' + span + ' ago', () => { + expect(dateMath.parse(nowEx)!.format(format)).toEqual(now.subtract(5, span).format(format)); + }); + + it('should return 5' + span + ' before ' + anchor, () => { + expect(dateMath.parse(thenEx)!.format(format)).toEqual(anchored.subtract(5, span).format(format)); + }); + }); + + afterEach(() => { + clock.restore(); + }); + }); + + describe('rounding', () => { + let now: DateTime; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + now = dateTime(); + }); + + each(spans, (span) => { + it('should round now to the beginning of the ' + span, () => { + expect(dateMath.parse('now/' + span)!.format(format)).toEqual(now.startOf(span).format(format)); + }); + + it('should round now to the end of the ' + span, () => { + expect(dateMath.parse('now/' + span, true)!.format(format)).toEqual(now.endOf(span).format(format)); + }); + }); + + afterEach(() => { + clock.restore(); + }); + }); + + describe('isValid', () => { + it('should return false when invalid date text', () => { + expect(dateMath.isValid('asd')).toBe(false); + }); + it('should return true when valid date text', () => { + expect(dateMath.isValid('now-1h')).toBe(true); + }); + }); + + describe('relative time to date parsing', () => { + it('should handle negative time', () => { + const date = dateMath.parseDateMath('-2d', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); + }); + + it('should handle multiple math expressions', () => { + const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf()); + }); + + it('should return false when invalid expression', () => { + const date = dateMath.parseDateMath('2', dateTime([2014, 1, 5])); + expect(date).toEqual(undefined); + }); + + it('should strip whitespace from string', () => { + const date = dateMath.parseDateMath(' - 2d', dateTime([2014, 1, 5])); + expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/datemath.ts b/packages/grafana-data/src/datetime/datemath.ts new file mode 100644 index 0000000..7881729 --- /dev/null +++ b/packages/grafana-data/src/datetime/datemath.ts @@ -0,0 +1,162 @@ +import includes from 'lodash/includes'; +import isDate from 'lodash/isDate'; +import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper'; +import { TimeZone } from '../types/index'; + +const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; + +export function isMathString(text: string | DateTime | Date): boolean { + if (!text) { + return false; + } + + if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) { + return true; + } else { + return false; + } +} + +/** + * Parses different types input to a moment instance. There is a specific formatting language that can be used + * if text arg is string. See unit tests for examples. + * @param text + * @param roundUp See parseDateMath function. + * @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used. + */ +export function parse( + text?: string | DateTime | Date | null, + roundUp?: boolean, + timezone?: TimeZone +): DateTime | undefined { + if (!text) { + return undefined; + } + + if (typeof text !== 'string') { + if (isDateTime(text)) { + return text; + } + if (isDate(text)) { + return dateTime(text); + } + // We got some non string which is not a moment nor Date. TS should be able to check for that but not always. + return undefined; + } else { + let time; + let mathString = ''; + let index; + let parseString; + + if (text.substring(0, 3) === 'now') { + time = dateTimeForTimeZone(timezone); + mathString = text.substring('now'.length); + } else { + index = text.indexOf('||'); + if (index === -1) { + parseString = text; + mathString = ''; // nothing else + } else { + parseString = text.substring(0, index); + mathString = text.substring(index + 2); + } + // We're going to just require ISO8601 timestamps, k? + time = dateTime(parseString, ISO_8601); + } + + if (!mathString.length) { + return time; + } + + return parseDateMath(mathString, time, roundUp); + } +} + +/** + * Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed + * by parse function. See parse function to see what is considered acceptable. + * @param text + */ +export function isValid(text: string | DateTime): boolean { + const date = parse(text); + if (!date) { + return false; + } + + if (isDateTime(date)) { + return date.isValid(); + } + + return false; +} + +/** + * Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples. + * @param mathString + * @param time + * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. + */ +// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any` +export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined { + const strippedMathString = mathString.replace(/\s/g, ''); + const dateTime = time; + let i = 0; + const len = strippedMathString.length; + + while (i < len) { + const c = strippedMathString.charAt(i++); + let type; + let num; + let unit; + + if (c === '/') { + type = 0; + } else if (c === '+') { + type = 1; + } else if (c === '-') { + type = 2; + } else { + return undefined; + } + + if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { + num = 1; + } else if (strippedMathString.length === 2) { + num = strippedMathString.charAt(i); + } else { + const numFrom = i; + while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) { + i++; + if (i > 10) { + return undefined; + } + } + num = parseInt(strippedMathString.substring(numFrom, i), 10); + } + + if (type === 0) { + // rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) + if (num !== 1) { + return undefined; + } + } + unit = strippedMathString.charAt(i++); + + if (!includes(units, unit)) { + return undefined; + } else { + if (type === 0) { + if (roundUp) { + dateTime.endOf(unit); + } else { + dateTime.startOf(unit); + } + } else if (type === 1) { + dateTime.add(num, unit); + } else if (type === 2) { + dateTime.subtract(num, unit); + } + } + } + return dateTime; +} diff --git a/packages/grafana-data/src/datetime/formats.test.ts b/packages/grafana-data/src/datetime/formats.test.ts new file mode 100644 index 0000000..9b551fa --- /dev/null +++ b/packages/grafana-data/src/datetime/formats.test.ts @@ -0,0 +1,19 @@ +import { localTimeFormat } from './formats'; + +describe('Date Formats', () => { + it('localTimeFormat', () => { + const format = localTimeFormat( + { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }, + '' + ); + + expect(format).toBe('MM/DD/YYYY, HH:mm:ss A'); + }); +}); diff --git a/packages/grafana-data/src/datetime/formats.ts b/packages/grafana-data/src/datetime/formats.ts new file mode 100644 index 0000000..f089a8e --- /dev/null +++ b/packages/grafana-data/src/datetime/formats.ts @@ -0,0 +1,121 @@ +export interface SystemDateFormatSettings { + fullDate: string; + interval: { + second: string; + minute: string; + hour: string; + day: string; + month: string; + year: string; + }; + useBrowserLocale: boolean; +} + +const DEFAULT_SYSTEM_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export class SystemDateFormatsState { + fullDate = DEFAULT_SYSTEM_DATE_FORMAT; + interval = { + second: 'HH:mm:ss', + minute: 'HH:mm', + hour: 'MM/DD HH:mm', + day: 'MM/DD', + month: 'YYYY-MM', + year: 'YYYY', + }; + + update(settings: SystemDateFormatSettings) { + this.fullDate = settings.fullDate; + this.interval = settings.interval; + + if (settings.useBrowserLocale) { + this.useBrowserLocale(); + } + } + + get fullDateMS() { + // Add millisecond to seconds part + return this.fullDate.replace('ss', 'ss.SSS'); + } + + useBrowserLocale() { + this.fullDate = localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + this.interval.second = localTimeFormat( + { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }, + null, + this.interval.second + ); + this.interval.minute = localTimeFormat( + { hour: '2-digit', minute: '2-digit', hour12: false }, + null, + this.interval.minute + ); + this.interval.hour = localTimeFormat( + { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }, + null, + this.interval.hour + ); + this.interval.day = localTimeFormat({ month: '2-digit', day: '2-digit', hour12: false }, null, this.interval.day); + this.interval.month = localTimeFormat( + { year: 'numeric', month: '2-digit', hour12: false }, + null, + this.interval.month + ); + } + + getTimeFieldUnit(useMsResolution?: boolean) { + return `time:${useMsResolution ? this.fullDateMS : this.fullDate}`; + } +} + +/** + * localTimeFormat helps to generate date formats for momentjs based on browser's locale + * + * @param locale browser locale, or default + * @param options DateTimeFormatOptions to format date + * @param fallback default format if Intl API is not present + */ +export function localTimeFormat( + options: Intl.DateTimeFormatOptions, + locale?: string | string[] | null, + fallback?: string +): string { + if (missingIntlDateTimeFormatSupport()) { + return fallback ?? DEFAULT_SYSTEM_DATE_FORMAT; + } + + if (!locale) { + locale = [...navigator.languages]; + } + + // https://momentjs.com/docs/#/displaying/format/ + const parts = new Intl.DateTimeFormat(locale, options).formatToParts(new Date()); + const mapping: { [key: string]: string } = { + year: 'YYYY', + month: 'MM', + day: 'DD', + hour: 'HH', + minute: 'mm', + second: 'ss', + weekday: 'ddd', + era: 'N', + dayPeriod: 'A', + timeZoneName: 'Z', + }; + + return parts.map((part) => mapping[part.type] || part.value).join(''); +} + +export const systemDateFormats = new SystemDateFormatsState(); + +const missingIntlDateTimeFormatSupport = (): boolean => { + return !('DateTimeFormat' in Intl) || !('formatToParts' in Intl.DateTimeFormat.prototype); +}; diff --git a/packages/grafana-data/src/datetime/formatter.test.ts b/packages/grafana-data/src/datetime/formatter.test.ts new file mode 100644 index 0000000..a97a6d5 --- /dev/null +++ b/packages/grafana-data/src/datetime/formatter.test.ts @@ -0,0 +1,76 @@ +import { dateTimeFormat } from './formatter'; + +describe('dateTimeFormat', () => { + describe('when no time zone have been set', () => { + const browserTime = dateTimeFormat(1587126975779, { timeZone: 'browser' }); + + it('should format with default formatting in browser/local time zone', () => { + expect(dateTimeFormat(1587126975779)).toBe(browserTime); + }); + }); + + describe('when invalid time zone have been set', () => { + const browserTime = dateTimeFormat(1587126975779, { timeZone: 'browser' }); + const options = { timeZone: 'asdf123' }; + + it('should format with default formatting in browser/local time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe(browserTime); + }); + }); + + describe('when UTC time zone have been set', () => { + const options = { timeZone: 'utc' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 12:36:15'); + }); + }); + + describe('when Europe/Stockholm time zone have been set', () => { + const options = { timeZone: 'Europe/Stockholm' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 14:36:15'); + }); + }); + + describe('when Australia/Perth time zone have been set', () => { + const options = { timeZone: 'Australia/Perth' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 20:36:15'); + }); + }); + + describe('when Asia/Yakutsk time zone have been set', () => { + const options = { timeZone: 'Asia/Yakutsk' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 21:36:15'); + }); + }); + + describe('when America/Panama time zone have been set', () => { + const options = { timeZone: 'America/Panama' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 07:36:15'); + }); + }); + + describe('when America/Los_Angeles time zone have been set', () => { + const options = { timeZone: 'America/Los_Angeles' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 05:36:15'); + }); + }); + + describe('when Africa/Djibouti time zone have been set', () => { + const options = { timeZone: 'Africa/Djibouti' }; + + it('should format with default formatting in correct time zone', () => { + expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17 15:36:15'); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/formatter.ts b/packages/grafana-data/src/datetime/formatter.ts new file mode 100644 index 0000000..108f0a7 --- /dev/null +++ b/packages/grafana-data/src/datetime/formatter.ts @@ -0,0 +1,107 @@ +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { MomentInput, Moment } from 'moment-timezone'; +import { TimeZone } from '../types'; +import { DateTimeInput } from './moment_wrapper'; +import { systemDateFormats } from './formats'; +import { DateTimeOptions, getTimeZone } from './common'; + +/** + * The type describing the options that can be passed to the {@link dateTimeFormat} + * helper function to control how the date and time value passed to the function is + * formatted. + * + * @public + */ +export interface DateTimeOptionsWithFormat extends DateTimeOptions { + /** + * Set this value to `true` if you want to include milliseconds when formatting date and time + */ + defaultWithMS?: boolean; +} + +type DateTimeFormatter = (dateInUtc: DateTimeInput, options?: T) => string; + +/** + * Helper function to format date and time according to the specified options. If no options + * are supplied, then default values are used. For more details, see {@link DateTimeOptionsWithFormat}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormat: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(getFormat(options)); + +/** + * Helper function to format date and time according to the standard ISO format e.g. 2013-02-04T22:44:30.652Z. + * If no options are supplied, then default values are used. For more details, see {@link DateTimeOptionsWithFormat}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatISO: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(); + +/** + * Helper function to return elapsed time since passed date. The returned value will be formatted + * in a human readable format e.g. 4 years ago. If no options are supplied, then default values are used. + * For more details, see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatTimeAgo: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).fromNow(); + +/** + * Helper function to format date and time according to the Grafana default formatting, but it + * also appends the time zone abbreviation at the end e.g. 2020-05-20 13:37:00 CET. If no options + * are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const dateTimeFormatWithAbbrevation: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format(`${systemDateFormats.fullDate} z`); + +/** + * Helper function to return only the time zone abbreviation for a given date and time value. If no options + * are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc. + * @param options + * + * @public + */ +export const timeZoneAbbrevation: DateTimeFormatter = (dateInUtc, options?) => + toTz(dateInUtc, getTimeZone(options)).format('z'); + +const getFormat = (options?: T): string => { + if (options?.defaultWithMS) { + return options?.format ?? systemDateFormats.fullDateMS; + } + return options?.format ?? systemDateFormats.fullDate; +}; + +const toTz = (dateInUtc: DateTimeInput, timeZone: TimeZone): Moment => { + const date = dateInUtc as MomentInput; + const zone = moment.tz.zone(timeZone); + + if (zone && zone.name) { + return moment.utc(date).tz(zone.name); + } + + switch (timeZone) { + case 'utc': + return moment.utc(date); + default: + return moment.utc(date).local(); + } +}; diff --git a/packages/grafana-data/src/datetime/index.ts b/packages/grafana-data/src/datetime/index.ts new file mode 100644 index 0000000..1e38fc7 --- /dev/null +++ b/packages/grafana-data/src/datetime/index.ts @@ -0,0 +1,10 @@ +// Names are too general to export globally +import * as dateMath from './datemath'; +import * as rangeUtil from './rangeutil'; +export * from './moment_wrapper'; +export * from './timezones'; +export * from './formats'; +export * from './formatter'; +export * from './parser'; +export { dateMath, rangeUtil }; +export { DateTimeOptions, setTimeZoneResolver, TimeZoneResolver } from './common'; diff --git a/packages/grafana-data/src/datetime/moment_wrapper.ts b/packages/grafana-data/src/datetime/moment_wrapper.ts new file mode 100644 index 0000000..03910a3 --- /dev/null +++ b/packages/grafana-data/src/datetime/moment_wrapper.ts @@ -0,0 +1,122 @@ +import { TimeZone } from '../types/time'; +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { Moment, MomentInput, DurationInputArg1, DurationInputArg2 } from 'moment'; +export interface DateTimeBuiltinFormat { + __momentBuiltinFormatBrand: any; +} +export const ISO_8601: DateTimeBuiltinFormat = moment.ISO_8601; +export type DateTimeInput = Date | string | number | Array | DateTime | null; // | undefined; +export type FormatInput = string | DateTimeBuiltinFormat | undefined; +export type DurationInput = string | number | DateTimeDuration; +export type DurationUnit = + | 'year' + | 'years' + | 'y' + | 'month' + | 'months' + | 'M' + | 'week' + | 'weeks' + | 'isoWeek' + | 'w' + | 'day' + | 'days' + | 'd' + | 'hour' + | 'hours' + | 'h' + | 'minute' + | 'minutes' + | 'm' + | 'second' + | 'seconds' + | 's' + | 'millisecond' + | 'milliseconds' + | 'ms' + | 'quarter' + | 'quarters' + | 'Q'; + +export interface DateTimeLocale { + firstDayOfWeek: () => number; +} + +export interface DateTimeDuration { + asHours: () => number; + hours: () => number; + minutes: () => number; + seconds: () => number; + asSeconds: () => number; +} + +export interface DateTime extends Object { + add: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; + set: (unit: DurationUnit, amount: DateTimeInput) => void; + diff: (amount: DateTimeInput, unit?: DurationUnit, truncate?: boolean) => number; + endOf: (unitOfTime: DurationUnit) => DateTime; + format: (formatInput?: FormatInput) => string; + fromNow: (withoutSuffix?: boolean) => string; + from: (formaInput: DateTimeInput) => string; + isSame: (input?: DateTimeInput, granularity?: DurationUnit) => boolean; + isBefore: (input?: DateTimeInput) => boolean; + isValid: () => boolean; + local: () => DateTime; + locale: (locale: string) => DateTime; + startOf: (unitOfTime: DurationUnit) => DateTime; + subtract: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime; + toDate: () => Date; + toISOString: () => string; + isoWeekday: (day?: number | string) => number | string; + valueOf: () => number; + unix: () => number; + utc: () => DateTime; + utcOffset: () => number; + hour?: () => number; + minute?: () => number; +} + +export const setLocale = (language: string) => { + moment.locale(language); +}; + +export const getLocale = () => { + return moment.locale(); +}; + +export const getLocaleData = (): DateTimeLocale => { + return moment.localeData(); +}; + +export const isDateTime = (value: any): value is DateTime => { + return moment.isMoment(value); +}; + +export const toUtc = (input?: DateTimeInput, formatInput?: FormatInput): DateTime => { + return moment.utc(input as MomentInput, formatInput) as DateTime; +}; + +export const toDuration = (input?: DurationInput, unit?: DurationUnit): DateTimeDuration => { + // moment built-in types are a bit flaky, for example `isoWeek` is not in the type definition but it's present in the js source. + return moment.duration(input as DurationInputArg1, unit as DurationInputArg2) as DateTimeDuration; +}; + +export const dateTime = (input?: DateTimeInput, formatInput?: FormatInput): DateTime => { + return moment(input as MomentInput, formatInput) as DateTime; +}; + +export const dateTimeAsMoment = (input?: DateTimeInput) => { + return dateTime(input) as Moment; +}; + +export const dateTimeForTimeZone = ( + timezone?: TimeZone, + input?: DateTimeInput, + formatInput?: FormatInput +): DateTime => { + if (timezone === 'utc') { + return toUtc(input, formatInput); + } + + return dateTime(input, formatInput); +}; diff --git a/packages/grafana-data/src/datetime/parser.test.ts b/packages/grafana-data/src/datetime/parser.test.ts new file mode 100644 index 0000000..62209df --- /dev/null +++ b/packages/grafana-data/src/datetime/parser.test.ts @@ -0,0 +1,25 @@ +import { dateTimeParse } from './parser'; +import { systemDateFormats } from './formats'; + +describe('dateTimeParse', () => { + it('should be able to parse using default format', () => { + const date = dateTimeParse('2020-03-02 15:00:22', { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-03-02T15:00:22Z'); + }); + + it('should be able to parse using default format', () => { + systemDateFormats.update({ + fullDate: 'MMMM D, YYYY, h:mm:ss a', + interval: {} as any, + useBrowserLocale: false, + }); + + const date = dateTimeParse('Aug 20, 2020 10:30:20 am', { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-08-20T10:30:20Z'); + }); + + it('should be able to parse array formats used by calendar', () => { + const date = dateTimeParse([2020, 5, 10, 10, 30, 20], { timeZone: 'utc' }); + expect(date.format()).toEqual('2020-06-10T10:30:20Z'); + }); +}); diff --git a/packages/grafana-data/src/datetime/parser.ts b/packages/grafana-data/src/datetime/parser.ts new file mode 100644 index 0000000..8efabe5 --- /dev/null +++ b/packages/grafana-data/src/datetime/parser.ts @@ -0,0 +1,94 @@ +/* eslint-disable id-blacklist, no-restricted-imports, @typescript-eslint/ban-types */ +import moment, { MomentInput } from 'moment-timezone'; +import { DateTimeInput, DateTime, isDateTime } from './moment_wrapper'; +import { DateTimeOptions, getTimeZone } from './common'; +import { parse, isValid } from './datemath'; +import { lowerCase } from 'lodash'; +import { systemDateFormats } from './formats'; + +/** + * The type that describes options that can be passed when parsing a date and time value. + * @public + */ +export interface DateTimeOptionsWhenParsing extends DateTimeOptions { + /** + * If the input is a Grafana quick date, e.g. now-6h, then you can specify this to control + * whether the last part of the date and time value is included or excluded. + * + * Example: now-6h and the current time is 12:20:00 if roundUp is set to true + * the returned DateTime value will be 06:00:00. + */ + roundUp?: boolean; +} + +type DateTimeParser = (value: DateTimeInput, options?: T) => DateTime; + +/** + * Helper function to parse a number, text or Date to a DateTime value. If a timeZone is supplied the incoming value + * is parsed with that timeZone as a base. The only exception to this is if the passed value is in a UTC-based + * format. Then it will use UTC as the base. If no format is specified the current system format will be assumed. + * + * It can also parse the Grafana quick date and time format, e.g. now-6h will be parsed as Date.now() - 6 hours and + * returned as a valid DateTime value. + * + * If no options are supplied, then default values are used. For more details please see {@link DateTimeOptions}. + * + * @param value - should be a parsable date and time value + * @param options + * + * @public + */ +export const dateTimeParse: DateTimeParser = (value, options?): DateTime => { + if (isDateTime(value)) { + return value; + } + + if (typeof value === 'string') { + return parseString(value, options); + } + + return parseOthers(value, options); +}; + +const parseString = (value: string, options?: DateTimeOptionsWhenParsing): DateTime => { + if (value.indexOf('now') !== -1) { + if (!isValid(value)) { + return moment() as DateTime; + } + + const parsed = parse(value, options?.roundUp, options?.timeZone); + return parsed || (moment() as DateTime); + } + + const timeZone = getTimeZone(options); + const zone = moment.tz.zone(timeZone); + const format = options?.format ?? systemDateFormats.fullDate; + + if (zone && zone.name) { + return moment.tz(value, format, zone.name) as DateTime; + } + + switch (lowerCase(timeZone)) { + case 'utc': + return moment.utc(value, format) as DateTime; + default: + return moment(value, format) as DateTime; + } +}; + +const parseOthers = (value: DateTimeInput, options?: DateTimeOptionsWhenParsing): DateTime => { + const date = value as MomentInput; + const timeZone = getTimeZone(options); + const zone = moment.tz.zone(timeZone); + + if (zone && zone.name) { + return moment.tz(date, zone.name) as DateTime; + } + + switch (lowerCase(timeZone)) { + case 'utc': + return moment.utc(date) as DateTime; + default: + return moment(date) as DateTime; + } +}; diff --git a/packages/grafana-data/src/datetime/rangeutil.test.ts b/packages/grafana-data/src/datetime/rangeutil.test.ts new file mode 100644 index 0000000..6393156 --- /dev/null +++ b/packages/grafana-data/src/datetime/rangeutil.test.ts @@ -0,0 +1,43 @@ +import { rangeUtil } from './index'; + +describe('Range Utils', () => { + describe('relative time', () => { + it('should identify absolute vs relative', () => { + expect( + rangeUtil.isRelativeTimeRange({ + from: '1234', + to: '4567', + }) + ).toBe(false); + expect( + rangeUtil.isRelativeTimeRange({ + from: 'now-5', + to: 'now', + }) + ).toBe(true); + }); + }); + + describe('describe_interval', () => { + it('falls back to seconds if input is a number', () => { + expect(rangeUtil.describeInterval('123')).toEqual({ + sec: 1, + type: 's', + count: 123, + }); + }); + + it('parses a valid time unt string correctly', () => { + expect(rangeUtil.describeInterval('123h')).toEqual({ + sec: 3600, + type: 'h', + count: 123, + }); + }); + + it('fails if input is invalid', () => { + expect(() => rangeUtil.describeInterval('123xyz')).toThrow(); + expect(() => rangeUtil.describeInterval('xyz')).toThrow(); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts new file mode 100644 index 0000000..1360e99 --- /dev/null +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -0,0 +1,419 @@ +import each from 'lodash/each'; +import groupBy from 'lodash/groupBy'; +import has from 'lodash/has'; + +import { RawTimeRange, TimeRange, TimeZone, IntervalValues } from '../types/time'; + +import * as dateMath from './datemath'; +import { isDateTime, DateTime } from './moment_wrapper'; +import { timeZoneAbbrevation, dateTimeFormat, dateTimeFormatTimeAgo } from './formatter'; +import { dateTimeParse } from './parser'; + +const spans: { [key: string]: { display: string; section?: number } } = { + s: { display: 'second' }, + m: { display: 'minute' }, + h: { display: 'hour' }, + d: { display: 'day' }, + w: { display: 'week' }, + M: { display: 'month' }, + y: { display: 'year' }, +}; + +const rangeOptions = [ + { from: 'now/d', to: 'now/d', display: 'Today', section: 2 }, + { from: 'now/d', to: 'now', display: 'Today so far', section: 2 }, + { from: 'now/w', to: 'now/w', display: 'This week', section: 2 }, + { from: 'now/w', to: 'now', display: 'This week so far', section: 2 }, + { from: 'now/M', to: 'now/M', display: 'This month', section: 2 }, + { from: 'now/M', to: 'now', display: 'This month so far', section: 2 }, + { from: 'now/y', to: 'now/y', display: 'This year', section: 2 }, + { from: 'now/y', to: 'now', display: 'This year so far', section: 2 }, + + { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 }, + { + from: 'now-2d/d', + to: 'now-2d/d', + display: 'Day before yesterday', + section: 1, + }, + { + from: 'now-7d/d', + to: 'now-7d/d', + display: 'This day last week', + section: 1, + }, + { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 }, + { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 }, + { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 }, + + { from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 }, + { from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 }, + { from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 }, + { from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 }, + { from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 }, + { from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 }, + { from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 }, + { from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 }, + { from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 }, + { from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 }, + { from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 }, + { from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 }, + { from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 }, + { from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 }, + { from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 }, + { from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 }, +]; + +const hiddenRangeOptions = [ + { from: 'now', to: 'now+1m', display: 'Next minute', section: 3 }, + { from: 'now', to: 'now+5m', display: 'Next 5 minutes', section: 3 }, + { from: 'now', to: 'now+15m', display: 'Next 15 minutes', section: 3 }, + { from: 'now', to: 'now+30m', display: 'Next 30 minutes', section: 3 }, + { from: 'now', to: 'now+1h', display: 'Next hour', section: 3 }, + { from: 'now', to: 'now+3h', display: 'Next 3 hours', section: 3 }, + { from: 'now', to: 'now+6h', display: 'Next 6 hours', section: 3 }, + { from: 'now', to: 'now+12h', display: 'Next 12 hours', section: 3 }, + { from: 'now', to: 'now+24h', display: 'Next 24 hours', section: 3 }, + { from: 'now', to: 'now+2d', display: 'Next 2 days', section: 0 }, + { from: 'now', to: 'now+7d', display: 'Next 7 days', section: 0 }, + { from: 'now', to: 'now+30d', display: 'Next 30 days', section: 0 }, + { from: 'now', to: 'now+90d', display: 'Next 90 days', section: 0 }, + { from: 'now', to: 'now+6M', display: 'Next 6 months', section: 0 }, + { from: 'now', to: 'now+1y', display: 'Next year', section: 0 }, + { from: 'now', to: 'now+2y', display: 'Next 2 years', section: 0 }, + { from: 'now', to: 'now+5y', display: 'Next 5 years', section: 0 }, +]; + +const rangeIndex: any = {}; +each(rangeOptions, (frame: any) => { + rangeIndex[frame.from + ' to ' + frame.to] = frame; +}); +each(hiddenRangeOptions, (frame: any) => { + rangeIndex[frame.from + ' to ' + frame.to] = frame; +}); + +export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) { + const groups = groupBy(rangeOptions, (option: any) => { + option.active = option.display === currentDisplay; + return option.section; + }); + + // _.each(timepickerSettings.time_options, (duration: string) => { + // let info = describeTextRange(duration); + // if (info.section) { + // groups[info.section].push(info); + // } + // }); + + return groups; +} + +// handles expressions like +// 5m +// 5m to now/d +// now/d to now +// now/d +// if no to then to now is assumed +export function describeTextRange(expr: any) { + const isLast = expr.indexOf('+') !== 0; + if (expr.indexOf('now') === -1) { + expr = (isLast ? 'now-' : 'now') + expr; + } + + let opt = rangeIndex[expr + ' to now']; + if (opt) { + return opt; + } + + if (isLast) { + opt = { from: expr, to: 'now' }; + } else { + opt = { from: 'now', to: expr }; + } + + const parts = /^now([-+])(\d+)(\w)/.exec(expr); + if (parts) { + const unit = parts[3]; + const amount = parseInt(parts[2], 10); + const span = spans[unit]; + if (span) { + opt.display = isLast ? 'Last ' : 'Next '; + opt.display += amount + ' ' + span.display; + opt.section = span.section; + if (amount > 1) { + opt.display += 's'; + } + } + } else { + opt.display = opt.from + ' to ' + opt.to; + opt.invalid = true; + } + + return opt; +} + +/** + * Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}. + * + * @example + * ``` + * // Prints "2": + * console.log(add(1,1)); + * ``` + * @category TimeUtils + * @param range - a time range (usually specified by the TimePicker) + * @alpha + */ +export function describeTimeRange(range: RawTimeRange, timeZone?: TimeZone): string { + const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()]; + + if (option) { + return option.display; + } + + const options = { timeZone }; + + if (isDateTime(range.from) && isDateTime(range.to)) { + return dateTimeFormat(range.from, options) + ' to ' + dateTimeFormat(range.to, options); + } + + if (isDateTime(range.from)) { + const parsed = dateMath.parse(range.to, true, 'utc'); + return parsed ? dateTimeFormat(range.from, options) + ' to ' + dateTimeFormatTimeAgo(parsed, options) : ''; + } + + if (isDateTime(range.to)) { + const parsed = dateMath.parse(range.from, false, 'utc'); + return parsed ? dateTimeFormatTimeAgo(parsed, options) + ' to ' + dateTimeFormat(range.to, options) : ''; + } + + if (range.to.toString() === 'now') { + const res = describeTextRange(range.from); + return res.display; + } + + return range.from.toString() + ' to ' + range.to.toString(); +} + +export const isValidTimeSpan = (value: string) => { + if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) { + return true; + } + + const info = describeTextRange(value); + return info.invalid !== true; +}; + +export const describeTimeRangeAbbreviation = (range: TimeRange, timeZone?: TimeZone) => { + if (isDateTime(range.from)) { + return timeZoneAbbrevation(range.from, { timeZone }); + } + const parsed = dateMath.parse(range.from, true); + return parsed ? timeZoneAbbrevation(parsed, { timeZone }) : ''; +}; + +export const convertRawToRange = (raw: RawTimeRange, timeZone?: TimeZone): TimeRange => { + const from = dateTimeParse(raw.from, { roundUp: false, timeZone }); + const to = dateTimeParse(raw.to, { roundUp: true, timeZone }); + + if (dateMath.isMathString(raw.from) || dateMath.isMathString(raw.to)) { + return { from, to, raw }; + } + + return { from, to, raw: { from, to } }; +}; + +function isRelativeTime(v: DateTime | string) { + if (typeof v === 'string') { + return (v as string).indexOf('now') >= 0; + } + return false; +} + +export function isRelativeTimeRange(raw: RawTimeRange): boolean { + return isRelativeTime(raw.from) || isRelativeTime(raw.to); +} + +export function secondsToHms(seconds: number): string { + const numYears = Math.floor(seconds / 31536000); + if (numYears) { + return numYears + 'y'; + } + const numDays = Math.floor((seconds % 31536000) / 86400); + if (numDays) { + return numDays + 'd'; + } + const numHours = Math.floor(((seconds % 31536000) % 86400) / 3600); + if (numHours) { + return numHours + 'h'; + } + const numMinutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60); + if (numMinutes) { + return numMinutes + 'm'; + } + const numSeconds = Math.floor((((seconds % 31536000) % 86400) % 3600) % 60); + if (numSeconds) { + return numSeconds + 's'; + } + const numMilliseconds = Math.floor(seconds * 1000.0); + if (numMilliseconds) { + return numMilliseconds + 'ms'; + } + + return 'less than a millisecond'; //'just now' //or other string you like; +} + +export function calculateInterval(range: TimeRange, resolution: number, lowLimitInterval?: string): IntervalValues { + let lowLimitMs = 1; // 1 millisecond default low limit + if (lowLimitInterval) { + lowLimitMs = intervalToMs(lowLimitInterval); + } + + let intervalMs = roundInterval((range.to.valueOf() - range.from.valueOf()) / resolution); + if (lowLimitMs > intervalMs) { + intervalMs = lowLimitMs; + } + return { + intervalMs: intervalMs, + interval: secondsToHms(intervalMs / 1000), + }; +} + +const interval_regex = /(\d+(?:\.\d+)?)(ms|[Mwdhmsy])/; +// histogram & trends +const intervals_in_seconds = { + y: 31536000, + M: 2592000, + w: 604800, + d: 86400, + h: 3600, + m: 60, + s: 1, + ms: 0.001, +}; + +export function describeInterval(str: string) { + // Default to seconds if no unit is provided + if (Number(str)) { + return { + sec: intervals_in_seconds.s, + type: 's', + count: parseInt(str, 10), + }; + } + + const matches = str.match(interval_regex); + if (!matches || !has(intervals_in_seconds, matches[2])) { + throw new Error( + `Invalid interval string, has to be either unit-less or end with one of the following units: "${Object.keys( + intervals_in_seconds + ).join(', ')}"` + ); + } + return { + sec: (intervals_in_seconds as any)[matches[2]] as number, + type: matches[2], + count: parseInt(matches[1], 10), + }; +} + +export function intervalToSeconds(str: string): number { + const info = describeInterval(str); + return info.sec * info.count; +} + +export function intervalToMs(str: string): number { + const info = describeInterval(str); + return info.sec * 1000 * info.count; +} + +export function roundInterval(interval: number) { + switch (true) { + // 0.015s + case interval < 15: + return 10; // 0.01s + // 0.035s + case interval < 35: + return 20; // 0.02s + // 0.075s + case interval < 75: + return 50; // 0.05s + // 0.15s + case interval < 150: + return 100; // 0.1s + // 0.35s + case interval < 350: + return 200; // 0.2s + // 0.75s + case interval < 750: + return 500; // 0.5s + // 1.5s + case interval < 1500: + return 1000; // 1s + // 3.5s + case interval < 3500: + return 2000; // 2s + // 7.5s + case interval < 7500: + return 5000; // 5s + // 12.5s + case interval < 12500: + return 10000; // 10s + // 17.5s + case interval < 17500: + return 15000; // 15s + // 25s + case interval < 25000: + return 20000; // 20s + // 45s + case interval < 45000: + return 30000; // 30s + // 1.5m + case interval < 90000: + return 60000; // 1m + // 3.5m + case interval < 210000: + return 120000; // 2m + // 7.5m + case interval < 450000: + return 300000; // 5m + // 12.5m + case interval < 750000: + return 600000; // 10m + // 12.5m + case interval < 1050000: + return 900000; // 15m + // 25m + case interval < 1500000: + return 1200000; // 20m + // 45m + case interval < 2700000: + return 1800000; // 30m + // 1.5h + case interval < 5400000: + return 3600000; // 1h + // 2.5h + case interval < 9000000: + return 7200000; // 2h + // 4.5h + case interval < 16200000: + return 10800000; // 3h + // 9h + case interval < 32400000: + return 21600000; // 6h + // 1d + case interval < 86400000: + return 43200000; // 12h + // 1w + case interval < 604800000: + return 86400000; // 1d + // 3w + case interval < 1814400000: + return 604800000; // 1w + // 6w + case interval < 3628800000: + return 2592000000; // 30d + default: + return 31536000000; // 1y + } +} diff --git a/packages/grafana-data/src/datetime/timezones.test.ts b/packages/grafana-data/src/datetime/timezones.test.ts new file mode 100644 index 0000000..cd78b67 --- /dev/null +++ b/packages/grafana-data/src/datetime/timezones.test.ts @@ -0,0 +1,28 @@ +import { getTimeZoneInfo } from './timezones'; +import { setTimeZoneResolver } from './common'; + +describe('getTimeZoneInfo', () => { + // global timezone is set to Pacific/Easter, see jest-config.js file + + describe('IANA canonical name of the timezone', () => { + it('should resolve for default timezone', () => { + setTimeZoneResolver(() => 'browser'); + const result = getTimeZoneInfo('', Date.now()); + expect(result?.ianaName).toBe('Pacific/Easter'); + }); + + it('should resolve for browser timezone', () => { + const result = getTimeZoneInfo('browser', Date.now()); + expect(result?.ianaName).toBe('Pacific/Easter'); + }); + it('should resolve for utc timezone', () => { + const result = getTimeZoneInfo('utc', Date.now()); + expect(result?.ianaName).toBe('UTC'); + }); + + it('should resolve for given timezone', () => { + const result = getTimeZoneInfo('Europe/Warsaw', Date.now()); + expect(result?.ianaName).toBe('Europe/Warsaw'); + }); + }); +}); diff --git a/packages/grafana-data/src/datetime/timezones.ts b/packages/grafana-data/src/datetime/timezones.ts new file mode 100644 index 0000000..2d89ff3 --- /dev/null +++ b/packages/grafana-data/src/datetime/timezones.ts @@ -0,0 +1,436 @@ +import moment from 'moment-timezone'; +import { memoize } from 'lodash'; +import { TimeZone } from '../types'; +import { getTimeZone } from './common'; + +export enum InternalTimeZones { + default = '', + localBrowserTime = 'browser', + utc = 'utc', +} + +export const timeZoneFormatUserFriendly = (timeZone: TimeZone | undefined) => { + switch (getTimeZone({ timeZone })) { + case 'browser': + return 'Local browser time'; + case 'utc': + return 'UTC'; + default: + return timeZone; + } +}; + +export interface TimeZoneCountry { + code: string; + name: string; +} +export interface TimeZoneInfo { + name: string; + zone: string; + countries: TimeZoneCountry[]; + abbreviation: string; + offsetInMins: number; + ianaName: string; +} + +export interface GroupedTimeZones { + name: string; + zones: TimeZone[]; +} + +export const getTimeZoneInfo = (zone: string, timestamp: number): TimeZoneInfo | undefined => { + const internal = mapInternal(zone, timestamp); + + if (internal) { + return internal; + } + + return mapToInfo(zone, timestamp); +}; + +export const getTimeZones = memoize((includeInternal = false): TimeZone[] => { + const initial: TimeZone[] = []; + + if (includeInternal) { + initial.push.apply(initial, [InternalTimeZones.default, InternalTimeZones.localBrowserTime, InternalTimeZones.utc]); + } + + return moment.tz.names().reduce((zones: TimeZone[], zone: string) => { + const countriesForZone = countriesByTimeZone[zone]; + + if (!Array.isArray(countriesForZone) || countriesForZone.length === 0) { + return zones; + } + + zones.push(zone); + return zones; + }, initial); +}); + +export const getTimeZoneGroups = memoize((includeInternal = false): GroupedTimeZones[] => { + const timeZones = getTimeZones(includeInternal); + + const groups = timeZones.reduce((groups: Record, zone: TimeZone) => { + const delimiter = zone.indexOf('/'); + + if (delimiter === -1) { + const group = ''; + groups[group] = groups[group] ?? []; + groups[group].push(zone); + + return groups; + } + + const group = zone.substr(0, delimiter); + groups[group] = groups[group] ?? []; + groups[group].push(zone); + + return groups; + }, {}); + + return Object.keys(groups).map((name) => ({ + name, + zones: groups[name], + })); +}); + +const mapInternal = (zone: string, timestamp: number): TimeZoneInfo | undefined => { + switch (zone) { + case InternalTimeZones.utc: { + return { + name: 'Coordinated Universal Time', + ianaName: 'UTC', + zone, + countries: [], + abbreviation: 'UTC, GMT', + offsetInMins: 0, + }; + } + + case InternalTimeZones.default: { + const tz = getTimeZone(); + const isInternal = tz === 'browser' || tz === 'utc'; + const info = (isInternal ? mapInternal(tz, timestamp) : mapToInfo(tz, timestamp)) ?? {}; + + return { + countries: countriesByTimeZone[tz] ?? [], + abbreviation: '', + offsetInMins: 0, + ...info, + ianaName: (info as TimeZoneInfo).ianaName, + name: 'Default', + zone, + }; + } + + case InternalTimeZones.localBrowserTime: { + const tz = moment.tz.guess(true); + const info = mapToInfo(tz, timestamp) ?? {}; + + return { + countries: countriesByTimeZone[tz] ?? [], + abbreviation: 'Your local time', + offsetInMins: new Date().getTimezoneOffset(), + ...info, + name: 'Browser Time', + ianaName: (info as TimeZoneInfo).ianaName, + zone, + }; + } + + default: + return undefined; + } +}; + +const abbrevationWithoutOffset = (abbrevation: string): string => { + if (/^(\+|\-).+/.test(abbrevation)) { + return ''; + } + return abbrevation; +}; + +const mapToInfo = (timeZone: TimeZone, timestamp: number): TimeZoneInfo | undefined => { + const momentTz = moment.tz.zone(timeZone); + if (!momentTz) { + return undefined; + } + + return { + name: timeZone, + ianaName: momentTz.name, + zone: timeZone, + countries: countriesByTimeZone[timeZone] ?? [], + abbreviation: abbrevationWithoutOffset(momentTz.abbr(timestamp)), + offsetInMins: momentTz.utcOffset(timestamp), + }; +}; + +// Country names by ISO 3166-1-alpha-2 code +const countryByCode: Record = { + AF: 'Afghanistan', + AX: 'Aland Islands', + AL: 'Albania', + DZ: 'Algeria', + AS: 'American Samoa', + AD: 'Andorra', + AO: 'Angola', + AI: 'Anguilla', + AQ: 'Antarctica', + AG: 'Antigua And Barbuda', + AR: 'Argentina', + AM: 'Armenia', + AW: 'Aruba', + AU: 'Australia', + AT: 'Austria', + AZ: 'Azerbaijan', + BS: 'Bahamas', + BH: 'Bahrain', + BD: 'Bangladesh', + BB: 'Barbados', + BY: 'Belarus', + BE: 'Belgium', + BZ: 'Belize', + BJ: 'Benin', + BM: 'Bermuda', + BT: 'Bhutan', + BO: 'Bolivia', + BA: 'Bosnia And Herzegovina', + BW: 'Botswana', + BV: 'Bouvet Island', + BR: 'Brazil', + IO: 'British Indian Ocean Territory', + BN: 'Brunei Darussalam', + BG: 'Bulgaria', + BF: 'Burkina Faso', + BI: 'Burundi', + KH: 'Cambodia', + CM: 'Cameroon', + CA: 'Canada', + CV: 'Cape Verde', + KY: 'Cayman Islands', + CF: 'Central African Republic', + TD: 'Chad', + CL: 'Chile', + CN: 'China', + CX: 'Christmas Island', + CC: 'Cocos (Keeling) Islands', + CO: 'Colombia', + KM: 'Comoros', + CG: 'Congo', + CD: 'Congo, Democratic Republic', + CK: 'Cook Islands', + CR: 'Costa Rica', + CI: "Cote D'Ivoire", + HR: 'Croatia', + CU: 'Cuba', + CY: 'Cyprus', + CZ: 'Czech Republic', + DK: 'Denmark', + DJ: 'Djibouti', + DM: 'Dominica', + DO: 'Dominican Republic', + EC: 'Ecuador', + EG: 'Egypt', + SV: 'El Salvador', + GQ: 'Equatorial Guinea', + ER: 'Eritrea', + EE: 'Estonia', + ET: 'Ethiopia', + FK: 'Falkland Islands (Malvinas)', + FO: 'Faroe Islands', + FJ: 'Fiji', + FI: 'Finland', + FR: 'France', + GF: 'French Guiana', + PF: 'French Polynesia', + TF: 'French Southern Territories', + GA: 'Gabon', + GM: 'Gambia', + GE: 'Georgia', + DE: 'Germany', + GH: 'Ghana', + GI: 'Gibraltar', + GR: 'Greece', + GL: 'Greenland', + GD: 'Grenada', + GP: 'Guadeloupe', + GU: 'Guam', + GT: 'Guatemala', + GG: 'Guernsey', + GN: 'Guinea', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HT: 'Haiti', + HM: 'Heard Island & Mcdonald Islands', + VA: 'Holy See (Vatican City State)', + HN: 'Honduras', + HK: 'Hong Kong', + HU: 'Hungary', + IS: 'Iceland', + IN: 'India', + ID: 'Indonesia', + IR: 'Iran (Islamic Republic Of)', + IQ: 'Iraq', + IE: 'Ireland', + IM: 'Isle Of Man', + IL: 'Israel', + IT: 'Italy', + JM: 'Jamaica', + JP: 'Japan', + JE: 'Jersey', + JO: 'Jordan', + KZ: 'Kazakhstan', + KE: 'Kenya', + KI: 'Kiribati', + KR: 'Korea', + KW: 'Kuwait', + KG: 'Kyrgyzstan', + LA: "Lao People's Democratic Republic", + LV: 'Latvia', + LB: 'Lebanon', + LS: 'Lesotho', + LR: 'Liberia', + LY: 'Libyan Arab Jamahiriya', + LI: 'Liechtenstein', + LT: 'Lithuania', + LU: 'Luxembourg', + MO: 'Macao', + MK: 'Macedonia', + MG: 'Madagascar', + MW: 'Malawi', + MY: 'Malaysia', + MV: 'Maldives', + ML: 'Mali', + MT: 'Malta', + MH: 'Marshall Islands', + MQ: 'Martinique', + MR: 'Mauritania', + MU: 'Mauritius', + YT: 'Mayotte', + MX: 'Mexico', + FM: 'Micronesia (Federated States Of)', + MD: 'Moldova', + MC: 'Monaco', + MN: 'Mongolia', + ME: 'Montenegro', + MS: 'Montserrat', + MA: 'Morocco', + MZ: 'Mozambique', + MM: 'Myanmar', + NA: 'Namibia', + NR: 'Nauru', + NP: 'Nepal', + NL: 'Netherlands', + AN: 'Netherlands Antilles', + NC: 'New Caledonia', + NZ: 'New Zealand', + NI: 'Nicaragua', + NE: 'Niger', + NG: 'Nigeria', + NU: 'Niue', + NF: 'Norfolk Island', + MP: 'Northern Mariana Islands', + NO: 'Norway', + OM: 'Oman', + PK: 'Pakistan', + PW: 'Palau', + PS: 'Palestinian Territory (Occupied)', + PA: 'Panama', + PG: 'Papua New Guinea', + PY: 'Paraguay', + PE: 'Peru', + PH: 'Philippines', + PN: 'Pitcairn', + PL: 'Poland', + PT: 'Portugal', + PR: 'Puerto Rico', + QA: 'Qatar', + RE: 'Reunion', + RO: 'Romania', + RU: 'Russian Federation', + RW: 'Rwanda', + BL: 'Saint Barthelemy', + SH: 'Saint Helena', + KN: 'Saint Kitts And Nevis', + LC: 'Saint Lucia', + MF: 'Saint Martin', + PM: 'Saint Pierre And Miquelon', + VC: 'Saint Vincent And Grenadines', + WS: 'Samoa', + SM: 'San Marino', + ST: 'Sao Tome And Principe', + SA: 'Saudi Arabia', + SN: 'Senegal', + RS: 'Serbia', + SC: 'Seychelles', + SL: 'Sierra Leone', + SG: 'Singapore', + SK: 'Slovakia', + SI: 'Slovenia', + SB: 'Solomon Islands', + SO: 'Somalia', + ZA: 'South Africa', + GS: 'South Georgia And Sandwich Isl.', + ES: 'Spain', + LK: 'Sri Lanka', + SD: 'Sudan', + SR: 'Suriname', + SJ: 'Svalbard And Jan Mayen', + SZ: 'Swaziland', + SE: 'Sweden', + CH: 'Switzerland', + SY: 'Syrian Arab Republic', + TW: 'Taiwan', + TJ: 'Tajikistan', + TZ: 'Tanzania', + TH: 'Thailand', + TL: 'Timor-Leste', + TG: 'Togo', + TK: 'Tokelau', + TO: 'Tonga', + TT: 'Trinidad And Tobago', + TN: 'Tunisia', + TR: 'Turkey', + TM: 'Turkmenistan', + TC: 'Turks And Caicos Islands', + TV: 'Tuvalu', + UG: 'Uganda', + UA: 'Ukraine', + AE: 'United Arab Emirates', + GB: 'United Kingdom', + US: 'United States', + UM: 'United States Outlying Islands', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VU: 'Vanuatu', + VE: 'Venezuela', + VN: 'Viet Nam', + VG: 'Virgin Islands, British', + VI: 'Virgin Islands, U.S.', + WF: 'Wallis And Futuna', + EH: 'Western Sahara', + YE: 'Yemen', + ZM: 'Zambia', + ZW: 'Zimbabwe', +}; + +const countriesByTimeZone = ((): Record => { + return moment.tz.countries().reduce((all: Record, code) => { + const timeZones = moment.tz.zonesForCountry(code); + return timeZones.reduce((all: Record, timeZone) => { + if (!all[timeZone]) { + all[timeZone] = []; + } + + const name = countryByCode[code]; + + if (!name) { + return all; + } + + all[timeZone].push({ code, name }); + return all; + }, all); + }, {}); +})(); diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts new file mode 100644 index 0000000..e8ea84e --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -0,0 +1,173 @@ +import { EventBusSrv } from './EventBus'; +import { BusEventWithPayload } from './types'; +import { eventFactory } from './eventFactory'; + +interface LoginEventPayload { + logins: number; +} + +interface HelloEventPayload { + hellos: number; +} + +class LoginEvent extends BusEventWithPayload { + static type = 'login-event'; +} + +class HelloEvent extends BusEventWithPayload { + static type = 'hello-event'; +} + +type LegacyEventPayload = [string, string]; + +export const legacyEvent = eventFactory('legacy-event'); + +class AlertSuccessEvent extends BusEventWithPayload { + static type = 'legacy-event'; +} + +describe('EventBus', () => { + it('Can create events', () => { + expect(new LoginEvent({ logins: 1 }).type).toBe('login-event'); + }); + + it('Can subscribe specific event', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, (event) => { + events.push(event); + }); + + bus.publish(new LoginEvent({ logins: 10 })); + bus.publish(new HelloEvent({ hellos: 10 })); + + expect(events[0].payload.logins).toBe(10); + expect(events.length).toBe(1); + }); + + describe('Legacy emitter behavior', () => { + it('Supports legacy events', () => { + const bus = new EventBusSrv(); + const events: any = []; + const handler = (event: LegacyEventPayload) => { + events.push(event); + }; + + bus.on(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + bus.off(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + expect(events.length).toEqual(1); + expect(events[0]).toEqual(['hello', 'hello2']); + }); + + it('Interoperability with legacy events', () => { + const bus = new EventBusSrv(); + const legacyEvents: any = []; + const newEvents: any = []; + + bus.on(legacyEvent, (event) => { + legacyEvents.push(event); + }); + + bus.subscribe(AlertSuccessEvent, (event) => { + newEvents.push(event); + }); + + bus.emit(legacyEvent, ['legacy', 'params']); + bus.publish(new AlertSuccessEvent(['new', 'event'])); + + expect(legacyEvents).toEqual([ + ['legacy', 'params'], + ['new', 'event'], + ]); + + expect(newEvents).toEqual([ + { + type: 'legacy-event', + payload: ['legacy', 'params'], + }, + { + type: 'legacy-event', + payload: ['new', 'event'], + }, + ]); + }); + + it('should notfiy subscribers', () => { + const bus = new EventBusSrv(); + let sub1Called = false; + let sub2Called = false; + + bus.on(legacyEvent, () => { + sub1Called = true; + }); + bus.on(legacyEvent, () => { + sub2Called = true; + }); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(true); + expect(sub2Called).toBe(true); + }); + + it('when subscribing twice', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + + function handler() { + sub1Called += 1; + } + + bus.on(legacyEvent, handler); + bus.on(legacyEvent, handler); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(2); + }); + + it('should handle errors', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + let sub2Called = 0; + + bus.on(legacyEvent, () => { + sub1Called++; + throw { message: 'hello' }; + }); + + bus.on(legacyEvent, () => { + sub2Called++; + }); + + try { + bus.emit(legacyEvent, null); + } catch (_) {} + try { + bus.emit(legacyEvent, null); + } catch (_) {} + + expect(sub1Called).toBe(2); + expect(sub2Called).toBe(0); + }); + + it('removeAllListeners should unsubscribe to all', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, (event) => { + events.push(event); + }); + + bus.removeAllListeners(); + bus.publish(new LoginEvent({ logins: 10 })); + + expect(events.length).toBe(0); + }); + }); +}); diff --git a/packages/grafana-data/src/events/EventBus.ts b/packages/grafana-data/src/events/EventBus.ts new file mode 100644 index 0000000..a9d1020 --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.ts @@ -0,0 +1,92 @@ +import EventEmitter from 'eventemitter3'; +import { Unsubscribable, Observable } from 'rxjs'; +import { + EventBus, + LegacyEmitter, + BusEventHandler, + BusEventType, + LegacyEventHandler, + BusEvent, + AppEvent, +} from './types'; + +/** + * @alpha + */ +export class EventBusSrv implements EventBus, LegacyEmitter { + private emitter: EventEmitter; + + constructor() { + this.emitter = new EventEmitter(); + } + + publish(event: T): void { + this.emitter.emit(event.type, event); + } + + subscribe(typeFilter: BusEventType, handler: BusEventHandler): Unsubscribable { + return this.getStream(typeFilter).subscribe({ next: handler }); + } + + getStream(eventType: BusEventType): Observable { + return new Observable((observer) => { + const handler = (event: T) => { + observer.next(event); + }; + + this.emitter.on(eventType.type, handler); + + return () => { + this.emitter.off(eventType.type, handler); + }; + }); + } + + /** + * Legacy functions + */ + emit(event: AppEvent | string, payload?: T | any): void { + // console.log(`Deprecated emitter function used (emit), use $emit`); + + if (typeof event === 'string') { + this.emitter.emit(event, { type: event, payload }); + } else { + this.emitter.emit(event.name, { type: event.name, payload }); + } + } + + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any) { + // console.log(`Deprecated emitter function used (on), use $on`); + + // need this wrapper to make old events compatible with old handlers + handler.wrapper = (emittedEvent: BusEvent) => { + handler(emittedEvent.payload); + }; + + if (typeof event === 'string') { + this.emitter.on(event, handler.wrapper); + } else { + this.emitter.on(event.name, handler.wrapper); + } + + if (scope) { + const unbind = scope.$on('$destroy', () => { + this.off(event, handler); + unbind(); + }); + } + } + + off(event: AppEvent | string, handler: LegacyEventHandler) { + if (typeof event === 'string') { + this.emitter.off(event, handler.wrapper); + return; + } + + this.emitter.off(event.name, handler.wrapper); + } + + removeAllListeners() { + this.emitter.removeAllListeners(); + } +} diff --git a/packages/grafana-data/src/events/eventFactory.ts b/packages/grafana-data/src/events/eventFactory.ts new file mode 100644 index 0000000..2dd1b92 --- /dev/null +++ b/packages/grafana-data/src/events/eventFactory.ts @@ -0,0 +1,12 @@ +import { AppEvent } from './types'; + +const typeList: Set = new Set(); + +export function eventFactory(name: string): AppEvent { + if (typeList.has(name)) { + throw new Error(`There is already an event defined with type '${name}'`); + } + + typeList.add(name); + return { name }; +} diff --git a/packages/grafana-data/src/events/index.ts b/packages/grafana-data/src/events/index.ts new file mode 100644 index 0000000..69c33f4 --- /dev/null +++ b/packages/grafana-data/src/events/index.ts @@ -0,0 +1,3 @@ +export * from './eventFactory'; +export * from './types'; +export * from './EventBus'; diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts new file mode 100644 index 0000000..63b4738 --- /dev/null +++ b/packages/grafana-data/src/events/types.ts @@ -0,0 +1,115 @@ +import { Unsubscribable, Observable } from 'rxjs'; + +/** + * @alpha + * internal interface + */ +export interface BusEvent { + readonly type: string; + readonly payload?: any; +} + +/** + * @alpha + * Base event type + */ +export abstract class BusEventBase implements BusEvent { + readonly type: string; + readonly payload?: any; + + constructor() { + //@ts-ignore + this.type = this.__proto__.constructor.type; + } +} + +/** + * @alpha + * Base event type with payload + */ +export abstract class BusEventWithPayload extends BusEventBase { + readonly payload: T; + + constructor(payload: T) { + super(); + this.payload = payload; + } +} + +/* + * Interface for an event type constructor + */ +export interface BusEventType { + type: string; + new (...args: any[]): T; +} + +/** + * @alpha + * Event callback/handler type + */ +export interface BusEventHandler { + (event: T): void; +} + +/** + * @alpha + * Main minimal interface + */ +export interface EventBus { + /** + * Publish single vent + */ + publish(event: T): void; + + /** + * Subscribe to single event + */ + subscribe(eventType: BusEventType, handler: BusEventHandler): Unsubscribable; + + /** + * Get observable of events + */ + getStream(eventType: BusEventType): Observable; + + /** + * Remove all event subscriptions + */ + removeAllListeners(): void; +} + +/** + * @public + * @deprecated event type + */ +export interface AppEvent { + readonly name: string; + payload?: T; +} + +/** @public */ +export interface LegacyEmitter { + /** + * @deprecated use $emit + */ + emit(event: AppEvent | string, payload?: T): void; + + /** + * @deprecated use $on + */ + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any): void; + + /** + * @deprecated use $on + */ + off(event: AppEvent | string, handler: (payload?: T | any) => void): void; +} + +/** @public */ +export interface LegacyEventHandler { + (payload: T): void; + wrapper?: (event: BusEvent) => void; +} + +/** @alpha */ +export interface EventBusExtended extends EventBus, LegacyEmitter {} diff --git a/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx b/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx new file mode 100644 index 0000000..9166a6b --- /dev/null +++ b/packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx @@ -0,0 +1,4 @@ +import { Registry } from '../utils/Registry'; +import { FieldConfigPropertyItem } from '../types/fieldOverrides'; + +export class FieldConfigOptionsRegistry extends Registry {} diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts new file mode 100644 index 0000000..261e15b --- /dev/null +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -0,0 +1,356 @@ +import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { DisplayProcessor, DisplayValue } from '../types/displayValue'; +import { MappingType, ValueMapping } from '../types/valueMapping'; +import { FieldConfig, FieldType, ThresholdsMode } from '../types'; +import { systemDateFormats } from '../datetime'; + +function getDisplayProcessorFromConfig(config: FieldConfig) { + return getDisplayProcessor({ + field: { + config, + type: FieldType.number, + }, + }); +} + +function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) { + processors.forEach((processor) => { + const value = processor(input); + for (const key of Object.keys(match)) { + expect((value as any)[key]).toEqual((match as any)[key]); + } + }); +} + +describe('Process simple display values', () => { + // Don't test float values here since the decimal formatting changes + const processors = [ + // Without options, this shortcuts to a much easier implementation + getDisplayProcessor({ field: { config: {} } }), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessorFromConfig({ min: 0, max: 100 }), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessorFromConfig({ unit: 'locale' }), + ]; + + it('support null', () => { + assertSame(null, processors, { text: '', numeric: NaN }); + }); + + it('support undefined', () => { + assertSame(undefined, processors, { text: '', numeric: NaN }); + }); + + it('support NaN', () => { + assertSame(NaN, processors, { text: 'NaN', numeric: NaN }); + }); + + it('Integer', () => { + assertSame(3, processors, { text: '3', numeric: 3 }); + }); + + it('Text to number', () => { + assertSame('3', processors, { text: '3', numeric: 3 }); + }); + + it('Empty string is NaN', () => { + assertSame('', processors, { text: '', numeric: NaN }); + }); + + it('Simple String', () => { + assertSame('hello', processors, { text: 'hello', numeric: NaN }); + }); + + it('empty array', () => { + assertSame([], processors, { text: '', numeric: NaN }); + }); + + it('array of text', () => { + assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN }); + }); + + it('array of numbers', () => { + assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN }); + }); + + it('empty object', () => { + assertSame({}, processors, { text: '[object Object]', numeric: NaN }); + }); + + it('boolean true', () => { + assertSame(true, processors, { text: 'true', numeric: 1 }); + }); + + it('boolean false', () => { + assertSame(false, processors, { text: 'false', numeric: 0 }); + }); +}); + +describe('Process null values', () => { + const processors = [ + getDisplayProcessorFromConfig({ + min: 0, + max: 100, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: '#000' }, + { value: 0, color: '#100' }, + { value: 100, color: '#200' }, + ], + }, + }), + ]; + + it('Null should get -Infinity (base) color', () => { + assertSame(null, processors, { text: '', numeric: NaN, color: '#000' }); + }); +}); + +describe('Format value', () => { + it('should return if value isNaN', () => { + const valueMappings: ValueMapping[] = []; + const value = 'N/A'; + const instance = getDisplayProcessorFromConfig({ mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('N/A'); + }); + + it('should return formatted value if there are no value mappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '6'; + + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('6.0'); + }); + + it('should return formatted value if there are no matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('10.0'); + }); + + it('should return mapped value if there are matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '11'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + expect(instance(value).text).toEqual('1-20'); + }); + + it('should return mapped value and leave numeric value in tact if value mapping maps to empty string', () => { + const valueMappings: ValueMapping[] = [{ id: 1, text: '', type: MappingType.ValueToText, value: '1' }]; + const value = '1'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings }); + + expect(instance(value).text).toEqual(''); + expect(instance(value).numeric).toEqual(1); + }); + + it('should not map 1kW to the value for 1W', () => { + const valueMappings: ValueMapping[] = [{ id: 0, text: 'mapped', type: MappingType.ValueToText, value: '1' }]; + const value = '1000'; + const instance = getDisplayProcessorFromConfig({ decimals: 1, mappings: valueMappings, unit: 'watt' }); + + const result = instance(value); + + expect(result.text).toEqual('1.0'); + }); + + it('With null value and thresholds should use base color', () => { + const instance = getDisplayProcessorFromConfig({ + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [{ value: -Infinity, color: '#AAA' }], + }, + }); + const disp = instance(null); + expect(disp.text).toEqual(''); + expect(disp.color).toEqual('#AAA'); + }); + + // + // Below is current behavior but it's clearly not working great + // + + it('with value 1000 and unit short', () => { + const value = 1000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 1200 and unit short', () => { + const value = 1200; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.20'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 1250 and unit short', () => { + const value = 1250; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.25'); + expect(disp.suffix).toEqual(' K'); + }); + + it('with value 10000000 and unit short', () => { + const value = 1000000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1'); + expect(disp.suffix).toEqual(' Mil'); + }); + + it('with value 15000000 and unit short', () => { + const value = 1500000; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'short' }); + const disp = instance(value); + expect(disp.text).toEqual('1.50'); + expect(disp.suffix).toEqual(' Mil'); + }); + + it('with value 128000000 and unit bytes', () => { + const value = 1280000125; + const instance = getDisplayProcessorFromConfig({ decimals: null, unit: 'bytes' }); + const disp = instance(value); + expect(disp.text).toEqual('1.19'); + expect(disp.suffix).toEqual(' GiB'); + }); +}); + +describe('Date display options', () => { + it('should format UTC dates', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'xyz', // ignore non-date formats + }, + }, + }); + expect(processor(0).text).toEqual('1970-01-01 00:00:00'); + }); + + it('should pick configured time format', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'dateTimeAsUS', // ignore non-date formats + }, + }, + }); + expect(processor(0).text).toEqual('01/01/1970 12:00:00 am'); + }); + + it('respect the configured date format', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: { + unit: 'time:YYYY', // ignore non-date formats + }, + }, + }); + expect(processor(0).text).toEqual('1970'); + }); + + it('Should use system date format by default', () => { + const currentFormat = systemDateFormats.fullDate; + systemDateFormats.fullDate = 'YYYY-MM'; + + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: {}, + }, + }); + + expect(processor(0).text).toEqual('1970-01'); + + systemDateFormats.fullDate = currentFormat; + }); + + it('should handle ISO string dates', () => { + const processor = getDisplayProcessor({ + timeZone: 'utc', + field: { + type: FieldType.time, + config: {}, + }, + }); + + expect(processor('2020-08-01T08:48:43.783337Z').text).toEqual('2020-08-01 08:48:43'); + }); + + describe('number formatting for string values', () => { + it('should preserve string unchanged if unit is strings', () => { + const processor = getDisplayProcessor({ + field: { + type: FieldType.string, + config: { unit: 'string' }, + }, + }); + expect(processor('22.1122334455').text).toEqual('22.1122334455'); + }); + + it('should format string as number if no unit', () => { + const processor = getDisplayProcessor({ + field: { + type: FieldType.string, + config: { decimals: 2 }, + }, + }); + expect(processor('22.1122334455').text).toEqual('22.11'); + }); + }); +}); + +describe('getRawDisplayProcessor', () => { + const processor = getRawDisplayProcessor(); + const date = new Date('2020-01-01T00:00:00.000Z'); + const timestamp = date.valueOf(); + + it.each` + value | expected + ${0} | ${'0'} + ${13.37} | ${'13.37'} + ${true} | ${'true'} + ${false} | ${'false'} + ${date} | ${`${date}`} + ${timestamp} | ${'1577836800000'} + ${'a string'} | ${'a string'} + ${null} | ${'null'} + ${undefined} | ${'undefined'} + ${{ value: 0, label: 'a label' }} | ${'[object Object]'} + `('when called with value:{$value}', ({ value, expected }) => { + const result = processor(value); + + expect(result).toEqual({ text: expected, numeric: null }); + }); +}); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts new file mode 100644 index 0000000..c552521 --- /dev/null +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -0,0 +1,138 @@ +// Libraries +import _ from 'lodash'; + +// Types +import { Field, FieldType } from '../types/dataFrame'; +import { GrafanaTheme } from '../types/theme'; +import { DisplayProcessor, DisplayValue } from '../types/displayValue'; +import { getValueFormat } from '../valueFormats/valueFormats'; +import { getMappedValue } from '../utils/valueMappings'; +import { dateTime } from '../datetime'; +import { KeyValue, TimeZone } from '../types'; +import { getScaleCalculator } from './scale'; +import { getTestTheme } from '../utils/testdata/testTheme'; + +interface DisplayProcessorOptions { + field: Partial; + /** + * Will pick browser timezone if not defined + */ + timeZone?: TimeZone; + /** + * Will pick 'dark' if not defined + */ + theme?: GrafanaTheme; +} + +// Reasonable units for time +const timeFormats: KeyValue = { + dateTimeAsIso: true, + dateTimeAsIsoNoDateIfToday: true, + dateTimeAsUS: true, + dateTimeAsUSNoDateIfToday: true, + dateTimeAsLocal: true, + dateTimeAsLocalNoDateIfToday: true, + dateTimeFromNow: true, +}; + +export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayProcessor { + if (!options || _.isEmpty(options) || !options.field) { + return toStringProcessor; + } + + const { field } = options; + const config = field.config ?? {}; + + // Theme should be required or we need access to default theme instance from here + const theme = options.theme ?? getTestTheme(); + + let unit = config.unit; + let hasDateUnit = unit && (timeFormats[unit] || unit.startsWith('time:')); + + if (field.type === FieldType.time && !hasDateUnit) { + unit = `dateTimeAsSystem`; + hasDateUnit = true; + } + + const formatFunc = getValueFormat(unit || 'none'); + const scaleFunc = getScaleCalculator(field as Field, theme); + + return (value: any) => { + const { mappings } = config; + const isStringUnit = unit === 'string'; + + if (hasDateUnit && typeof value === 'string') { + value = dateTime(value).valueOf(); + } + + let text = _.toString(value); + let numeric = isStringUnit ? NaN : toNumber(value); + let prefix: string | undefined = undefined; + let suffix: string | undefined = undefined; + let shouldFormat = true; + + if (mappings && mappings.length > 0) { + const mappedValue = getMappedValue(mappings, value); + + if (mappedValue) { + text = mappedValue.text; + const v = isStringUnit ? NaN : toNumber(text); + + if (!isNaN(v)) { + numeric = v; + } + + shouldFormat = false; + } + } + + if (!isNaN(numeric)) { + if (shouldFormat && !_.isBoolean(value)) { + const v = formatFunc(numeric, config.decimals, null, options.timeZone); + text = v.text; + suffix = v.suffix; + prefix = v.prefix; + } + + // Return the value along with scale info + if (text) { + return { text, numeric, prefix, suffix, ...scaleFunc(numeric) }; + } + } + + if (!text) { + if (config.noValue) { + text = config.noValue; + } else { + text = ''; // No data? + } + } + + return { text, numeric, prefix, suffix, ...scaleFunc(-Infinity) }; + }; +} + +/** Will return any value as a number or NaN */ +function toNumber(value: any): number { + if (typeof value === 'number') { + return value; + } + if (value === '' || value === null || value === undefined || Array.isArray(value)) { + return NaN; // lodash calls them 0 + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + return _.toNumber(value); +} + +function toStringProcessor(value: any): DisplayValue { + return { text: _.toString(value), numeric: toNumber(value) }; +} + +export function getRawDisplayProcessor(): DisplayProcessor { + return (value: any) => ({ + text: `${value}`, + numeric: (null as unknown) as number, + }); +} diff --git a/packages/grafana-data/src/field/fieldColor.test.ts b/packages/grafana-data/src/field/fieldColor.test.ts new file mode 100644 index 0000000..295e71a --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.test.ts @@ -0,0 +1,91 @@ +import { Field, FieldColorModeId, FieldType } from '../types'; +import { getTestTheme } from '../utils/testdata/testTheme'; +import { ArrayVector } from '../vector/ArrayVector'; +import { fieldColorModeRegistry, FieldValueColorCalculator, getFieldSeriesColor } from './fieldColor'; + +function getTestField(mode: string): Field { + return { + name: 'name', + type: FieldType.number, + values: new ArrayVector(), + config: { + color: { + mode: mode, + } as any, + }, + state: {}, + }; +} + +interface GetCalcOptions { + mode: string; + seriesIndex?: number; +} + +function getCalculator(options: GetCalcOptions): FieldValueColorCalculator { + const field = getTestField(options.mode); + const mode = fieldColorModeRegistry.get(options.mode); + field.state!.seriesIndex = options.seriesIndex; + return mode.getCalculator(field, getTestTheme()); +} + +describe('fieldColorModeRegistry', () => { + it('Schemes should interpolate', () => { + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + expect(calcFn(70, 0.5, undefined)).toEqual('rgb(226, 192, 61)'); + }); + + it('Palette classic with series index 0', () => { + const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 0 }); + expect(calcFn(70, 0, undefined)).toEqual('#7EB26D'); + }); + + it('Palette classic with series index 1', () => { + const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 1 }); + expect(calcFn(70, 0, undefined)).toEqual('#EAB839'); + }); + + it('When color.seriesBy is set to last use that instead of v', () => { + const field = getTestField('continuous-GrYlRd'); + + field.config.color!.seriesBy = 'last'; + // min = -10, max = 10, last = 5 + // last percent 75% + field.values = new ArrayVector([0, -10, 5, 10, 2, 5]); + + const color = getFieldSeriesColor(field, getTestTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(4, 0.75)); + }); +}); + +describe('getFieldSeriesColor', () => { + const field = getTestField('continuous-GrYlRd'); + field.values = new ArrayVector([0, -10, 5, 10, 2, 5]); + + it('When color.seriesBy is last use that to calc series color', () => { + field.config.color!.seriesBy = 'last'; + const color = getFieldSeriesColor(field, getTestTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + // the 4 can be anything, 0.75 comes from 5 being 75% in the range -10 to 10 (see data above) + expect(color.color).toEqual(calcFn(4, 0.75)); + }); + + it('When color.seriesBy is max use that to calc series color', () => { + field.config.color!.seriesBy = 'max'; + const color = getFieldSeriesColor(field, getTestTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(10, 1)); + }); + + it('When color.seriesBy is min use that to calc series color', () => { + field.config.color!.seriesBy = 'min'; + const color = getFieldSeriesColor(field, getTestTheme()); + const calcFn = getCalculator({ mode: 'continuous-GrYlRd' }); + + expect(color.color).toEqual(calcFn(-10, 0)); + }); +}); diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts new file mode 100644 index 0000000..2da0b53 --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -0,0 +1,242 @@ +import { FALLBACK_COLOR, Field, FieldColorModeId, GrafanaTheme, Threshold } from '../types'; +import { classicColors, getColorForTheme, RegistryItem } from '../utils'; +import { Registry } from '../utils/Registry'; +import { interpolateRgbBasis } from 'd3-interpolate'; +import { fallBackTreshold } from './thresholds'; +import { getScaleCalculator, ColorScaleValue } from './scale'; +import { reduceField } from '../transformations/fieldReducer'; + +export type FieldValueColorCalculator = (value: number, percent: number, Threshold?: Threshold) => string; + +export interface FieldColorMode extends RegistryItem { + getCalculator: (field: Field, theme: GrafanaTheme) => FieldValueColorCalculator; + colors?: string[]; + isContinuous?: boolean; + isByValue?: boolean; +} + +export const fieldColorModeRegistry = new Registry(() => { + return [ + { + id: FieldColorModeId.Fixed, + name: 'Single color', + description: 'Set a specific color', + getCalculator: getFixedColor, + }, + { + id: FieldColorModeId.Thresholds, + name: 'From thresholds', + isByValue: true, + description: 'Derive colors from thresholds', + getCalculator: (_field, theme) => { + return (_value, _percent, threshold) => { + const thresholdSafe = threshold ?? fallBackTreshold; + return getColorForTheme(thresholdSafe.color, theme); + }; + }, + }, + // new FieldColorSchemeMode({ + // id: FieldColorModeId.PaletteSaturated, + // name: 'Saturated palette', + // //description: 'Assigns color based on series or field index', + // isContinuous: false, + // isByValue: false, + // colors: [ + // 'blue', + // 'red', + // 'green', + // 'orange', + // 'purple', + // 'orange', + // 'dark-blue', + // 'dark-red', + // 'dark-yellow', + // 'dark-purple', + // 'dark-orange', + // ], + // }), + new FieldColorSchemeMode({ + id: FieldColorModeId.PaletteClassic, + name: 'Classic palette', + isContinuous: false, + isByValue: false, + colors: classicColors, + }), + new FieldColorSchemeMode({ + id: 'continuous-GrYlRd', + name: 'Green-Yellow-Red', + isContinuous: true, + isByValue: true, + colors: ['green', 'yellow', 'red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-RdYlGr', + name: 'Red-Yellow-Green', + isContinuous: true, + isByValue: true, + colors: ['red', 'yellow', 'green'], + }), + new FieldColorSchemeMode({ + id: 'continuous-BlYlRd', + name: 'Blue-Yellow-Red', + isContinuous: true, + isByValue: true, + colors: ['dark-blue', 'super-light-yellow', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-YlRd', + name: 'Yellow-Red', + isContinuous: true, + isByValue: true, + colors: ['super-light-yellow', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-BlPu', + name: 'Blue-Purple', + isContinuous: true, + isByValue: true, + colors: ['blue', 'purple'], + }), + new FieldColorSchemeMode({ + id: 'continuous-YlBl', + name: 'Yellow-Blue', + isContinuous: true, + isByValue: true, + colors: ['super-light-yellow', 'dark-blue'], + }), + new FieldColorSchemeMode({ + id: 'continuous-blues', + name: 'Blues', + isContinuous: true, + isByValue: true, + colors: ['panel-bg', 'dark-blue'], + }), + new FieldColorSchemeMode({ + id: 'continuous-reds', + name: 'Reds', + isContinuous: true, + isByValue: true, + colors: ['panel-bg', 'dark-red'], + }), + new FieldColorSchemeMode({ + id: 'continuous-greens', + name: 'Greens', + isContinuous: true, + isByValue: true, + colors: ['panel-bg', 'dark-green'], + }), + new FieldColorSchemeMode({ + id: 'continuous-purples', + name: 'Purples', + isContinuous: true, + isByValue: true, + colors: ['panel-bg', 'dark-purple'], + }), + ]; +}); + +interface FieldColorSchemeModeOptions { + id: string; + name: string; + description?: string; + colors: string[]; + isContinuous: boolean; + isByValue: boolean; +} + +export class FieldColorSchemeMode implements FieldColorMode { + id: string; + name: string; + description?: string; + colors: string[]; + isContinuous: boolean; + isByValue: boolean; + colorCache?: string[]; + interpolator?: (value: number) => string; + + constructor(options: FieldColorSchemeModeOptions) { + this.id = options.id; + this.name = options.name; + this.description = options.description; + this.colors = options.colors; + this.isContinuous = options.isContinuous; + this.isByValue = options.isByValue; + } + + private getColors(theme: GrafanaTheme) { + if (this.colorCache) { + return this.colorCache; + } + + this.colorCache = this.colors.map((c) => getColorForTheme(c, theme)); + return this.colorCache; + } + + private getInterpolator() { + if (!this.interpolator) { + this.interpolator = interpolateRgbBasis(this.colorCache!); + } + + return this.interpolator; + } + + getCalculator(field: Field, theme: GrafanaTheme) { + const colors = this.getColors(theme); + + if (this.isByValue) { + if (this.isContinuous) { + return (_: number, percent: number, _threshold?: Threshold) => { + return this.getInterpolator()(percent); + }; + } else { + return (_: number, percent: number, _threshold?: Threshold) => { + return colors[percent * (colors.length - 1)]; + }; + } + } else { + const seriesIndex = field.state?.seriesIndex ?? 0; + + return (_: number, _percent: number, _threshold?: Threshold) => { + return colors[seriesIndex % colors.length]; + }; + } + } +} + +export function getFieldColorModeForField(field: Field): FieldColorMode { + return fieldColorModeRegistry.get(field.config.color?.mode ?? FieldColorModeId.Thresholds); +} + +export function getFieldColorMode(mode?: FieldColorModeId): FieldColorMode { + return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds); +} + +/** + * @alpha + * Function that will return a series color for any given color mode. If the color mode is a by value color + * mode it will use the field.config.color.seriesBy property to figure out which value to use + */ +export function getFieldSeriesColor(field: Field, theme: GrafanaTheme): ColorScaleValue { + const mode = getFieldColorModeForField(field); + + if (!mode.isByValue) { + return { + color: mode.getCalculator(field, theme)(0, 0), + threshold: fallBackTreshold, + percent: 1, + }; + } + + const scale = getScaleCalculator(field, theme); + const stat = field.config.color?.seriesBy ?? 'last'; + const calcs = reduceField({ field, reducers: [stat] }); + const value = calcs[stat] ?? 0; + + return scale(value); +} + +function getFixedColor(field: Field, theme: GrafanaTheme) { + return () => { + return getColorForTheme(field.config.color?.fixedColor ?? FALLBACK_COLOR, theme); + }; +} diff --git a/packages/grafana-data/src/field/fieldComparers.ts b/packages/grafana-data/src/field/fieldComparers.ts new file mode 100644 index 0000000..c98c78d --- /dev/null +++ b/packages/grafana-data/src/field/fieldComparers.ts @@ -0,0 +1,108 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { Vector } from '../types/vector'; +import { dateTime } from '../datetime'; +import isNumber from 'lodash/isNumber'; + +type IndexComparer = (a: number, b: number) => number; + +export const fieldIndexComparer = (field: Field, reverse = false): IndexComparer => { + const values = field.values; + + switch (field.type) { + case FieldType.number: + return numericIndexComparer(values, reverse); + case FieldType.string: + return stringIndexComparer(values, reverse); + case FieldType.boolean: + return booleanIndexComparer(values, reverse); + case FieldType.time: + return timeIndexComparer(values, reverse); + default: + return naturalIndexComparer(reverse); + } +}; + +export const timeComparer = (a: any, b: any): number => { + if (!a || !b) { + return falsyComparer(a, b); + } + + if (isNumber(a) && isNumber(b)) { + return numericComparer(a, b); + } + + if (dateTime(a).isBefore(b)) { + return -1; + } + + if (dateTime(b).isBefore(a)) { + return 1; + } + + return 0; +}; + +export const numericComparer = (a: number, b: number): number => { + return a - b; +}; + +export const stringComparer = (a: string, b: string): number => { + if (!a || !b) { + return falsyComparer(a, b); + } + return a.localeCompare(b); +}; + +export const booleanComparer = (a: boolean, b: boolean): number => { + return falsyComparer(a, b); +}; + +const falsyComparer = (a: any, b: any): number => { + if (!a && b) { + return 1; + } + + if (a && !b) { + return -1; + } + + return 0; +}; + +const timeIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA = values.get(a); + const vB = values.get(b); + return reverse ? timeComparer(vB, vA) : timeComparer(vA, vB); + }; +}; + +const booleanIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: boolean = values.get(a); + const vB: boolean = values.get(b); + return reverse ? booleanComparer(vB, vA) : booleanComparer(vA, vB); + }; +}; + +const numericIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: number = values.get(a); + const vB: number = values.get(b); + return reverse ? numericComparer(vB, vA) : numericComparer(vA, vB); + }; +}; + +const stringIndexComparer = (values: Vector, reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + const vA: string = values.get(a); + const vB: string = values.get(b); + return reverse ? stringComparer(vB, vA) : stringComparer(vA, vB); + }; +}; + +const naturalIndexComparer = (reverse: boolean): IndexComparer => { + return (a: number, b: number): number => { + return reverse ? numericComparer(b, a) : numericComparer(a, b); + }; +}; diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts new file mode 100644 index 0000000..e521232 --- /dev/null +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -0,0 +1,248 @@ +import merge from 'lodash/merge'; +import { getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; +import { toDataFrame } from '../dataframe/processDataFrame'; +import { ReducerID } from '../transformations/fieldReducer'; +import { MappingType } from '../types'; +import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; +import { getTestTheme } from '../utils/testdata/testTheme'; + +describe('FieldDisplay', () => { + beforeAll(() => { + // Since FieldConfigEditors belong to grafana-ui we need to mock those here + // as grafana-ui code cannot be imported in grafana-data. + // TODO: figure out a way to share standard editors between data/ui tests + const mappings = { + id: 'mappings', // Match field properties + process: (value: any) => value, + shouldApply: () => true, + } as any; + + standardFieldConfigEditorRegistry.setInit(() => { + return [mappings]; + }); + }); + + it('show first numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + }, + fieldConfig: { + overrides: [], + defaults: { + displayName: '$__cell_0 * $__field_name * $__series_name', + }, + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.text)).toEqual(['1', '2']); + }); + + it('show last numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.last], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([5, 6]); + }); + + it('show all numeric values', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, // + limit: 1000, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([1, 3, 5, 2, 4, 6]); + }); + + it('show 2 numeric values (limit)', () => { + const options = createDisplayOptions({ + reduceOptions: { + values: true, // + limit: 2, + calcs: [], + }, + }); + const display = getFieldDisplayValues(options); + expect(display.map((v) => v.display.numeric)).toEqual([1, 3]); // First 2 are from the first field + }); + + it('Should return field thresholds when there is no data', () => { + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: { + thresholds: { steps: [{ color: '#F2495C', value: 50 }] }, + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].field.thresholds!.steps!.length).toEqual(1); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should return field with default text when no mapping or data available', () => { + const options = createEmptyDisplayOptions(); + const display = getFieldDisplayValues(options); + expect(display[0].display.text).toEqual('No data'); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should return field mapped value when there is no data', () => { + const mapEmptyToText = '0'; + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: { + mappings: [ + { + id: 1, + operator: '', + text: mapEmptyToText, + type: MappingType.ValueToText, + value: 'null', + }, + ], + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].display.text).toEqual(mapEmptyToText); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should always return display numeric 0 when there is no data', () => { + const mapEmptyToText = '0'; + const options = createEmptyDisplayOptions({ + fieldConfig: { + overrides: { + mappings: [ + { + id: 1, + operator: '', + text: mapEmptyToText, + type: MappingType.ValueToText, + value: 'null', + }, + ], + }, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].display.numeric).toEqual(0); + }); + + it('Should always return defaults with min/max 0 when there is no data', () => { + const options = createEmptyDisplayOptions({ + fieldConfig: { + defaults: {}, + }, + }); + + const display = getFieldDisplayValues(options); + expect(display[0].field.min).toEqual(0); + expect(display[0].field.max).toEqual(0); + }); + + describe('Value mapping', () => { + it('should apply value mapping', () => { + const mappingConfig = [ + { + id: 1, + operator: '', + text: 'Value mapped to text', + type: MappingType.ValueToText, + value: '1', + }, + ]; + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + }, + }); + + options.data![0].fields[1]!.config = { mappings: mappingConfig }; + options.data![0].fields[2]!.config = { mappings: mappingConfig }; + + const result = getFieldDisplayValues(options); + expect(result[0].display.text).toEqual('Value mapped to text'); + }); + it('should apply range value mapping', () => { + const mappedValue = 'Range mapped to text'; + const mappingConfig = [ + { + id: 1, + operator: '', + text: mappedValue, + type: MappingType.RangeToText, + value: 1, + from: '1', + to: '3', + }, + ]; + const options = createDisplayOptions({ + reduceOptions: { + calcs: [ReducerID.first], + values: true, + }, + }); + + options.data![0].fields[1]!.config = { mappings: mappingConfig }; + options.data![0].fields[2]!.config = { mappings: mappingConfig }; + + const result = getFieldDisplayValues(options); + + expect(result[0].display.text).toEqual(mappedValue); + expect(result[2].display.text).toEqual('5'); + expect(result[3].display.text).toEqual(mappedValue); + }); + }); +}); + +function createEmptyDisplayOptions(extend = {}): GetFieldDisplayValuesOptions { + const options = createDisplayOptions(extend); + + return Object.assign(options, { + data: [ + { + name: 'No data', + fields: [], + length: 0, + }, + ], + }); +} + +function createDisplayOptions(extend: Partial = {}): GetFieldDisplayValuesOptions { + const options: GetFieldDisplayValuesOptions = { + data: [ + toDataFrame({ + name: 'Series Name', + fields: [ + { name: 'Field 1', values: ['a', 'b', 'c'] }, + { name: 'Field 2', values: [1, 3, 5] }, + { name: 'Field 3', values: [2, 4, 6] }, + ], + }), + ], + replaceVariables: (value: string) => { + return value; + }, + reduceOptions: { + calcs: [], + }, + fieldConfig: { + overrides: [], + defaults: {}, + }, + theme: getTestTheme(), + }; + + return merge(options, extend); +} diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts new file mode 100644 index 0000000..d0f2000 --- /dev/null +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -0,0 +1,324 @@ +import toString from 'lodash/toString'; +import isEmpty from 'lodash/isEmpty'; + +import { getDisplayProcessor } from './displayProcessor'; +import { + DataFrame, + DisplayValue, + DisplayValueAlignmentFactors, + Field, + FieldConfig, + FieldConfigSource, + FieldType, + InterpolateFunction, + LinkModel, + TimeRange, + TimeZone, +} from '../types'; +import { DataFrameView } from '../dataframe/DataFrameView'; +import { GrafanaTheme } from '../types/theme'; +import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { ScopedVars } from '../types/ScopedVars'; +import { getTimeField } from '../dataframe/processDataFrame'; +import { getFieldMatcher } from '../transformations'; +import { FieldMatcherID } from '../transformations/matchers/ids'; + +/** + * Options for how to turn DataFrames into an array of display values + */ +export interface ReduceDataOptions { + /* If true show each row value */ + values?: boolean; + /** if showing all values limit */ + limit?: number; + /** When !values, pick one value for the whole field */ + calcs: string[]; + /** Which fields to show. By default this is only numeric fields */ + fields?: string; +} + +// TODO: use built in variables, same as for data links? +export const VAR_SERIES_NAME = '__series.name'; +export const VAR_FIELD_NAME = '__field.displayName'; // Includes the rendered tags and naming strategy +export const VAR_FIELD_LABELS = '__field.labels'; +export const VAR_CALC = '__calc'; +export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates + +function getTitleTemplate(stats: string[]): string { + const parts: string[] = []; + if (stats.length > 1) { + parts.push('${' + VAR_CALC + '}'); + } + + parts.push('${' + VAR_FIELD_NAME + '}'); + + return parts.join(' '); +} + +export interface FieldSparkline { + y: Field; // Y values + x?: Field; // if this does not exist, use the index + timeRange?: TimeRange; // Optionally force an absolute time + highlightIndex?: number; +} + +export interface FieldDisplay { + name: string; // The field name (title is in display) + field: FieldConfig; + display: DisplayValue; + sparkline?: FieldSparkline; + + // Expose to the original values for delayed inspection (DataLinks etc) + view?: DataFrameView; + colIndex?: number; // The field column index + rowIndex?: number; // only filled in when the value is from a row (ie, not a reduction) + getLinks?: () => LinkModel[]; + hasLinks: boolean; +} + +export interface GetFieldDisplayValuesOptions { + data?: DataFrame[]; + reduceOptions: ReduceDataOptions; + fieldConfig: FieldConfigSource; + replaceVariables: InterpolateFunction; + sparkline?: boolean; // Calculate the sparkline + theme: GrafanaTheme; + timeZone?: TimeZone; +} + +export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; + +export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { + const { replaceVariables, reduceOptions, timeZone } = options; + const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last]; + + const values: FieldDisplay[] = []; + const fieldMatcher = getFieldMatcher( + reduceOptions.fields + ? { + id: FieldMatcherID.byRegexp, + options: reduceOptions.fields, + } + : { + id: FieldMatcherID.numeric, + } + ); + + if (options.data) { + // Field overrides are applied already + const data = options.data; + let hitLimit = false; + const limit = reduceOptions.limit ? reduceOptions.limit : DEFAULT_FIELD_DISPLAY_VALUES_LIMIT; + const scopedVars: ScopedVars = {}; + const defaultDisplayName = getTitleTemplate(calcs); + + for (let s = 0; s < data.length && !hitLimit; s++) { + const series = data[s]; // Name is already set + + const { timeField } = getTimeField(series); + const view = new DataFrameView(series); + + for (let i = 0; i < series.fields.length && !hitLimit; i++) { + const field = series.fields[i]; + const fieldLinksSupplier = field.getLinks; + + // To filter out time field, need an option for this + if (!fieldMatcher(field, series, data)) { + continue; + } + + let config = field.config; // already set by the prepare task + + if (field.state?.range) { + // Us the global min/max values + config = { + ...config, + ...field.state?.range, + }; + } + + const displayName = field.config.displayName ?? defaultDisplayName; + + const display = + field.display ?? + getDisplayProcessor({ + field, + theme: options.theme, + timeZone, + }); + + // Show all rows + if (reduceOptions.values) { + const usesCellValues = displayName.indexOf(VAR_CELL_PREFIX) >= 0; + + for (let j = 0; j < field.values.length; j++) { + // Add all the row variables + if (usesCellValues) { + for (let k = 0; k < series.fields.length; k++) { + const f = series.fields[k]; + const v = f.values.get(j); + scopedVars[VAR_CELL_PREFIX + k] = { + value: v, + text: toString(v), + }; + } + } + + const displayValue = display(field.values.get(j)); + displayValue.title = replaceVariables(displayName, { + ...field.state?.scopedVars, // series and field scoped vars + ...scopedVars, + }); + + values.push({ + name: '', + field: config, + display: displayValue, + view, + colIndex: i, + rowIndex: j, + getLinks: fieldLinksSupplier + ? () => + fieldLinksSupplier({ + valueRowIndex: j, + }) + : () => [], + hasLinks: hasLinks(field), + }); + + if (values.length >= limit) { + hitLimit = true; + break; + } + } + } else { + const results = reduceField({ + field, + reducers: calcs, // The stats to calculate + }); + + for (const calc of calcs) { + scopedVars[VAR_CALC] = { value: calc, text: calc }; + const displayValue = display(results[calc]); + displayValue.title = replaceVariables(displayName, { + ...field.state?.scopedVars, // series and field scoped vars + ...scopedVars, + }); + + let sparkline: FieldSparkline | undefined = undefined; + if (options.sparkline) { + sparkline = { + y: series.fields[i], + x: timeField, + }; + if (calc === ReducerID.last) { + sparkline.highlightIndex = sparkline.y.values.length - 1; + } else if (calc === ReducerID.first) { + sparkline.highlightIndex = 0; + } + } + + values.push({ + name: calc, + field: config, + display: displayValue, + sparkline, + view, + colIndex: i, + getLinks: fieldLinksSupplier + ? () => + fieldLinksSupplier({ + calculatedValue: displayValue, + }) + : () => [], + hasLinks: hasLinks(field), + }); + } + } + } + } + } + + if (values.length === 0) { + values.push(createNoValuesFieldDisplay(options)); + } + + return values; +}; + +export function hasLinks(field: Field): boolean { + return field.config?.links?.length ? field.config.links.length > 0 : false; +} + +export function getDisplayValueAlignmentFactors(values: FieldDisplay[]): DisplayValueAlignmentFactors { + const info: DisplayValueAlignmentFactors = { + title: '', + text: '', + }; + + let prefixLength = 0; + let suffixLength = 0; + + for (let i = 0; i < values.length; i++) { + const v = values[i].display; + + if (v.text && v.text.length > info.text.length) { + info.text = v.text; + } + + if (v.title && v.title.length > info.title.length) { + info.title = v.title; + } + + if (v.prefix && v.prefix.length > prefixLength) { + info.prefix = v.prefix; + prefixLength = v.prefix.length; + } + + if (v.suffix && v.suffix.length > suffixLength) { + info.suffix = v.suffix; + suffixLength = v.suffix.length; + } + } + return info; +} + +function createNoValuesFieldDisplay(options: GetFieldDisplayValuesOptions): FieldDisplay { + const displayName = 'No data'; + const { fieldConfig, timeZone } = options; + const { defaults } = fieldConfig; + + const displayProcessor = getDisplayProcessor({ + field: { + type: FieldType.other, + config: defaults, + }, + theme: options.theme, + timeZone, + }); + + const display = displayProcessor(null); + const text = getDisplayText(display, displayName); + + return { + name: displayName, + field: { + ...defaults, + max: defaults.max ?? 0, + min: defaults.min ?? 0, + }, + display: { + text, + numeric: 0, + color: display.color, + }, + hasLinks: false, + }; +} + +function getDisplayText(display: DisplayValue, fallback: string): string { + if (!display || isEmpty(display.text)) { + return fallback; + } + return display.text; +} diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts new file mode 100644 index 0000000..b1e0b85 --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -0,0 +1,823 @@ +import { + applyFieldOverrides, + applyRawFieldOverrides, + FieldOverrideEnv, + findNumericFieldMinMax, + getLinksSupplier, + setDynamicConfigValue, + setFieldConfigDefaults, +} from './fieldOverrides'; +import { ArrayDataFrame, MutableDataFrame, toDataFrame } from '../dataframe'; +import { + DataFrame, + Field, + FieldColorModeId, + FieldConfig, + FieldConfigPropertyItem, + FieldConfigSource, + FieldType, + InterpolateFunction, + ScopedVars, + ThresholdsMode, +} from '../types'; +import { locationUtil, Registry } from '../utils'; +import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; +import { FieldMatcherID } from '../transformations'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { getFieldDisplayName } from './fieldState'; +import { ArrayVector } from '../vector'; +import { getDisplayProcessor } from './displayProcessor'; +import { getTestTheme } from '../utils/testdata/testTheme'; + +const property1: any = { + id: 'custom.property1', // Match field properties + path: 'property1', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const property2 = { + id: 'custom.property2', // Match field properties + path: 'property2', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const property3: any = { + id: 'custom.property3.nested', // Match field properties + path: 'property3.nested', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => true, +}; + +const shouldApplyFalse: any = { + id: 'custom.shouldApplyFalse', // Match field properties + path: 'shouldApplyFalse', // Match field properties + isCustom: true, + process: (value: any) => value, + shouldApply: () => false, +}; + +export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry(() => { + return [property1, property2, property3, shouldApplyFalse, ...mockStandardProperties()]; +}); + +locationUtil.initialize({ + getConfig: () => { + return { appSubUrl: '/subUrl' } as any; + }, + // @ts-ignore + buildParamsFromVariables: () => {}, + // @ts-ignore + getTimeRangeForUrl: () => {}, +}); + +describe('Global MinMax', () => { + it('find global min max', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 100, value2: 1234 }, + { title: 'BBB', value: -20, value2: null }, + { title: 'CCC', value: 200, value2: 1000 }, + ]); + + const minmax = findNumericFieldMinMax([f0]); + expect(minmax.min).toEqual(-20); + expect(minmax.max).toEqual(1234); + }); + + it('find global min max when all values are zero', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 0, value2: 0 }, + { title: 'CCC', value: 0, value2: 0 }, + ]); + + const minmax = findNumericFieldMinMax([f0]); + expect(minmax.min).toEqual(0); + expect(minmax.max).toEqual(0); + }); + + describe('when value is null', () => { + it('then global min max should be null', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { name: 'Value', type: FieldType.number, values: [null] }, + ], + }); + const { min, max } = findNumericFieldMinMax([frame]); + + expect(min).toBe(null); + expect(max).toBe(null); + }); + }); + + describe('when value values are zeo', () => { + it('then global min max should be correct', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Value', type: FieldType.number, values: [1, 2] }, + ], + }); + const frame2 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Value', type: FieldType.number, values: [0, 0] }, + ], + }); + + const { min, max } = findNumericFieldMinMax([frame, frame2]); + + expect(min).toBe(0); + expect(max).toBe(2); + }); + }); +}); + +describe('applyFieldOverrides', () => { + const f0 = new ArrayDataFrame<{ title: string; value: number; value2: number | null }>([ + { title: 'AAA', value: 100, value2: 1234 }, + { title: 'BBB', value: -20, value2: null }, + { title: 'CCC', value: 200, value2: 1000 }, + ]); + + // Hardcode the max value + f0.fields[1].config.max = 0; + f0.fields[1].config.decimals = 6; + + const src: FieldConfigSource = { + defaults: { + unit: 'xyz', + decimals: 2, + links: [{ title: 'link', url: '${__value.text}' }], + }, + overrides: [ + { + matcher: { id: FieldMatcherID.numeric }, + properties: [ + { id: 'decimals', value: 1 }, // Numeric + { id: 'displayName', value: 'Kittens' }, // Text + ], + }, + ], + }; + + describe('given multiple data frames', () => { + const f0 = new MutableDataFrame({ + name: 'A', + fields: [{ name: 'message', type: FieldType.string, values: [10, 20] }], + }); + const f1 = new MutableDataFrame({ + name: 'B', + fields: [{ name: 'info', type: FieldType.string, values: [10, 20] }], + }); + + it('should add scopedVars to fields', () => { + const withOverrides = applyFieldOverrides({ + data: [f0, f1], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (value: any) => value, + theme: getTestTheme(), + fieldConfigRegistry: new FieldConfigOptionsRegistry(), + }); + + expect(withOverrides[0].fields[0].state!.scopedVars).toMatchInlineSnapshot(` + Object { + "__field": Object { + "text": "Field", + "value": Object {}, + }, + "__series": Object { + "text": "Series", + "value": Object { + "name": "A", + }, + }, + } + `); + + expect(withOverrides[1].fields[0].state!.scopedVars).toMatchInlineSnapshot(` + Object { + "__field": Object { + "text": "Field", + "value": Object {}, + }, + "__series": Object { + "text": "Series", + "value": Object { + "name": "B", + }, + }, + } + `); + }); + }); + + it('will merge FieldConfig with default values', () => { + const field: FieldConfig = { + min: 0, + max: 100, + }; + + const f1 = { + unit: 'ms', + dateFormat: '', // should be ignored + max: parseFloat('NOPE'), // should be ignored + min: null, // should alo be ignored! + displayName: 'newTitle', + }; + + const f: DataFrame = toDataFrame({ + fields: [{ type: FieldType.number, name: 'x', config: field, values: [] }], + }); + + const processed = applyFieldOverrides({ + data: [f], + fieldConfig: { + defaults: f1 as FieldConfig, + overrides: [], + }, + fieldConfigRegistry: customFieldRegistry, + replaceVariables: (v) => v, + theme: getTestTheme(), + })[0]; + + const outField = processed.fields[0]; + + expect(outField.config.min).toEqual(0); + expect(outField.config.max).toEqual(100); + expect(outField.config.unit).toEqual('ms'); + expect(getFieldDisplayName(outField, f)).toEqual('newTitle'); + }); + + it('will apply field overrides', () => { + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: (undefined as any) as InterpolateFunction, + theme: getTestTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + const valueColumn = data.fields[1]; + const config = valueColumn.config; + + // Keep max from the original setting + expect(config.max).toEqual(0); + + // Don't Automatically pick the min value + expect(config.min).toEqual(undefined); + + // The default value applied + expect(config.unit).toEqual('xyz'); + + // The default value applied + expect(config.displayName).toEqual('Kittens'); + + // The override applied + expect(config.decimals).toEqual(1); + }); + + it('will apply set min/max when asked', () => { + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: (undefined as any) as InterpolateFunction, + theme: getTestTheme(), + })[0]; + const valueColumn = data.fields[1]; + const range = valueColumn.state!.range!; + + // Keep max from the original setting + expect(range.max).toEqual(0); + + // Don't Automatically pick the min value + expect(range.min).toEqual(-20); + }); + + it('getLinks should use applied field config', () => { + const replaceVariablesCalls: any[] = []; + + const data = applyFieldOverrides({ + data: [f0], // the frame + fieldConfig: src as FieldConfigSource, // defaults + overrides + replaceVariables: ((value: string, variables: ScopedVars) => { + replaceVariablesCalls.push(variables); + return value; + }) as InterpolateFunction, + theme: getTestTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + + data.fields[1].getLinks!({ valueRowIndex: 0 }); + + expect(data.fields[1].config.decimals).toEqual(1); + expect(replaceVariablesCalls[0].__value.value.text).toEqual('100.0'); + }); +}); + +describe('setFieldConfigDefaults', () => { + it('applies field config defaults', () => { + const dsFieldConfig: FieldConfig = { + decimals: 2, + min: 0, + max: 100, + }; + + const panelFieldConfig: FieldConfig = { + decimals: 1, + min: 10, + max: 50, + unit: 'km', + }; + + const context: FieldOverrideEnv = { + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + fieldConfigRegistry: customFieldRegistry, + }; + + // we mutate dsFieldConfig + setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); + + expect(dsFieldConfig).toMatchInlineSnapshot(` + Object { + "custom": Object {}, + "decimals": 2, + "max": 100, + "min": 0, + "unit": "km", + } + `); + }); + + it('applies field config defaults for custom properties', () => { + const dsFieldConfig: FieldConfig = { + custom: { + property1: 10, + }, + }; + + const panelFieldConfig: FieldConfig = { + custom: { + property1: 20, + property2: 10, + }, + }; + + const context: FieldOverrideEnv = { + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + fieldConfigRegistry: customFieldRegistry, + }; + + // we mutate dsFieldConfig + setFieldConfigDefaults(dsFieldConfig, panelFieldConfig, context); + + expect(dsFieldConfig).toMatchInlineSnapshot(` + Object { + "custom": Object { + "property1": 10, + "property2": 10, + }, + } + `); + }); +}); + +describe('setDynamicConfigValue', () => { + it('applies dynamic config values', () => { + const config = { + displayName: 'test', + }; + + setDynamicConfigValue( + config, + { + id: 'displayName', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.displayName).toEqual('applied'); + }); + + it('applies custom dynamic config values', () => { + const config = { + custom: { + property1: 1, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property1', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property1).toEqual('applied'); + }); + + it('applies overrides even when shouldApply returns false', () => { + const config: FieldConfig = { + custom: {}, + }; + setDynamicConfigValue( + config, + { + id: 'custom.shouldApplyFalse', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.shouldApplyFalse).toEqual('applied'); + }); + + it('applies nested custom dynamic config values', () => { + const config = { + custom: { + property3: { + nested: 1, + }, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property3.nested', + value: 'applied', + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property3.nested).toEqual('applied'); + }); + + it('removes properties', () => { + const config = { + displayName: 'title', + custom: { + property3: { + nested: 1, + }, + }, + }; + setDynamicConfigValue( + config, + { + id: 'custom.property3.nested', + value: undefined, + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + setDynamicConfigValue( + config, + { + id: 'displayName', + value: undefined, + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [] as any, + field: { type: FieldType.number } as any, + dataFrameIndex: 0, + } + ); + + expect(config.custom.property3).toEqual({}); + expect(config.displayName).toBeUndefined(); + }); +}); + +describe('getLinksSupplier', () => { + it('will replace variables in url and title of the data link', () => { + locationUtil.initialize({ + getConfig: () => ({} as any), + buildParamsFromVariables: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + + const f0 = new MutableDataFrame({ + name: 'A', + fields: [ + { + name: 'message', + type: FieldType.string, + values: [10, 20], + config: { + links: [ + { + url: 'url to be interpolated', + title: 'title to be interpolated', + }, + ], + }, + }, + ], + }); + + const replaceSpy = jest.fn(); + const supplier = getLinksSupplier( + f0, + f0.fields[0], + {}, + replaceSpy, + // this is used only for internal links so isn't needed here + { + theme: getTestTheme(), + } + ); + supplier({}); + + expect(replaceSpy).toBeCalledTimes(2); + expect(replaceSpy.mock.calls[0][0]).toEqual('url to be interpolated'); + expect(replaceSpy.mock.calls[1][0]).toEqual('title to be interpolated'); + }); + + it('handles internal links', () => { + locationUtil.initialize({ + getConfig: () => ({ appSubUrl: '' } as any), + buildParamsFromVariables: (() => {}) as any, + getTimeRangeForUrl: (() => {}) as any, + }); + + const f0 = new MutableDataFrame({ + name: 'A', + fields: [ + { + name: 'message', + type: FieldType.string, + values: [10, 20], + config: { + links: [ + { + url: '', + title: '', + internal: { + datasourceUid: '0', + datasourceName: 'testDS', + query: '12345', + }, + }, + ], + }, + }, + ], + }); + + const supplier = getLinksSupplier( + f0, + f0.fields[0], + {}, + // We do not need to interpolate anything for this test + (value, vars, format) => value, + { theme: getTestTheme() } + ); + const links = supplier({ valueRowIndex: 0 }); + expect(links.length).toBe(1); + expect(links[0]).toEqual( + expect.objectContaining({ + title: 'testDS', + href: '/explore?left={"datasource":"testDS","queries":["12345"]}', + onClick: undefined, + }) + ); + }); +}); + +describe('applyRawFieldOverrides', () => { + const getNumberFieldConfig = () => ({ + custom: {}, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + color: 'green', + value: (null as unknown) as number, + }, + { + color: 'red', + value: 80, + }, + ], + }, + mappings: [], + color: { + mode: FieldColorModeId.Thresholds, + }, + min: 0, + max: 1599124316808, + }); + + const getEmptyConfig = () => ({ + custom: {}, + mappings: [], + }); + + const getDisplayValue = (frames: DataFrame[], frameIndex: number, fieldIndex: number) => { + const field = frames[frameIndex].fields[fieldIndex]; + const value = field.values.get(0); + return field.display!(value); + }; + + const expectRawDataDisplayValue = (frames: DataFrame[], frameIndex: number) => { + expect(getDisplayValue(frames, frameIndex, 0)).toEqual({ text: '1599045551050', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 1)).toEqual({ text: '3.14159265359', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 2)).toEqual({ text: '0', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 3)).toEqual({ text: '0', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ text: 'A - string', numeric: null }); + expect(getDisplayValue(frames, frameIndex, 5)).toEqual({ text: '1599045551050', numeric: null }); + }; + + const expectFormattedDataDisplayValue = (frames: DataFrame[], frameIndex: number) => { + expect(getDisplayValue(frames, frameIndex, 0)).toEqual({ + color: '#F2495C', + numeric: 1599045551050, + prefix: undefined, + suffix: undefined, + text: '1599045551050', + percent: expect.any(Number), + threshold: { + color: 'red', + value: 80, + }, + }); + + expect(getDisplayValue(frames, frameIndex, 1)).toEqual({ + color: '#73BF69', + numeric: 3.14159265359, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '3.142', + threshold: { + color: 'green', + value: null, + }, + }); + + expect(getDisplayValue(frames, frameIndex, 2)).toEqual({ + color: '#73BF69', + numeric: 0, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '0', + threshold: { + color: 'green', + value: null, + }, + }); + + expect(getDisplayValue(frames, frameIndex, 3)).toEqual({ + color: '#808080', + numeric: 0, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '0', + threshold: expect.anything(), + }); + + expect(getDisplayValue(frames, frameIndex, 4)).toEqual({ + color: '#808080', + numeric: NaN, + percent: 0, + prefix: undefined, + suffix: undefined, + text: 'A - string', + threshold: expect.anything(), + }); + + expect(getDisplayValue(frames, frameIndex, 5)).toEqual({ + color: '#808080', + numeric: 1599045551050, + percent: expect.any(Number), + prefix: undefined, + suffix: undefined, + text: '2020-09-02 11:19:11', + threshold: expect.anything(), + }); + }; + + describe('when called', () => { + it('then all fields should have their display processor replaced with the raw display processor', () => { + const numberAsEpoc: Field = { + name: 'numberAsEpoc', + type: FieldType.number, + values: new ArrayVector([1599045551050]), + config: getNumberFieldConfig(), + }; + + const numberWithDecimals: Field = { + name: 'numberWithDecimals', + type: FieldType.number, + values: new ArrayVector([3.14159265359]), + config: { + ...getNumberFieldConfig(), + decimals: 3, + }, + }; + + const numberAsBoolean: Field = { + name: 'numberAsBoolean', + type: FieldType.number, + values: new ArrayVector([0]), + config: getNumberFieldConfig(), + }; + + const boolean: Field = { + name: 'boolean', + type: FieldType.boolean, + values: new ArrayVector([0]), + config: getEmptyConfig(), + }; + + const string: Field = { + name: 'string', + type: FieldType.boolean, + values: new ArrayVector(['A - string']), + config: getEmptyConfig(), + }; + + const datetime: Field = { + name: 'datetime', + type: FieldType.time, + values: new ArrayVector([1599045551050]), + config: { + unit: 'dateTimeAsIso', + }, + }; + + const dataFrameA: DataFrame = toDataFrame({ + fields: [numberAsEpoc, numberWithDecimals, numberAsBoolean, boolean, string, datetime], + }); + + dataFrameA.fields[0].display = getDisplayProcessor({ field: dataFrameA.fields[0] }); + dataFrameA.fields[1].display = getDisplayProcessor({ field: dataFrameA.fields[1] }); + dataFrameA.fields[2].display = getDisplayProcessor({ field: dataFrameA.fields[2] }); + dataFrameA.fields[3].display = getDisplayProcessor({ field: dataFrameA.fields[3] }); + dataFrameA.fields[4].display = getDisplayProcessor({ field: dataFrameA.fields[4] }); + dataFrameA.fields[5].display = getDisplayProcessor({ field: dataFrameA.fields[5], timeZone: 'utc' }); + + const dataFrameB: DataFrame = toDataFrame({ + fields: [numberAsEpoc, numberWithDecimals, numberAsBoolean, boolean, string, datetime], + }); + + dataFrameB.fields[0].display = getDisplayProcessor({ field: dataFrameB.fields[0] }); + dataFrameB.fields[1].display = getDisplayProcessor({ field: dataFrameB.fields[1] }); + dataFrameB.fields[2].display = getDisplayProcessor({ field: dataFrameB.fields[2] }); + dataFrameB.fields[3].display = getDisplayProcessor({ field: dataFrameB.fields[3] }); + dataFrameB.fields[4].display = getDisplayProcessor({ field: dataFrameB.fields[4] }); + dataFrameB.fields[5].display = getDisplayProcessor({ field: dataFrameB.fields[5], timeZone: 'utc' }); + + const data = [dataFrameA, dataFrameB]; + const rawData = applyRawFieldOverrides(data); + + // expect raw data is correct + expectRawDataDisplayValue(rawData, 0); + expectRawDataDisplayValue(rawData, 1); + + // expect the original data is still the same + expectFormattedDataDisplayValue(data, 0); + expectFormattedDataDisplayValue(data, 1); + }); + }); +}); diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts new file mode 100644 index 0000000..80621e8 --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -0,0 +1,448 @@ +import { + ApplyFieldOverrideOptions, + DataFrame, + DataLink, + DynamicConfigValue, + Field, + FieldColorModeId, + FieldConfig, + FieldConfigPropertyItem, + FieldOverrideContext, + FieldType, + GrafanaTheme, + InterpolateFunction, + LinkModel, + NumericRange, + ScopedVars, + TimeZone, + ValueLinkConfig, +} from '../types'; +import { fieldMatchers, reduceField, ReducerID } from '../transformations'; +import { FieldMatcher } from '../types/transformations'; +import isNumber from 'lodash/isNumber'; +import set from 'lodash/set'; +import unset from 'lodash/unset'; +import get from 'lodash/get'; +import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { guessFieldTypeForField } from '../dataframe'; +import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { DataLinkBuiltInVars, locationUtil } from '../utils'; +import { formattedValueToString } from '../valueFormats'; +import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +import { getFieldDisplayName, getFrameDisplayName } from './fieldState'; +import { getTimeField } from '../dataframe/processDataFrame'; +import { mapInternalLinkToExplore } from '../utils/dataLinks'; +import { getTemplateProxyForField } from './templateProxies'; + +interface OverrideProps { + match: FieldMatcher; + properties: DynamicConfigValue[]; +} + +export function findNumericFieldMinMax(data: DataFrame[]): NumericRange { + let min: number | null = null; + let max: number | null = null; + + const reducers = [ReducerID.min, ReducerID.max]; + + for (const frame of data) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + const stats = reduceField({ field, reducers }); + const statsMin = stats[ReducerID.min]; + const statsMax = stats[ReducerID.max]; + + if (min === null || statsMin < min) { + min = statsMin; + } + + if (max === null || statsMax > max) { + max = statsMax; + } + } + } + } + + return { min, max, delta: (max ?? 0) - (min ?? 0) }; +} + +/** + * Return a copy of the DataFrame with all rules applied + */ +export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFrame[] { + if (!options.data) { + return []; + } + + const source = options.fieldConfig; + if (!source) { + return options.data; + } + + const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry; + + let seriesIndex = 0; + let globalRange: NumericRange | undefined = undefined; + + // Prepare the Matchers + const override: OverrideProps[] = []; + if (source.overrides) { + for (const rule of source.overrides) { + const info = fieldMatchers.get(rule.matcher.id); + if (info) { + override.push({ + match: info.get(rule.matcher.options), + properties: rule.properties, + }); + } + } + } + + return options.data.map((frame, index) => { + // Need to define this new frame here as it's passed to the getLinkSupplier function inside the fields loop + const newFrame: DataFrame = { ...frame }; + + const scopedVars: ScopedVars = { + __series: { text: 'Series', value: { name: getFrameDisplayName(frame, index) } }, // might be missing + }; + + const fields: Field[] = frame.fields.map((field) => { + // Config is mutable within this scope + const fieldScopedVars = { ...scopedVars }; + const displayName = getFieldDisplayName(field, frame, options.data); + + fieldScopedVars['__field'] = { + text: 'Field', + value: getTemplateProxyForField(field, frame, options.data), + }; + + field.state = { + ...field.state, + scopedVars: fieldScopedVars, + displayName, + }; + + const config: FieldConfig = { ...field.config }; + const context = { + field, + data: options.data!, + dataFrameIndex: index, + replaceVariables: options.replaceVariables, + fieldConfigRegistry: fieldConfigRegistry, + }; + + // Anything in the field config that's not set by the datasource + // will be filled in by panel's field configuration + setFieldConfigDefaults(config, source.defaults, context); + // Find any matching rules and then override + for (const rule of override) { + if (rule.match(field, frame, options.data!)) { + for (const prop of rule.properties) { + // config.scopedVars is set already here + setDynamicConfigValue(config, prop, context); + } + } + } + + // Try harder to set a real value that is not 'other' + let type = field.type; + if (!type || type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + type = t; + } + } + + // Some units have an implied range + if (config.unit === 'percent') { + if (!isNumber(config.min)) { + config.min = 0; + } + if (!isNumber(config.max)) { + config.max = 100; + } + } else if (config.unit === 'percentunit') { + if (!isNumber(config.min)) { + config.min = 0; + } + if (!isNumber(config.max)) { + config.max = 1; + } + } + + // Set the Min/Max value automatically + let range: NumericRange | undefined = undefined; + if (field.type === FieldType.number) { + if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { + globalRange = findNumericFieldMinMax(options.data!); + } + const min = config.min ?? globalRange!.min; + const max = config.max ?? globalRange!.max; + range = { min, max, delta: max! - min! }; + } + + // Some color modes needs series index to assign field color so we count + // up series index here but ignore time fields + if (field.type !== FieldType.time) { + seriesIndex++; + } + + // Overwrite the configs + const newField: Field = { + ...field, + config, + type, + state: { + ...field.state, + displayName: null, + seriesIndex, + range, + }, + }; + + // and set the display processor using it + newField.display = getDisplayProcessor({ + field: newField, + theme: options.theme, + timeZone: options.timeZone, + }); + + // Attach data links supplier + newField.getLinks = getLinksSupplier(newFrame, newField, fieldScopedVars, context.replaceVariables, { + theme: options.theme, + timeZone: options.timeZone, + }); + + return newField; + }); + + newFrame.fields = fields; + return newFrame; + }); +} + +export interface FieldOverrideEnv extends FieldOverrideContext { + fieldConfigRegistry: FieldConfigOptionsRegistry; +} + +export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) { + const reg = context.fieldConfigRegistry; + const item = reg.getIfExists(value.id); + if (!item) { + return; + } + + const val = item.process(value.value, context, item.settings); + + const remove = val === undefined || val === null; + + if (remove) { + if (item.isCustom && config.custom) { + unset(config.custom, item.path); + } else { + unset(config, item.path); + } + } else { + if (item.isCustom) { + if (!config.custom) { + config.custom = {}; + } + set(config.custom, item.path, val); + } else { + set(config, item.path, val); + } + } +} + +// config -> from DS +// defaults -> from Panel config +export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) { + for (const fieldConfigProperty of context.fieldConfigRegistry.list()) { + if (fieldConfigProperty.isCustom && !config.custom) { + config.custom = {}; + } + processFieldConfigValue( + fieldConfigProperty.isCustom ? config.custom : config, + fieldConfigProperty.isCustom ? defaults.custom : defaults, + fieldConfigProperty, + context + ); + } + + validateFieldConfig(config); +} + +const processFieldConfigValue = ( + destination: Record, // it's mutable + source: Record, + fieldConfigProperty: FieldConfigPropertyItem, + context: FieldOverrideEnv +) => { + const currentConfig = get(destination, fieldConfigProperty.path); + if (currentConfig === null || currentConfig === undefined) { + const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id); + if (!item) { + return; + } + + if (item && item.shouldApply(context.field!)) { + const val = item.process(get(source, item.path), context, item.settings); + if (val !== undefined && val !== null) { + set(destination, item.path, val); + } + } + } +}; + +/** + * This checks that all options on FieldConfig make sense. It mutates any value that needs + * fixed. In particular this makes sure that the first threshold value is -Infinity (not valid in JSON) + */ +export function validateFieldConfig(config: FieldConfig) { + const { thresholds } = config; + + if (!config.color) { + if (thresholds) { + config.color = { + mode: FieldColorModeId.Thresholds, + }; + } + // No Color settings + } else if (!config.color.mode) { + // Without a mode, skip color altogether + delete config.color; + } + + // Verify that max > min (swap if necessary) + if (config.hasOwnProperty('min') && config.hasOwnProperty('max') && config.min! > config.max!) { + const tmp = config.max; + config.max = config.min; + config.min = tmp; + } +} + +export const getLinksSupplier = ( + frame: DataFrame, + field: Field, + fieldScopedVars: ScopedVars, + replaceVariables: InterpolateFunction, + options: { + theme: GrafanaTheme; + timeZone?: TimeZone; + } +) => (config: ValueLinkConfig): Array> => { + if (!field.config.links || field.config.links.length === 0) { + return []; + } + const timeRangeUrl = locationUtil.getTimeRangeUrlParams(); + const { timeField } = getTimeField(frame); + + return field.config.links.map((link: DataLink) => { + const variablesQuery = locationUtil.getVariablesUrlParams(); + let dataFrameVars = {}; + let valueVars = {}; + + // We are not displaying reduction result + if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) { + const fieldsProxy = getFieldDisplayValuesProxy(frame, config.valueRowIndex, options); + valueVars = { + raw: field.values.get(config.valueRowIndex), + numeric: fieldsProxy[field.name].numeric, + text: fieldsProxy[field.name].text, + time: timeField ? timeField.values.get(config.valueRowIndex) : undefined, + }; + dataFrameVars = { + __data: { + value: { + name: frame.name, + refId: frame.refId, + fields: fieldsProxy, + }, + text: 'Data', + }, + }; + } else { + if (config.calculatedValue) { + valueVars = { + raw: config.calculatedValue.numeric, + numeric: config.calculatedValue.numeric, + text: formattedValueToString(config.calculatedValue), + }; + } + } + + const variables = { + ...fieldScopedVars, + __value: { + text: 'Value', + value: valueVars, + }, + ...dataFrameVars, + [DataLinkBuiltInVars.keepTime]: { + text: timeRangeUrl, + value: timeRangeUrl, + }, + [DataLinkBuiltInVars.includeVars]: { + text: variablesQuery, + value: variablesQuery, + }, + }; + + if (link.internal) { + // For internal links at the moment only destination is Explore. + return mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: variables, + field, + range: {} as any, + replaceVariables, + }); + } else { + let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, '')); + href = replaceVariables(href, variables); + href = locationUtil.processUrl(href); + + const info: LinkModel = { + href, + title: replaceVariables(link.title || '', variables), + target: link.targetBlank ? '_blank' : undefined, + origin: field, + }; + + return info; + } + }); +}; + +/** + * Return a copy of the DataFrame with raw data + */ +export function applyRawFieldOverrides(data: DataFrame[]): DataFrame[] { + if (!data || data.length === 0) { + return []; + } + + const newData = [...data]; + const processor = getRawDisplayProcessor(); + + for (let frameIndex = 0; frameIndex < newData.length; frameIndex++) { + const newFrame = { ...newData[frameIndex] }; + const newFields = [...newFrame.fields]; + + for (let fieldIndex = 0; fieldIndex < newFields.length; fieldIndex++) { + newFields[fieldIndex] = { + ...newFields[fieldIndex], + display: processor, + }; + } + + newData[frameIndex] = { + ...newFrame, + fields: newFields, + }; + } + + return newData; +} diff --git a/packages/grafana-data/src/field/fieldState.test.ts b/packages/grafana-data/src/field/fieldState.test.ts new file mode 100644 index 0000000..713fd8b --- /dev/null +++ b/packages/grafana-data/src/field/fieldState.test.ts @@ -0,0 +1,170 @@ +import { DataFrame, TIME_SERIES_VALUE_FIELD_NAME, FieldType } from '../types'; +import { getFieldDisplayName } from './fieldState'; +import { toDataFrame } from '../dataframe'; + +interface TitleScenario { + frames: DataFrame[]; + frameIndex?: number; // assume 0 + fieldIndex?: number; // assume 0 +} + +function checkScenario(scenario: TitleScenario): string { + const frame = scenario.frames[scenario.frameIndex ?? 0]; + const field = frame.fields[scenario.fieldIndex ?? 0]; + return getFieldDisplayName(field, frame, scenario.frames); +} + +describe('Check field state calculations (displayName and id)', () => { + it('should use field name if no frame name', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Field 1'); + }); + + it('should use only field name if only one series', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Field 1'); + }); + + it('should use frame name and field name if more than one frame', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: 'Field 1' }], + }), + toDataFrame({ + name: 'Series B', + fields: [{ name: 'Field 1' }], + }), + ], + }); + expect(title).toEqual('Series A Field 1'); + }); + + it('should add field name count to name if it exists more than once and is equal to TIME_SERIES_VALUE_FIELD_NAME', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME }, { name: TIME_SERIES_VALUE_FIELD_NAME }], + }), + ], + }); + const title2 = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME }, { name: TIME_SERIES_VALUE_FIELD_NAME }], + }), + ], + fieldIndex: 1, + }); + + expect(title).toEqual('Value 1'); + expect(title2).toEqual('Value 2'); + }); + + it('should add field name count to name if field name exists more than once', () => { + const title2 = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'A' }, { name: 'A' }], + }), + ], + fieldIndex: 1, + }); + + expect(title2).toEqual('A 2'); + }); + + it('should only use label value if only one label', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + ], + }); + expect(title).toEqual('Server A'); + }); + + it('should use label value only if all series have same name', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'cpu', + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + toDataFrame({ + name: 'cpu', + fields: [{ name: 'Value', labels: { server: 'Server A' } }], + }), + ], + }); + expect(title).toEqual('Server A'); + }); + + it('should use label name and value if more than one label', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: 'Value', labels: { server: 'Server A', mode: 'B' } }], + }), + ], + }); + expect(title).toEqual('{mode="B", server="Server A"}'); + }); + + it('should use field name even when it is TIME_SERIES_VALUE_FIELD_NAME if there are no labels', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME, labels: {} }], + }), + ], + }); + expect(title).toEqual('Value'); + }); + + it('should use series name when field name is TIME_SERIES_VALUE_FIELD_NAME and there are no labels ', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + name: 'Series A', + fields: [{ name: TIME_SERIES_VALUE_FIELD_NAME, labels: {} }], + }), + ], + }); + expect(title).toEqual('Series A'); + }); + + it('should reder loki frames', () => { + const title = checkScenario({ + frames: [ + toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time }, + { + name: 'line', + labels: { host: 'ec2-13-53-116-156.eu-north-1.compute.amazonaws.com', region: 'eu-north1' }, + }, + ], + }), + ], + fieldIndex: 1, + }); + expect(title).toEqual('line {host="ec2-13-53-116-156.eu-north-1.compute.amazonaws.com", region="eu-north1"}'); + }); +}); diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts new file mode 100644 index 0000000..255e2dd --- /dev/null +++ b/packages/grafana-data/src/field/fieldState.ts @@ -0,0 +1,192 @@ +import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME, FieldType, TIME_SERIES_TIME_FIELD_NAME } from '../types'; +import { formatLabels } from '../utils/labels'; + +/** + * Get an appropriate display title + */ +export function getFrameDisplayName(frame: DataFrame, index?: number) { + if (frame.name) { + return frame.name; + } + + // Single field with tags + const valuesWithLabels = frame.fields.filter((f) => f.labels !== undefined); + if (valuesWithLabels.length === 1) { + return formatLabels(valuesWithLabels[0].labels!); + } + + // list all the + if (index === undefined) { + return frame.fields + .filter((f) => f.type !== FieldType.time) + .map((f) => getFieldDisplayName(f, frame)) + .join(', '); + } + + if (frame.refId) { + return `Series (${frame.refId})`; + } + + return `Series (${index})`; +} + +export function getFieldDisplayName(field: Field, frame?: DataFrame, allFrames?: DataFrame[]): string { + const existingTitle = field.state?.displayName; + + if (existingTitle) { + return existingTitle; + } + + const displayName = calculateFieldDisplayName(field, frame, allFrames); + field.state = field.state || {}; + field.state.displayName = displayName; + + return displayName; +} + +/** + * Get an appropriate display name. If the 'displayName' field config is set, use that + */ +function calculateFieldDisplayName(field: Field, frame?: DataFrame, allFrames?: DataFrame[]): string { + const hasConfigTitle = field.config?.displayName && field.config?.displayName.length; + + let displayName = hasConfigTitle ? field.config!.displayName! : field.name; + + if (hasConfigTitle) { + return displayName; + } + + if (frame && field.config?.displayNameFromDS) { + return field.config.displayNameFromDS; + } + + // This is an ugly exception for time field + // For time series we should normally treat time field with same name + // But in case it has a join source we should handle it as normal field + if (field.type === FieldType.time && !field.labels) { + return displayName ?? TIME_SERIES_TIME_FIELD_NAME; + } + + let parts: string[] = []; + let frameNamesDiffer = false; + + if (allFrames && allFrames.length > 1) { + for (let i = 1; i < allFrames.length; i++) { + const frame = allFrames[i]; + if (frame.name !== allFrames[i - 1].name) { + frameNamesDiffer = true; + break; + } + } + } + + let frameNameAdded = false; + let labelsAdded = false; + + if (frameNamesDiffer && frame?.name) { + parts.push(frame.name); + frameNameAdded = true; + } + + if (field.name && field.name !== TIME_SERIES_VALUE_FIELD_NAME) { + parts.push(field.name); + } + + if (field.labels && frame) { + let singleLabelName = getSingleLabelName(allFrames ?? [frame]); + + if (!singleLabelName) { + let allLabels = formatLabels(field.labels); + if (allLabels) { + parts.push(allLabels); + labelsAdded = true; + } + } else if (field.labels[singleLabelName]) { + parts.push(field.labels[singleLabelName]); + labelsAdded = true; + } + } + + // if we have not added frame name and no labels, and field name = Value, we should add frame name + if (frame && !frameNameAdded && !labelsAdded && field.name === TIME_SERIES_VALUE_FIELD_NAME) { + if (frame.name && frame.name.length > 0) { + parts.push(frame.name); + frameNameAdded = true; + } + } + + if (parts.length) { + displayName = parts.join(' '); + } else if (field.name) { + displayName = field.name; + } else { + displayName = TIME_SERIES_VALUE_FIELD_NAME; + } + + // Ensure unique field name + if (displayName === field.name) { + displayName = getUniqueFieldName(field, frame); + } + + return displayName; +} + +function getUniqueFieldName(field: Field, frame?: DataFrame) { + let dupeCount = 0; + let foundSelf = false; + + if (frame) { + for (let i = 0; i < frame.fields.length; i++) { + const otherField = frame.fields[i]; + + if (field === otherField) { + foundSelf = true; + + if (dupeCount > 0) { + dupeCount++; + break; + } + } else if (field.name === otherField.name) { + dupeCount++; + + if (foundSelf) { + break; + } + } + } + } + + if (dupeCount) { + return `${field.name} ${dupeCount}`; + } + + return field.name; +} + +/** + * Checks all data frames and return name of label if there is only one label name in all frames + */ +function getSingleLabelName(frames: DataFrame[]): string | null { + let singleName: string | null = null; + + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + + for (const field of frame.fields) { + if (!field.labels) { + continue; + } + + // yes this should be in! + for (const labelKey in field.labels) { + if (singleName === null) { + singleName = labelKey; + } else if (labelKey !== singleName) { + return null; + } + } + } + } + + return singleName; +} diff --git a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx new file mode 100644 index 0000000..ab550e1 --- /dev/null +++ b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.test.tsx @@ -0,0 +1,78 @@ +import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +import { applyFieldOverrides } from './fieldOverrides'; +import { toDataFrame } from '../dataframe'; +import { GrafanaTheme } from '../types'; +import { getTestTheme } from '../utils/testdata/testTheme'; + +describe('getFieldDisplayValuesProxy', () => { + const data = applyFieldOverrides({ + data: [ + toDataFrame({ + fields: [ + { name: 'Time', values: [1, 2, 3] }, + { + name: 'power', + values: [100, 200, 300], + labels: { + name: 'POWAH!', + }, + config: { + displayName: 'The Power', + }, + }, + { + name: 'Last', + values: ['a', 'b', 'c'], + }, + ], + }), + ], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (val: string) => val, + timeZone: 'utc', + theme: getTestTheme(), + })[0]; + + it('should define all display functions', () => { + // Field display should be set + for (const field of data.fields) { + expect(field.display).toBeDefined(); + } + }); + + it('should format the time values in UTC', () => { + // Test Proxies in general + const p = getFieldDisplayValuesProxy(data, 0, { + theme: {} as GrafanaTheme, + }); + const time = p.Time; + expect(time.numeric).toEqual(1); + expect(time.text).toEqual('1970-01-01 00:00:00'); + + // Should get to the same values by name or index + const time2 = p[0]; + expect(time2.toString()).toEqual(time.toString()); + }); + + it('Lookup by name, index, or displayName', () => { + const p = getFieldDisplayValuesProxy(data, 2, { + theme: {} as GrafanaTheme, + }); + expect(p.power.numeric).toEqual(300); + expect(p['power'].numeric).toEqual(300); + expect(p['POWAH!'].numeric).toEqual(300); + expect(p['The Power'].numeric).toEqual(300); + expect(p[1].numeric).toEqual(300); + }); + + it('should return undefined when missing', () => { + const p = getFieldDisplayValuesProxy(data, 0, { + theme: {} as GrafanaTheme, + }); + expect(p.xyz).toBeUndefined(); + expect(p[100]).toBeUndefined(); + }); +}); diff --git a/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts new file mode 100644 index 0000000..0abdd67 --- /dev/null +++ b/packages/grafana-data/src/field/getFieldDisplayValuesProxy.ts @@ -0,0 +1,60 @@ +import toNumber from 'lodash/toNumber'; +import { DataFrame, DisplayValue, GrafanaTheme, TimeZone } from '../types'; +import { getDisplayProcessor } from './displayProcessor'; +import { formattedValueToString } from '../valueFormats'; + +/** + * + * @param frame + * @param rowIndex + * @param options + * @internal + */ +export function getFieldDisplayValuesProxy( + frame: DataFrame, + rowIndex: number, + options: { + theme: GrafanaTheme; + timeZone?: TimeZone; + } +): Record { + return new Proxy({} as Record, { + get: (obj: any, key: string) => { + // 1. Match the name + let field = frame.fields.find((f) => key === f.name); + if (!field) { + // 2. Match the array index + const k = toNumber(key); + field = frame.fields[k]; + } + if (!field) { + // 3. Match the config displayName + field = frame.fields.find((f) => key === f.config.displayName); + } + if (!field) { + // 4. Match the name label + field = frame.fields.find((f) => { + if (f.labels) { + return key === f.labels.name; + } + return false; + }); + } + if (!field) { + return undefined; + } + if (!field.display) { + // Lazy load the display processor + field.display = getDisplayProcessor({ + field, + theme: options.theme, + timeZone: options.timeZone, + }); + } + const raw = field.values.get(rowIndex); + const disp = field.display(raw); + disp.toString = () => formattedValueToString(disp); + return disp; + }, + }); +} diff --git a/packages/grafana-data/src/field/index.ts b/packages/grafana-data/src/field/index.ts new file mode 100644 index 0000000..72cb12d --- /dev/null +++ b/packages/grafana-data/src/field/index.ts @@ -0,0 +1,18 @@ +export * from './fieldDisplay'; +export * from './displayProcessor'; +export * from './standardFieldConfigEditorRegistry'; +export * from './overrides/processors'; + +export { + getFieldColorModeForField, + getFieldColorMode, + fieldColorModeRegistry, + FieldColorMode, + getFieldSeriesColor, +} from './fieldColor'; +export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +export { sortThresholds, getActiveThreshold } from './thresholds'; +export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides } from './fieldOverrides'; +export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; +export { getFieldDisplayName, getFrameDisplayName } from './fieldState'; +export { getScaleCalculator, getFieldConfigWithMinMax } from './scale'; diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts new file mode 100644 index 0000000..0a36bfc --- /dev/null +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -0,0 +1,150 @@ +import { DataLink, FieldOverrideContext, SelectableValue, ThresholdsConfig, ValueMapping } from '../../types'; + +export const identityOverrideProcessor = (value: T, _context: FieldOverrideContext, _settings: any) => { + return value; +}; + +export interface NumberFieldConfigSettings { + placeholder?: string; + integer?: boolean; + min?: number; + max?: number; + step?: number; +} + +export const numberOverrideProcessor = ( + value: any, + context: FieldOverrideContext, + settings?: NumberFieldConfigSettings +) => { + if (value === undefined || value === null) { + return undefined; + } + + return parseFloat(value); +}; + +export interface SliderFieldConfigSettings { + min: number; + max: number; + step?: number; +} + +export interface DataLinksFieldConfigSettings {} + +export const dataLinksOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: DataLinksFieldConfigSettings +) => { + return value as DataLink[]; +}; + +export interface ValueMappingFieldConfigSettings {} + +export const valueMappingsOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: ValueMappingFieldConfigSettings +) => { + return value as ValueMapping[]; // !!!! likely not !!!! +}; + +export interface SelectFieldConfigSettings { + allowCustomValue?: boolean; + + /** The default options */ + options: Array>; + + /** Optionally use the context to define the options */ + getOptions?: (context: FieldOverrideContext) => Promise>>; +} + +export const selectOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: SelectFieldConfigSettings +) => { + return value; +}; + +export interface StringFieldConfigSettings { + placeholder?: string; + maxLength?: number; + expandTemplateVars?: boolean; + useTextarea?: boolean; + rows?: number; +} + +export const stringOverrideProcessor = ( + value: any, + context: FieldOverrideContext, + settings?: StringFieldConfigSettings +) => { + if (value === null || value === undefined) { + return value; + } + if (settings && settings.expandTemplateVars && context.replaceVariables) { + return context.replaceVariables(value, context.field!.state!.scopedVars); + } + return `${value}`; +}; + +export interface ThresholdsFieldConfigSettings { + // Anything? +} + +export const thresholdsOverrideProcessor = ( + value: any, + _context: FieldOverrideContext, + _settings?: ThresholdsFieldConfigSettings +) => { + return value as ThresholdsConfig; // !!!! likely not !!!! +}; + +export interface UnitFieldConfigSettings {} + +export const unitOverrideProcessor = ( + value: boolean, + _context: FieldOverrideContext, + _settings?: UnitFieldConfigSettings +) => { + return value; +}; + +export const booleanOverrideProcessor = ( + value: boolean, + _context: FieldOverrideContext, + _settings?: ThresholdsFieldConfigSettings +) => { + return value; // !!!! likely not !!!! +}; + +export interface FieldColorConfigSettings { + /** + * When switching to a visualization that does not support by value coloring then Grafana will + * switch to a by series palette based color mode + */ + byValueSupport?: boolean; + /** + * When switching to a visualization that has this set to true then Grafana will change color mode + * to from thresholds if it was set to a by series palette + */ + preferThresholdsMode?: boolean; + /** + * Set to true if the visualization supports both by value and by series + * This will enable the Color by series UI option that sets the `color.seriesBy` option. + */ + bySeriesSupport?: boolean; +} + +export interface StatsPickerConfigSettings { + /** + * Enable multi-selection in the stats picker + */ + allowMultiple: boolean; + /** + * Default stats to be use in the stats picker + */ + defaultStat?: string; +} diff --git a/packages/grafana-data/src/field/scale.test.ts b/packages/grafana-data/src/field/scale.test.ts new file mode 100644 index 0000000..0d284da --- /dev/null +++ b/packages/grafana-data/src/field/scale.test.ts @@ -0,0 +1,29 @@ +import { ThresholdsMode, Field, FieldType } from '../types'; +import { sortThresholds } from './thresholds'; +import { ArrayVector } from '../vector/ArrayVector'; +import { getScaleCalculator } from './scale'; +import { getTestTheme } from '../utils/testdata/testTheme'; + +describe('getScaleCalculator', () => { + it('should return percent, threshold and color', () => { + const thresholds = [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]; + + const field: Field = { + name: 'test', + config: { thresholds: { mode: ThresholdsMode.Absolute, steps: sortThresholds(thresholds) } }, + type: FieldType.number, + values: new ArrayVector([0, 50, 100]), + }; + + const calc = getScaleCalculator(field, getTestTheme()); + expect(calc(70)).toEqual({ + percent: 0.7, + threshold: thresholds[1], + color: '#EAB839', + }); + }); +}); diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts new file mode 100644 index 0000000..6228027 --- /dev/null +++ b/packages/grafana-data/src/field/scale.ts @@ -0,0 +1,84 @@ +import { isNumber } from 'lodash'; +import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { Field, FieldConfig, FieldType, GrafanaTheme, NumericRange, Threshold } from '../types'; +import { getFieldColorModeForField } from './fieldColor'; +import { getActiveThresholdForValue } from './thresholds'; + +export interface ColorScaleValue { + percent: number; // 0-1 + threshold: Threshold; + color: string; +} + +export type ScaleCalculator = (value: number) => ColorScaleValue; + +export function getScaleCalculator(field: Field, theme: GrafanaTheme): ScaleCalculator { + const mode = getFieldColorModeForField(field); + const getColor = mode.getCalculator(field, theme); + const info = field.state?.range ?? getMinMaxAndDelta(field); + + return (value: number) => { + let percent = 0; + + if (value !== -Infinity) { + percent = (value - info.min!) / info.delta; + } + + const threshold = getActiveThresholdForValue(field, value, percent); + + return { + percent, + threshold, + color: getColor(value, percent, threshold), + }; + }; +} + +function getMinMaxAndDelta(field: Field): NumericRange { + if (field.type !== FieldType.number) { + return { min: 0, max: 100, delta: 100 }; + } + + // Calculate min/max if required + let min = field.config.min; + let max = field.config.max; + + if (!isNumber(min) || !isNumber(max)) { + if (field.values && field.values.length) { + const stats = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] }); + if (!isNumber(min)) { + min = stats[ReducerID.min]; + } + if (!isNumber(max)) { + max = stats[ReducerID.max]; + } + } else { + min = 0; + max = 100; + } + } + + return { + min, + max, + delta: max! - min!, + }; +} + +/** + * @internal + */ +export function getFieldConfigWithMinMax(field: Field, local?: boolean): FieldConfig { + const { config } = field; + let { min, max } = config; + + if (isNumber(min) && isNumber(max)) { + return config; + } + + if (local || !field.state?.range) { + return { ...config, ...getMinMaxAndDelta(field) }; + } + + return { ...config, ...field.state.range }; +} diff --git a/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts new file mode 100644 index 0000000..e0a09be --- /dev/null +++ b/packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts @@ -0,0 +1,27 @@ +import { Registry, RegistryItem } from '../utils/Registry'; +import { ComponentType } from 'react'; +import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; +import { DataFrame, InterpolateFunction, VariableSuggestionsScope, VariableSuggestion } from '../types'; +import { EventBus } from '../events'; + +export interface StandardEditorContext { + data?: DataFrame[]; // All results + replaceVariables?: InterpolateFunction; + eventBus?: EventBus; + getSuggestions?: (scope?: VariableSuggestionsScope) => VariableSuggestion[]; + options?: TOptions; +} + +export interface StandardEditorProps { + value: TValue; + onChange: (value?: TValue) => void; + item: StandardEditorsRegistryItem; + context: StandardEditorContext; +} +export interface StandardEditorsRegistryItem extends RegistryItem { + editor: ComponentType>; + settings?: TSettings; +} +export const standardFieldConfigEditorRegistry = new FieldConfigOptionsRegistry(); + +export const standardEditorsRegistry = new Registry>(); diff --git a/packages/grafana-data/src/field/templateProxies.test.ts b/packages/grafana-data/src/field/templateProxies.test.ts new file mode 100644 index 0000000..d1ea674 --- /dev/null +++ b/packages/grafana-data/src/field/templateProxies.test.ts @@ -0,0 +1,32 @@ +import { getTemplateProxyForField } from './templateProxies'; +import { toDataFrame } from '../dataframe'; + +describe('Template proxies', () => { + it('supports name and displayName', () => { + const frames = [ + toDataFrame({ + fields: [ + { + name: '🔥', + config: { displayName: '✨' }, + labels: { + b: 'BBB', + a: 'AAA', + }, + }, + ], + }), + ]; + + const f = getTemplateProxyForField(frames[0].fields[0], frames[0], frames); + + expect(f.name).toEqual('🔥'); + expect(f.displayName).toEqual('✨'); + expect(`${f.labels}`).toEqual('a="AAA", b="BBB"'); + expect(f.labels.__values).toEqual('AAA, BBB'); + expect(f.labels.a).toEqual('AAA'); + + // Deprecated syntax + expect(`${f.formattedLabels}`).toEqual('a="AAA", b="BBB"'); + }); +}); diff --git a/packages/grafana-data/src/field/templateProxies.ts b/packages/grafana-data/src/field/templateProxies.ts new file mode 100644 index 0000000..c667ed8 --- /dev/null +++ b/packages/grafana-data/src/field/templateProxies.ts @@ -0,0 +1,39 @@ +import { DataFrame, Field } from '../types'; +import { getFieldDisplayName } from './fieldState'; +import { formatLabels } from '../utils/labels'; + +/** + * This object is created often, and only used when tmplates exist. Using a proxy lets us delay + * calculations of the more complex structures (label names) until they are actually used + */ +export function getTemplateProxyForField(field: Field, frame?: DataFrame, frames?: DataFrame[]): any { + return new Proxy( + {} as any, // This object shows up in test snapshots + { + get: (obj: Field, key: string, reciever: any) => { + if (key === 'name') { + return field.name; + } + + if (key === 'displayName') { + return getFieldDisplayName(field, frame, frames); + } + + if (key === 'labels' || key === 'formattedLabels') { + // formattedLabels deprecated + if (!field.labels) { + return ''; + } + return { + ...field.labels, + __values: Object.values(field.labels).sort().join(', '), + toString: () => { + return formatLabels(field.labels!, '', true); + }, + }; + } + return undefined; // (field as any)[key]; // any property? + }, + } + ); +} diff --git a/packages/grafana-data/src/field/thresholds.test.ts b/packages/grafana-data/src/field/thresholds.test.ts new file mode 100644 index 0000000..52b232b --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.test.ts @@ -0,0 +1,93 @@ +import { ThresholdsConfig, ThresholdsMode, FieldConfig, Threshold, Field, FieldType } from '../types'; +import { sortThresholds, getActiveThreshold, getActiveThresholdForValue } from './thresholds'; +import { validateFieldConfig } from './fieldOverrides'; +import { ArrayVector } from '../vector/ArrayVector'; + +describe('thresholds', () => { + test('sort thresholds', () => { + const thresholds: ThresholdsConfig = { + steps: [ + { color: 'TEN', value: 10 }, + { color: 'HHH', value: 100 }, + { color: 'ONE', value: 1 }, + ], + mode: ThresholdsMode.Absolute, + }; + const sorted = sortThresholds(thresholds.steps).map((t) => t.value); + expect(sorted).toEqual([1, 10, 100]); + const config: FieldConfig = { thresholds }; + + // Mutates and sorts the + validateFieldConfig(config); + expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); + }); + + test('find active', () => { + const thresholds: ThresholdsConfig = { + steps: [ + { color: 'ONE', value: 1 }, + { color: 'TEN', value: 10 }, + { color: 'HHH', value: 100 }, + ], + mode: ThresholdsMode.Absolute, + }; + const config: FieldConfig = { thresholds }; + // Mutates and sets ONE to -Infinity + validateFieldConfig(config); + expect(getActiveThreshold(-1, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(1, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(5, thresholds.steps).color).toEqual('ONE'); + expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(11, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(99, thresholds.steps).color).toEqual('TEN'); + expect(getActiveThreshold(100, thresholds.steps).color).toEqual('HHH'); + expect(getActiveThreshold(1000, thresholds.steps).color).toEqual('HHH'); + }); + + function getThreshold(value: number, steps: Threshold[], mode: ThresholdsMode, percent = 1): Threshold { + const field: Field = { + name: 'test', + config: { thresholds: { mode: mode, steps: sortThresholds(steps) } }, + type: FieldType.number, + values: new ArrayVector([]), + }; + validateFieldConfig(field.config!); + return getActiveThresholdForValue(field, value, percent); + } + + describe('Get color from threshold', () => { + it('should get first threshold color when only one threshold', () => { + const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }]; + expect(getThreshold(49, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[0]); + }); + + it('should get the threshold color if value is same as a threshold', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(50, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); + }); + + it('should get the nearest threshold color between thresholds', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(55, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); + }); + + it('should be able to get percent based threshold', () => { + const thresholds = [ + { index: 0, value: 0, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.9)).toEqual(thresholds[2]); + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.5)).toEqual(thresholds[1]); + expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.2)).toEqual(thresholds[0]); + }); + }); +}); diff --git a/packages/grafana-data/src/field/thresholds.ts b/packages/grafana-data/src/field/thresholds.ts new file mode 100644 index 0000000..8328483 --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.ts @@ -0,0 +1,38 @@ +import { Threshold, FALLBACK_COLOR, Field, ThresholdsMode } from '../types'; + +export const fallBackTreshold: Threshold = { value: 0, color: FALLBACK_COLOR }; + +export function getActiveThreshold(value: number, thresholds: Threshold[] | undefined): Threshold { + if (!thresholds || thresholds.length === 0) { + return fallBackTreshold; + } + + let active = thresholds[0]; + + for (const threshold of thresholds) { + if (value >= threshold.value) { + active = threshold; + } else { + break; + } + } + + return active; +} + +export function getActiveThresholdForValue(field: Field, value: number, percent: number): Threshold { + const { thresholds } = field.config; + + if (thresholds?.mode === ThresholdsMode.Percentage) { + return getActiveThreshold(percent * 100, thresholds?.steps); + } + + return getActiveThreshold(value, thresholds?.steps); +} + +/** + * Sorts the thresholds + */ +export function sortThresholds(thresholds: Threshold[]) { + return thresholds.sort((t1, t2) => t1.value - t2.value); +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts new file mode 100644 index 0000000..40f642a --- /dev/null +++ b/packages/grafana-data/src/index.ts @@ -0,0 +1,22 @@ +/** + * A library containing most of the core functionality and data types used in Grafana. + * + * @packageDocumentation + */ +export * from './utils'; +export * from './types'; +export * from './vector'; +export * from './dataframe'; +export * from './transformations'; +export * from './datetime'; +export * from './text'; +export * from './valueFormats'; +export * from './field'; +export * from './events'; +export { + ValueMatcherOptions, + BasicValueMatcherOptions, + RangeValueMatcherOptions, +} from './transformations/matchers/valueMatchers/types'; +export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin'; +export { createFieldConfigRegistry } from './panel/registryFactories'; diff --git a/packages/grafana-data/src/panel/PanelPlugin.test.tsx b/packages/grafana-data/src/panel/PanelPlugin.test.tsx new file mode 100644 index 0000000..2e238e6 --- /dev/null +++ b/packages/grafana-data/src/panel/PanelPlugin.test.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { identityOverrideProcessor, standardEditorsRegistry, standardFieldConfigEditorRegistry } from '../field'; +import { PanelPlugin } from './PanelPlugin'; +import { FieldConfigProperty } from '../types'; + +describe('PanelPlugin', () => { + describe('declarative options', () => { + beforeAll(() => { + standardFieldConfigEditorRegistry.setInit(() => { + return [ + { + id: FieldConfigProperty.Min, + path: 'min', + }, + { + id: FieldConfigProperty.Max, + path: 'max', + }, + ] as any; + }); + standardEditorsRegistry.setInit(() => { + return [ + { + id: 'number', + }, + ] as any; + }); + }); + + test('field config UI API', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addCustomEditor({ + id: 'custom', + path: 'custom', + name: 'Custom', + description: 'Custom field config property description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + // eslint-disable-next-line react/display-name + override: () =>
Editor
, + process: identityOverrideProcessor, + settings: {}, + shouldApply: () => true, + }); + }, + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(3); + }); + + test('options UI API', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder.addCustomEditor({ + id: 'option', + path: 'option', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + settings: {}, + }); + }); + + expect(panel.optionEditors).toBeDefined(); + expect(panel.optionEditors!.list()).toHaveLength(1); + }); + }); + + describe('default options', () => { + describe('panel options', () => { + test('default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder + .addNumberInput({ + path: 'numericOption', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }) + .addNumberInput({ + path: 'numericOptionNoDefault', + name: 'Option editor', + description: 'Option editor description', + }) + .addCustomEditor({ + id: 'customOption', + path: 'customOption', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + settings: {}, + defaultValue: { value: 'Custom default value' }, + }); + }); + + const expectedDefaults = { + numericOption: 10, + customOption: { value: 'Custom default value' }, + }; + + expect(panel.defaults).toEqual(expectedDefaults); + }); + + test('default values for nested paths', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.setPanelOptions((builder) => { + builder.addNumberInput({ + path: 'numericOption.nested', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }); + }); + + const expectedDefaults = { + numericOption: { nested: 10 }, + }; + + expect(panel.defaults).toEqual(expectedDefaults); + }); + }); + + describe('field config options', () => { + test('default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder + .addNumberInput({ + path: 'numericOption', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }) + .addNumberInput({ + path: 'numericOptionNoDefault', + name: 'Option editor', + description: 'Option editor description', + }) + .addCustomEditor({ + id: 'customOption', + path: 'customOption', + name: 'Option editor', + description: 'Option editor description', + // eslint-disable-next-line react/display-name + editor: () =>
Editor
, + // eslint-disable-next-line react/display-name + override: () =>
Override editor
, + process: identityOverrideProcessor, + shouldApply: () => true, + settings: {}, + defaultValue: { value: 'Custom default value' }, + }); + }, + }); + + const expectedDefaults = { + numericOption: 10, + customOption: { value: 'Custom default value' }, + }; + + expect(panel.fieldConfigDefaults.defaults.custom).toEqual(expectedDefaults); + }); + + test('default values for nested paths', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'numericOption.nested', + name: 'Option editor', + description: 'Option editor description', + defaultValue: 10, + }); + }, + }); + + const expectedDefaults = { + numericOption: { nested: 10 }, + }; + + expect(panel.fieldConfigDefaults.defaults.custom).toEqual(expectedDefaults); + }); + }); + + describe('standard field config options', () => { + test('standard config', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig(); + expect(panel.fieldConfigRegistry.list()).toHaveLength(2); + }); + + test('disabling standard config properties', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + disableStandardOptions: [FieldConfigProperty.Min], + }); + expect(panel.fieldConfigRegistry.list()).toHaveLength(1); + }); + + describe('default values', () => { + test('setting default values', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, + [FieldConfigProperty.Min]: { defaultValue: 10 }, + }, + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(2); + + expect(panel.fieldConfigDefaults).toEqual({ + defaults: { + min: 10, + max: 20, + custom: {}, + }, + overrides: [], + }); + }); + + it('should disable properties independently from the default values settings', () => { + const panel = new PanelPlugin(() => { + return
Panel
; + }); + + panel.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, + }, + disableStandardOptions: [FieldConfigProperty.Min], + }); + + expect(panel.fieldConfigRegistry.list()).toHaveLength(1); + + expect(panel.fieldConfigDefaults).toEqual({ + defaults: { + max: 20, + custom: {}, + }, + overrides: [], + }); + }); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts new file mode 100644 index 0000000..ce68b47 --- /dev/null +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -0,0 +1,328 @@ +import { + FieldConfigSource, + GrafanaPlugin, + PanelEditorProps, + PanelMigrationHandler, + PanelOptionEditorsRegistry, + PanelPluginMeta, + PanelProps, + PanelTypeChangedHandler, + FieldConfigProperty, +} from '../types'; +import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; +import { ComponentClass, ComponentType } from 'react'; +import set from 'lodash/set'; +import { deprecationWarning } from '../utils'; +import { FieldConfigOptionsRegistry } from '../field'; +import { createFieldConfigRegistry } from './registryFactories'; + +/** @beta */ +export type StandardOptionConfig = { + defaultValue?: any; + settings?: any; +}; + +/** @beta */ +export interface SetFieldConfigOptionsArgs { + /** + * Configuration object of the standard field config properites + * + * @example + * ```typescript + * { + * standardOptions: { + * [FieldConfigProperty.Decimals]: { + * defaultValue: 3 + * } + * } + * } + * ``` + */ + standardOptions?: Partial>; + + /** + * Array of standard field config properties that should not be available in the panel + * @example + * ```typescript + * { + * disableStandardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max, FieldConfigProperty.Unit] + * } + * ``` + */ + disableStandardOptions?: FieldConfigProperty[]; + + /** + * Function that allows custom field config properties definition. + * + * @param builder + * + * @example + * ```typescript + * useCustomConfig: builder => { + * builder + * .addNumberInput({ + * id: 'shapeBorderWidth', + * name: 'Border width', + * description: 'Border width of the shape', + * settings: { + * min: 1, + * max: 5, + * }, + * }) + * .addSelect({ + * id: 'displayMode', + * name: 'Display mode', + * description: 'How the shape shout be rendered' + * settings: { + * options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }] + * }, + * }) + * } + * ``` + */ + useCustomConfig?: (builder: FieldConfigEditorBuilder) => void; +} + +export class PanelPlugin< + TOptions = any, + TFieldConfigOptions extends object = any +> extends GrafanaPlugin { + private _defaults?: TOptions; + private _fieldConfigDefaults: FieldConfigSource = { + defaults: {}, + overrides: [], + }; + + private _fieldConfigRegistry?: FieldConfigOptionsRegistry; + private _initConfigRegistry = () => { + return new FieldConfigOptionsRegistry(); + }; + + private _optionEditors?: PanelOptionEditorsRegistry; + private registerOptionEditors?: (builder: PanelOptionsEditorBuilder) => void; + + panel: ComponentType> | null; + editor?: ComponentClass>; + onPanelMigration?: PanelMigrationHandler; + onPanelTypeChanged?: PanelTypeChangedHandler; + noPadding?: boolean; + + /** + * Legacy angular ctrl. If this exists it will be used instead of the panel + */ + angularPanelCtrl?: any; + + constructor(panel: ComponentType> | null) { + super(); + this.panel = panel; + } + + get defaults() { + let result = this._defaults || {}; + + if (!this._defaults) { + const editors = this.optionEditors; + + if (!editors || editors.list().length === 0) { + return null; + } + + for (const editor of editors.list()) { + set(result, editor.id, editor.defaultValue); + } + } + + return result; + } + + get fieldConfigDefaults(): FieldConfigSource { + const configDefaults = this._fieldConfigDefaults.defaults; + configDefaults.custom = {} as TFieldConfigOptions; + + for (const option of this.fieldConfigRegistry.list()) { + if (option.defaultValue === undefined) { + continue; + } + + set(configDefaults, option.id, option.defaultValue); + } + + return { + defaults: { + ...configDefaults, + }, + overrides: this._fieldConfigDefaults.overrides, + }; + } + + /** + * @deprecated setDefaults is deprecated in favor of setPanelOptions + */ + setDefaults(defaults: TOptions) { + deprecationWarning('PanelPlugin', 'setDefaults', 'setPanelOptions'); + this._defaults = defaults; + return this; + } + + get fieldConfigRegistry() { + if (!this._fieldConfigRegistry) { + this._fieldConfigRegistry = this._initConfigRegistry(); + } + + return this._fieldConfigRegistry; + } + + get optionEditors(): PanelOptionEditorsRegistry { + if (!this._optionEditors) { + const builder = new PanelOptionsEditorBuilder(); + this._optionEditors = builder.getRegistry(); + + if (this.registerOptionEditors) { + this.registerOptionEditors(builder); + } + } + + return this._optionEditors; + } + + /** + * @deprecated setEditor is deprecated in favor of setPanelOptions + */ + setEditor(editor: ComponentClass>) { + deprecationWarning('PanelPlugin', 'setEditor', 'setPanelOptions'); + this.editor = editor; + return this; + } + + setNoPadding() { + this.noPadding = true; + return this; + } + + /** + * This function is called before the panel first loads if + * the current version is different than the version that was saved. + * + * This is a good place to support any changes to the options model + */ + setMigrationHandler(handler: PanelMigrationHandler) { + this.onPanelMigration = handler; + return this; + } + + /** + * This function is called when the visualization was changed. This + * passes in the panel model for previous visualisation options inspection + * and panel model updates. + * + * This is useful for supporting PanelModel API updates when changing + * between Angular and React panels. + */ + setPanelChangeHandler(handler: PanelTypeChangedHandler) { + this.onPanelTypeChanged = handler; + return this; + } + + /** + * Enables panel options editor creation + * + * @example + * ```typescript + * + * import { ShapePanel } from './ShapePanel'; + * + * interface ShapePanelOptions {} + * + * export const plugin = new PanelPlugin(ShapePanel) + * .setPanelOptions(builder => { + * builder + * .addSelect({ + * id: 'shape', + * name: 'Shape', + * description: 'Select shape to render' + * settings: { + * options: [ + * {value: 'circle', label: 'Circle' }, + * {value: 'square', label: 'Square }, + * {value: 'triangle', label: 'Triangle } + * ] + * }, + * }) + * }) + * ``` + * + * @public + **/ + setPanelOptions(builder: (builder: PanelOptionsEditorBuilder) => void) { + // builder is applied lazily when options UI is created + this.registerOptionEditors = builder; + return this; + } + + /** + * Allows specifying which standard field config options panel should use and defining default values + * + * @example + * ```typescript + * + * import { ShapePanel } from './ShapePanel'; + * + * interface ShapePanelOptions {} + * + * // when plugin should use all standard options + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig(); + * + * // when plugin should only display specific standard options + * // note, that options will be displayed in the order they are provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max] + * }); + * + * // when standard option's default value needs to be provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max], + * standardOptionsDefaults: { + * [FieldConfigProperty.Min]: 20, + * [FieldConfigProperty.Max]: 100 + * } + * }); + * + * // when custom field config options needs to be provided + * export const plugin = new PanelPlugin(ShapePanel) + * .useFieldConfig({ + * useCustomConfig: builder => { + * builder + * .addNumberInput({ + * id: 'shapeBorderWidth', + * name: 'Border width', + * description: 'Border width of the shape', + * settings: { + * min: 1, + * max: 5, + * }, + * }) + * .addSelect({ + * id: 'displayMode', + * name: 'Display mode', + * description: 'How the shape shout be rendered' + * settings: { + * options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }] + * }, + * }) + * }, + * }); + * + * ``` + * + * @public + */ + useFieldConfig(config: SetFieldConfigOptionsArgs = {}) { + // builder is applied lazily when custom field configs are accessed + this._initConfigRegistry = () => createFieldConfigRegistry(config, this.meta.name); + + return this; + } +} diff --git a/packages/grafana-data/src/panel/registryFactories.ts b/packages/grafana-data/src/panel/registryFactories.ts new file mode 100644 index 0000000..694d601 --- /dev/null +++ b/packages/grafana-data/src/panel/registryFactories.ts @@ -0,0 +1,64 @@ +import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry'; +import { standardFieldConfigEditorRegistry } from '../field/standardFieldConfigEditorRegistry'; +import { FieldConfigProperty } from '../types/fieldOverrides'; +import { FieldConfigEditorBuilder } from '../utils/OptionsUIBuilders'; +import { SetFieldConfigOptionsArgs } from './PanelPlugin'; + +/** + * Helper functionality to create a field config registry. + * + * @param config - configuration to base the registry on. + * @param pluginName - name of the plugin that will use the registry. + * @internal + */ +export function createFieldConfigRegistry( + config: SetFieldConfigOptionsArgs = {}, + pluginName: string +): FieldConfigOptionsRegistry { + const registry = new FieldConfigOptionsRegistry(); + + // Add custom options + if (config.useCustomConfig) { + const builder = new FieldConfigEditorBuilder(); + config.useCustomConfig(builder); + + for (const customProp of builder.getRegistry().list()) { + customProp.isCustom = true; + // need to do something to make the custom items not conflict with standard ones + // problem is id (registry index) is used as property path + // so sort of need a property path on the FieldPropertyEditorItem + customProp.id = 'custom.' + customProp.id; + registry.register(customProp); + } + } + + for (let fieldConfigProp of standardFieldConfigEditorRegistry.list()) { + if (config.disableStandardOptions) { + const isDisabled = config.disableStandardOptions.indexOf(fieldConfigProp.id as FieldConfigProperty) > -1; + if (isDisabled) { + continue; + } + } + if (config.standardOptions) { + const customDefault: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.defaultValue; + const customSettings: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.settings; + if (customDefault) { + fieldConfigProp = { + ...fieldConfigProp, + defaultValue: customDefault, + }; + } + + if (customSettings) { + fieldConfigProp = { + ...fieldConfigProp, + settings: fieldConfigProp.settings ? { ...fieldConfigProp.settings, ...customSettings } : customSettings, + }; + } + } + + registry.register(fieldConfigProp); + } + + return registry; +} diff --git a/packages/grafana-data/src/text/index.ts b/packages/grafana-data/src/text/index.ts new file mode 100644 index 0000000..bece689 --- /dev/null +++ b/packages/grafana-data/src/text/index.ts @@ -0,0 +1,11 @@ +export * from './string'; +export * from './markdown'; +export * from './text'; +import { escapeHtml, hasAnsiCodes, sanitize, sanitizeUrl } from './sanitize'; + +export const textUtil = { + escapeHtml, + hasAnsiCodes, + sanitize, + sanitizeUrl, +}; diff --git a/packages/grafana-data/src/text/markdown.test.ts b/packages/grafana-data/src/text/markdown.test.ts new file mode 100644 index 0000000..ed51f2c --- /dev/null +++ b/packages/grafana-data/src/text/markdown.test.ts @@ -0,0 +1,13 @@ +import { renderMarkdown } from './markdown'; + +describe('Markdown wrapper', () => { + it('should be able to handle undefined value', () => { + const str = renderMarkdown(undefined); + expect(str).toBe(''); + }); + + it('should sanitize by default', () => { + const str = renderMarkdown(''); + expect(str).toBe('<script>alert()</script>'); + }); +}); diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts new file mode 100644 index 0000000..4c9b369 --- /dev/null +++ b/packages/grafana-data/src/text/markdown.ts @@ -0,0 +1,28 @@ +import marked from 'marked'; +import { sanitize } from './sanitize'; + +let hasInitialized = false; + +export interface RenderMarkdownOptions { + noSanitize?: boolean; +} + +export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): string { + if (!hasInitialized) { + marked.setOptions({ + pedantic: false, + gfm: true, + smartLists: true, + smartypants: false, + xhtml: false, + }); + hasInitialized = true; + } + + const html = marked(str || ''); + if (options?.noSanitize) { + return html; + } + + return sanitize(html); +} diff --git a/packages/grafana-data/src/text/sanitize.ts b/packages/grafana-data/src/text/sanitize.ts new file mode 100644 index 0000000..d32c4a9 --- /dev/null +++ b/packages/grafana-data/src/text/sanitize.ts @@ -0,0 +1,40 @@ +import xss from 'xss'; +import { sanitizeUrl as braintreeSanitizeUrl } from '@braintree/sanitize-url'; + +const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => { + // @ts-ignore + acc[element] = xss.whiteList[element].concat(['class', 'style']); + return acc; +}, {}); + +const sanitizeXSS = new xss.FilterXSS({ + whiteList: XSSWL, +}); + +/** + * Returns string safe from XSS attacks. + * + * Even though we allow the style-attribute, there's still default filtering applied to it + * Info: https://github.com/leizongmin/js-xss#customize-css-filter + * Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js + */ +export function sanitize(unsanitizedString: string): string { + try { + return sanitizeXSS.process(unsanitizedString); + } catch (error) { + console.error('String could not be sanitized', unsanitizedString); + return unsanitizedString; + } +} + +export function sanitizeUrl(url: string): string { + return braintreeSanitizeUrl(url); +} + +export function hasAnsiCodes(input: string): boolean { + return /\u001b\[\d{1,2}m/.test(input); +} + +export function escapeHtml(str: string): string { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/packages/grafana-data/src/text/string.test.ts b/packages/grafana-data/src/text/string.test.ts new file mode 100644 index 0000000..b9cd3eb --- /dev/null +++ b/packages/grafana-data/src/text/string.test.ts @@ -0,0 +1,90 @@ +import { escapeStringForRegex, stringToJsRegex, stringToMs, unEscapeStringFromRegex } from './string'; + +describe('stringToJsRegex', () => { + it('should just return string as RegEx if it does not start as a regex', () => { + const output = stringToJsRegex('validRegexp'); + expect(output).toBeInstanceOf(RegExp); + }); + + it('should parse the valid regex value', () => { + const output = stringToJsRegex('/validRegexp/'); + expect(output).toBeInstanceOf(RegExp); + }); + + it('should throw error on invalid regex value', () => { + const input = '/etc/hostname'; + expect(() => { + stringToJsRegex(input); + }).toThrow(); + }); +}); + +describe('stringToMs', () => { + it('should return zero if no input', () => { + const output = stringToMs(''); + expect(output).toBe(0); + }); + + it('should return its input, as int, if no unit is supplied', () => { + const output = stringToMs('1000'); + expect(output).toBe(1000); + }); + + it('should convert 3s to 3000', () => { + const output = stringToMs('3s'); + expect(output).toBe(3000); + }); + + it('should convert 2m to 120000', () => { + const output = stringToMs('2m'); + expect(output).toBe(120000); + }); + + it('should convert 2h to 7200000', () => { + const output = stringToMs('2h'); + expect(output).toBe(7200000); + }); + + it('should convert 2d to 172800000', () => { + const output = stringToMs('2d'); + expect(output).toBe(172800000); + }); + + it('should throw on unsupported unit', () => { + expect(() => { + stringToMs('1y'); + }).toThrow(); + }); +}); + +describe('escapeStringForRegex', () => { + describe('when using a string with special chars', () => { + it('then all special chars should be escaped', () => { + const result = escapeStringForRegex('([{}])|*+-.?<>#&^$'); + expect(result).toBe('\\(\\[\\{\\}\\]\\)\\|\\*\\+\\-\\.\\?\\<\\>\\#\\&\\^\\$'); + }); + }); + + describe('when using a string without special chars', () => { + it('then nothing should change', () => { + const result = escapeStringForRegex('some string 123'); + expect(result).toBe('some string 123'); + }); + }); +}); + +describe('unEscapeStringFromRegex', () => { + describe('when using a string with escaped special chars', () => { + it('then all special chars should be unescaped', () => { + const result = unEscapeStringFromRegex('\\(\\[\\{\\}\\]\\)\\|\\*\\+\\-\\.\\?\\<\\>\\#\\&\\^\\$'); + expect(result).toBe('([{}])|*+-.?<>#&^$'); + }); + }); + + describe('when using a string without escaped special chars', () => { + it('then nothing should change', () => { + const result = unEscapeStringFromRegex('some string 123'); + expect(result).toBe('some string 123'); + }); + }); +}); diff --git a/packages/grafana-data/src/text/string.ts b/packages/grafana-data/src/text/string.ts new file mode 100644 index 0000000..79cf3ae --- /dev/null +++ b/packages/grafana-data/src/text/string.ts @@ -0,0 +1,97 @@ +import { camelCase } from 'lodash'; +const specialChars = ['(', '[', '{', '}', ']', ')', '|', '*', '+', '-', '.', '?', '<', '>', '#', '&', '^', '$']; + +export const escapeStringForRegex = (value: string) => { + if (!value) { + return value; + } + + return specialChars.reduce((escaped, currentChar) => escaped.replace(currentChar, '\\' + currentChar), value); +}; + +export const unEscapeStringFromRegex = (value: string) => { + if (!value) { + return value; + } + + return specialChars.reduce((escaped, currentChar) => escaped.replace('\\' + currentChar, currentChar), value); +}; + +export function stringStartsAsRegEx(str: string): boolean { + if (!str) { + return false; + } + + return str[0] === '/'; +} + +export function stringToJsRegex(str: string): RegExp { + if (!stringStartsAsRegEx(str)) { + return new RegExp(`^${str}$`); + } + + const match = str.match(new RegExp('^/(.*?)/(g?i?m?y?)$')); + + if (!match) { + throw new Error(`'${str}' is not a valid regular expression.`); + } + + return new RegExp(match[1], match[2]); +} + +export function stringToMs(str: string): number { + if (!str) { + return 0; + } + + const nr = parseInt(str, 10); + const unit = str.substr(String(nr).length); + const s = 1000; + const m = s * 60; + const h = m * 60; + const d = h * 24; + + switch (unit) { + case 's': + return nr * s; + case 'm': + return nr * m; + case 'h': + return nr * h; + case 'd': + return nr * d; + default: + if (!unit) { + return isNaN(nr) ? 0 : nr; + } + throw new Error('Not supported unit: ' + unit); + } +} + +export function toNumberString(value: number | undefined | null): string { + if (value !== null && value !== undefined && Number.isFinite(value as number)) { + return value.toString(); + } + return ''; +} + +export function toIntegerOrUndefined(value: string): number | undefined { + if (!value) { + return undefined; + } + const v = parseInt(value, 10); + return isNaN(v) ? undefined : v; +} + +export function toFloatOrUndefined(value: string): number | undefined { + if (!value) { + return undefined; + } + const v = parseFloat(value); + return isNaN(v) ? undefined : v; +} + +export const toPascalCase = (string: string) => { + const str = camelCase(string); + return str.charAt(0).toUpperCase() + str.substring(1); +}; diff --git a/packages/grafana-data/src/text/text.test.ts b/packages/grafana-data/src/text/text.test.ts new file mode 100644 index 0000000..624fbc6 --- /dev/null +++ b/packages/grafana-data/src/text/text.test.ts @@ -0,0 +1,68 @@ +import { findMatchesInText, parseFlags } from './text'; + +describe('findMatchesInText()', () => { + it('gets no matches for when search and or line are empty', () => { + expect(findMatchesInText('', '')).toEqual([]); + expect(findMatchesInText('foo', '')).toEqual([]); + expect(findMatchesInText('', 'foo')).toEqual([]); + }); + + it('gets no matches for unmatched search string', () => { + expect(findMatchesInText('foo', 'bar')).toEqual([]); + }); + + it('gets matches for matched search string', () => { + expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]); + expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); + }); + + test('should find all matches for a complete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + { length: 3, start: 9, text: 'bar', end: 12 }, + ]); + }); + + test('not fail on incomplete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + ]); + expect(findMatchesInText('foo foo bar', '(')).toEqual([]); + expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]); + }); + + test('should parse and use flags', () => { + expect(findMatchesInText(' foo FOO bar ', '(?i)foo')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'FOO', end: 8 }, + ]); + expect(findMatchesInText(' foo FOO bar ', '(?i)(?-i)foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); + expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)^foo.')).toEqual([ + { length: 4, start: 0, text: 'FOO\n', end: 4 }, + { length: 4, start: 4, text: 'foob', end: 8 }, + ]); + expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)(?-smi)^foo.')).toEqual([]); + }); +}); + +describe('parseFlags()', () => { + it('when no flags or text', () => { + expect(parseFlags('')).toEqual({ cleaned: '', flags: 'g' }); + expect(parseFlags('(?is)')).toEqual({ cleaned: '', flags: 'gis' }); + expect(parseFlags('foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + }); + + it('when flags present', () => { + expect(parseFlags('(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' }); + expect(parseFlags('(?ims)foo')).toEqual({ cleaned: 'foo', flags: 'gims' }); + }); + + it('when flags cancel each other', () => { + expect(parseFlags('(?i)(?-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?i-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?is)(?-ims)foo')).toEqual({ cleaned: 'foo', flags: 'g' }); + expect(parseFlags('(?i)(?-i)(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' }); + }); +}); diff --git a/packages/grafana-data/src/text/text.ts b/packages/grafana-data/src/text/text.ts new file mode 100644 index 0000000..567b850 --- /dev/null +++ b/packages/grafana-data/src/text/text.ts @@ -0,0 +1,84 @@ +export interface TextMatch { + text: string; + start: number; + length: number; + end: number; +} + +/** + * Adapt findMatchesInText for react-highlight-words findChunks handler. + * See https://github.com/bvaughn/react-highlight-words#props + */ +export function findHighlightChunksInText({ + searchWords, + textToHighlight, +}: { + searchWords: string[]; + textToHighlight: string; +}) { + return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []); +} + +const cleanNeedle = (needle: string): string => { + return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); +}; + +/** + * Returns a list of substring regexp matches. + */ +export function findMatchesInText(haystack: string, needle: string): TextMatch[] { + // Empty search can send re.exec() into infinite loop, exit early + if (!haystack || !needle) { + return []; + } + const matches: TextMatch[] = []; + const { cleaned, flags } = parseFlags(cleanNeedle(needle)); + let regexp: RegExp; + try { + regexp = new RegExp(`(?:${cleaned})`, flags); + } catch (error) { + return matches; + } + haystack.replace(regexp, (substring, ...rest) => { + if (substring) { + const offset = rest[rest.length - 2]; + matches.push({ + text: substring, + start: offset, + length: substring.length, + end: offset + substring.length, + }); + } + return ''; + }); + return matches; +} + +const CLEAR_FLAG = '-'; +const FLAGS_REGEXP = /\(\?([ims-]+)\)/g; + +/** + * Converts any mode modifiers in the text to the Javascript equivalent flag + */ +export function parseFlags(text: string): { cleaned: string; flags: string } { + const flags: Set = new Set(['g']); + + const cleaned = text.replace(FLAGS_REGEXP, (str, group) => { + const clearAll = group.startsWith(CLEAR_FLAG); + + for (let i = 0; i < group.length; ++i) { + const flag = group.charAt(i); + if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) { + flags.delete(flag); + } else if (flag !== CLEAR_FLAG) { + flags.add(flag); + } + } + return ''; // Remove flag from text + }); + + return { + cleaned: cleaned, + flags: Array.from(flags).join(''), + }; +} diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts new file mode 100644 index 0000000..ac8d0f0 --- /dev/null +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -0,0 +1,165 @@ +import difference from 'lodash/difference'; + +import { fieldReducers, ReducerID, reduceField } from './fieldReducer'; + +import { Field, FieldType } from '../types/index'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { ArrayVector } from '../vector/ArrayVector'; + +/** + * Run a reducer and get back the value + */ +function reduce(field: Field, id: string): any { + return reduceField({ field, reducers: [id] })[id]; +} + +function createField(name: string, values?: T[], type?: FieldType): Field { + const arr = new ArrayVector(values); + return { + name, + config: {}, + type: type ? type : guessFieldTypeFromValue(arr.get(0)), + values: arr, + }; +} + +describe('Stats Calculators', () => { + const basicTable = new MutableDataFrame({ + fields: [ + { name: 'a', values: [10, 20] }, + { name: 'b', values: [20, 30] }, + { name: 'c', values: [30, 40] }, + ], + }); + + it('should load all standard stats', () => { + for (const id of Object.keys(ReducerID)) { + const reducer = fieldReducers.getIfExists(id); + const found = reducer ? reducer.id : ''; + expect(found).toEqual(id); + } + }); + + it('should fail to load unknown stats', () => { + const names = ['not a stat', ReducerID.max, ReducerID.min, 'also not a stat']; + const stats = fieldReducers.list(names); + expect(stats.length).toBe(2); + + const found = stats.map((v) => v.id); + const notFound = difference(names, found); + expect(notFound.length).toBe(2); + + expect(notFound[0]).toBe('not a stat'); + }); + + it('should calculate basic stats', () => { + const stats = reduceField({ + field: basicTable.fields[0], + reducers: ['first', 'last', 'mean', 'count'], + }); + + expect(stats.first).toEqual(10); + expect(stats.last).toEqual(20); + expect(stats.mean).toEqual(15); + expect(stats.count).toEqual(2); + }); + + it('should support a single stat also', () => { + basicTable.fields[0].state = undefined; // clear the cache + const stats = reduceField({ + field: basicTable.fields[0], + reducers: ['first'], + }); + + // Should do the simple version that just looks up value + expect(Object.keys(stats).length).toEqual(1); + expect(stats.first).toEqual(10); + }); + + it('should get non standard stats', () => { + const stats = reduceField({ + field: basicTable.fields[0], + reducers: [ReducerID.distinctCount, ReducerID.changeCount], + }); + + expect(stats.distinctCount).toEqual(2); + expect(stats.changeCount).toEqual(1); + }); + + it('should calculate step', () => { + const stats = reduceField({ + field: createField('x', [100, 200, 300, 400]), + reducers: [ReducerID.step, ReducerID.delta], + }); + + expect(stats.step).toEqual(100); + expect(stats.delta).toEqual(300); + }); + + it('consistently check allIsNull/allIsZero', () => { + const empty = createField('x'); + const allNull = createField('x', [null, null, null, null]); + const allUndefined = createField('x', [undefined, undefined, undefined, undefined]); + const allZero = createField('x', [0, 0, 0, 0]); + + expect(reduce(empty, ReducerID.allIsNull)).toEqual(true); + expect(reduce(allNull, ReducerID.allIsNull)).toEqual(true); + expect(reduce(allUndefined, ReducerID.allIsNull)).toEqual(true); + + expect(reduce(empty, ReducerID.allIsZero)).toEqual(false); + expect(reduce(allNull, ReducerID.allIsZero)).toEqual(false); + expect(reduce(allZero, ReducerID.allIsZero)).toEqual(true); + }); + + it('consistent results for first/last value with null', () => { + const info = [ + { + data: [null, 200, null], // first/last value is null + result: 200, + }, + { + data: [null, null, null], // All null + result: null, + }, + { + data: [undefined, undefined, undefined], // Empty row + result: null, + }, + ]; + + const stats = reduceField({ + field: createField('x', info[0].data), + reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull, ReducerID.diffperc], // uses standard path + }); + expect(stats[ReducerID.first]).toEqual(null); + expect(stats[ReducerID.last]).toEqual(null); + expect(stats[ReducerID.firstNotNull]).toEqual(200); + expect(stats[ReducerID.lastNotNull]).toEqual(200); + expect(stats[ReducerID.diffperc]).toEqual(0); + + const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull]; + for (const input of info) { + for (const reducer of reducers) { + const v1 = reduceField({ + field: createField('x', input.data), + reducers: [reducer, ReducerID.mean], // uses standard path + })[reducer]; + + const v2 = reduceField({ + field: createField('x', input.data), + reducers: [reducer], // uses optimized path + })[reducer]; + + if (v1 !== v2 || v1 !== input.result) { + const msg = + `Invalid ${reducer} result for: ` + + input.data.join(', ') + + ` Expected: ${input.result}` + // configured + ` Received: Multiple: ${v1}, Single: ${v2}`; + expect(msg).toEqual(null); + } + } + } + }); +}); diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts new file mode 100644 index 0000000..604474f --- /dev/null +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -0,0 +1,443 @@ +// Libraries +import isNumber from 'lodash/isNumber'; + +import { NullValueMode, Field, FieldState, FieldCalcs } from '../types/index'; +import { Registry, RegistryItem } from '../utils/Registry'; + +export enum ReducerID { + sum = 'sum', + max = 'max', + min = 'min', + logmin = 'logmin', + mean = 'mean', + last = 'last', + first = 'first', + count = 'count', + range = 'range', + diff = 'diff', + diffperc = 'diffperc', + delta = 'delta', + step = 'step', + + firstNotNull = 'firstNotNull', + lastNotNull = 'lastNotNull', + + changeCount = 'changeCount', + distinctCount = 'distinctCount', + + allIsZero = 'allIsZero', + allIsNull = 'allIsNull', +} + +// Internal function +type FieldReducer = (field: Field, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs; + +export interface FieldReducerInfo extends RegistryItem { + // Internal details + emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero + standard: boolean; // The most common stats can all be calculated in a single pass + reduce?: FieldReducer; +} + +interface ReduceFieldOptions { + field: Field; + reducers: string[]; // The stats to calculate +} + +/** + * @returns an object with a key for each selected stat + */ +export function reduceField(options: ReduceFieldOptions): FieldCalcs { + const { field, reducers } = options; + + if (!field || !reducers || reducers.length < 1) { + return {}; + } + + if (field.state?.calcs) { + // Find the values we need to calculate + const missing: string[] = []; + for (const s of reducers) { + if (!field.state.calcs.hasOwnProperty(s)) { + missing.push(s); + } + } + if (missing.length < 1) { + return { + ...field.state.calcs, + }; + } + } + if (!field.state) { + field.state = {} as FieldState; + } + + const queue = fieldReducers.list(reducers); + + // Return early for empty series + // This lets the concrete implementations assume at least one row + const data = field.values; + if (data.length < 1) { + const calcs = { ...field.state.calcs } as FieldCalcs; + for (const reducer of queue) { + calcs[reducer.id] = reducer.emptyInputResult !== null ? reducer.emptyInputResult : null; + } + return (field.state.calcs = calcs); + } + + const { nullValueMode } = field.config; + const ignoreNulls = nullValueMode === NullValueMode.Ignore; + const nullAsZero = nullValueMode === NullValueMode.AsZero; + + // Avoid calculating all the standard stats if possible + if (queue.length === 1 && queue[0].reduce) { + const values = queue[0].reduce(field, ignoreNulls, nullAsZero); + field.state.calcs = { + ...field.state.calcs, + ...values, + }; + return values; + } + + // For now everything can use the standard stats + let values = doStandardCalcs(field, ignoreNulls, nullAsZero); + + for (const reducer of queue) { + if (!values.hasOwnProperty(reducer.id) && reducer.reduce) { + values = { + ...values, + ...reducer.reduce(field, ignoreNulls, nullAsZero), + }; + } + } + + field.state.calcs = { + ...field.state.calcs, + ...values, + }; + return values; +} + +// ------------------------------------------------------------------------------ +// +// No Exported symbols below here. +// +// ------------------------------------------------------------------------------ + +export const fieldReducers = new Registry(() => [ + { + id: ReducerID.lastNotNull, + name: 'Last (not null)', + description: 'Last non-null value', + standard: true, + aliasIds: ['current'], + reduce: calculateLastNotNull, + }, + { + id: ReducerID.last, + name: 'Last', + description: 'Last Value', + standard: true, + reduce: calculateLast, + }, + { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, + { + id: ReducerID.firstNotNull, + name: 'First (not null)', + description: 'First non-null value', + standard: true, + reduce: calculateFirstNotNull, + }, + { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, + { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, + { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, aliasIds: ['avg'] }, + { + id: ReducerID.sum, + name: 'Total', + description: 'The sum of all values', + emptyInputResult: 0, + standard: true, + aliasIds: ['total'], + }, + { + id: ReducerID.count, + name: 'Count', + description: 'Number of values in response', + emptyInputResult: 0, + standard: true, + }, + { + id: ReducerID.range, + name: 'Range', + description: 'Difference between minimum and maximum values', + standard: true, + }, + { + id: ReducerID.delta, + name: 'Delta', + description: 'Cumulative change in value', + standard: true, + }, + { + id: ReducerID.step, + name: 'Step', + description: 'Minimum interval between values', + standard: true, + }, + { + id: ReducerID.diff, + name: 'Difference', + description: 'Difference between first and last values', + standard: true, + }, + { + id: ReducerID.logmin, + name: 'Min (above zero)', + description: 'Used for log min scale', + standard: true, + }, + { + id: ReducerID.allIsZero, + name: 'All Zeros', + description: 'All values are zero', + emptyInputResult: false, + standard: true, + }, + { + id: ReducerID.allIsNull, + name: 'All Nulls', + description: 'All values are null', + emptyInputResult: true, + standard: true, + }, + { + id: ReducerID.changeCount, + name: 'Change Count', + description: 'Number of times the value changes', + standard: false, + reduce: calculateChangeCount, + }, + { + id: ReducerID.distinctCount, + name: 'Distinct Count', + description: 'Number of distinct values', + standard: false, + reduce: calculateDistinctCount, + }, + { + id: ReducerID.diffperc, + name: 'Difference percent', + description: 'Percentage difference between first and last values', + standard: true, + }, +]); + +export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const calcs = { + sum: 0, + max: -Number.MAX_VALUE, + min: Number.MAX_VALUE, + logmin: Number.MAX_VALUE, + mean: null, + last: null, + first: null, + lastNotNull: null, + firstNotNull: null, + count: 0, + nonNullCount: 0, + allIsNull: true, + allIsZero: true, + range: null, + diff: null, + delta: 0, + step: Number.MAX_VALUE, + diffperc: 0, + + // Just used for calculations -- not exposed as a stat + previousDeltaUp: true, + } as FieldCalcs; + + const data = field.values; + calcs.count = data.length; + + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + + if (i === 0) { + calcs.first = currentValue; + } + + calcs.last = currentValue; + + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + + if (currentValue !== null && currentValue !== undefined) { + const isFirst = calcs.firstNotNull === null; + if (isFirst) { + calcs.firstNotNull = currentValue; + } + + if (isNumber(currentValue)) { + calcs.sum += currentValue; + calcs.allIsNull = false; + calcs.nonNullCount++; + + if (!isFirst) { + const step = currentValue - calcs.lastNotNull!; + if (calcs.step > step) { + calcs.step = step; // the minimum interval + } + + if (calcs.lastNotNull! > currentValue) { + // counter reset + calcs.previousDeltaUp = false; + if (i === data.length - 1) { + // reset on last + calcs.delta += currentValue; + } + } else { + if (calcs.previousDeltaUp) { + calcs.delta += step; // normal increment + } else { + calcs.delta += currentValue; // account for counter reset + } + calcs.previousDeltaUp = true; + } + } + + if (currentValue > calcs.max) { + calcs.max = currentValue; + } + + if (currentValue < calcs.min) { + calcs.min = currentValue; + } + + if (currentValue < calcs.logmin && currentValue > 0) { + calcs.logmin = currentValue; + } + } + + if (currentValue !== 0) { + calcs.allIsZero = false; + } + + calcs.lastNotNull = currentValue; + } + } + + if (calcs.max === -Number.MAX_VALUE) { + calcs.max = null; + } + + if (calcs.min === Number.MAX_VALUE) { + calcs.min = null; + } + + if (calcs.step === Number.MAX_VALUE) { + calcs.step = null; + } + + if (calcs.nonNullCount > 0) { + calcs.mean = calcs.sum! / calcs.nonNullCount; + } + + if (calcs.allIsNull) { + calcs.allIsZero = false; + } + + if (calcs.max !== null && calcs.min !== null) { + calcs.range = calcs.max - calcs.min; + } + + if (isNumber(calcs.firstNotNull) && isNumber(calcs.lastNotNull)) { + calcs.diff = calcs.lastNotNull - calcs.firstNotNull; + } + + if (isNumber(calcs.firstNotNull) && isNumber(calcs.diff)) { + calcs.diffperc = calcs.diff / calcs.firstNotNull; + } + return calcs; +} + +function calculateFirst(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + return { first: field.values.get(0) }; +} + +function calculateFirstNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + for (let idx = 0; idx < data.length; idx++) { + const v = data.get(idx); + if (v != null && v !== undefined) { + return { firstNotNull: v }; + } + } + return { firstNotNull: null }; +} + +function calculateLast(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + return { last: data.get(data.length - 1) }; +} + +function calculateLastNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + let idx = data.length - 1; + while (idx >= 0) { + const v = data.get(idx--); + if (v != null && v !== undefined) { + return { lastNotNull: v }; + } + } + return { lastNotNull: null }; +} + +function calculateChangeCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + let count = 0; + let first = true; + let last: any = null; + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + if (!first && last !== currentValue) { + count++; + } + first = false; + last = currentValue; + } + + return { changeCount: count }; +} + +function calculateDistinctCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { + const data = field.values; + const distinct = new Set(); + for (let i = 0; i < data.length; i++) { + let currentValue = data.get(i); + if (currentValue === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + currentValue = 0; + } + } + distinct.add(currentValue); + } + return { distinctCount: distinct.size }; +} diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts new file mode 100644 index 0000000..dd80a0e --- /dev/null +++ b/packages/grafana-data/src/transformations/index.ts @@ -0,0 +1,14 @@ +export * from './matchers/ids'; +export * from './transformers/ids'; +export * from './matchers'; +export { standardTransformers } from './transformers'; +export * from './fieldReducer'; +export { transformDataFrame } from './transformDataFrame'; +export { + TransformerRegistryItem, + TransformerUIProps, + standardTransformersRegistry, +} from './standardTransformersRegistry'; +export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher'; +export { RenameByRegexTransformerOptions } from './transformers/renameByRegex'; +export { outerJoinDataFrames } from './transformers/joinDataFrames'; diff --git a/packages/grafana-data/src/transformations/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts new file mode 100644 index 0000000..aa5e6c7 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -0,0 +1,99 @@ +// Load the Builtin matchers +import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates'; +import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher'; +import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher'; +import { getRefIdMatchers } from './matchers/refIdMatcher'; +import { + FieldMatcherInfo, + MatcherConfig, + FrameMatcherInfo, + FieldMatcher, + FrameMatcher, + ValueMatcherInfo, + ValueMatcher, +} from '../types/transformations'; +import { Registry } from '../utils/Registry'; +import { getNullValueMatchers } from './matchers/valueMatchers/nullMatchers'; +import { getNumericValueMatchers } from './matchers/valueMatchers/numericMatchers'; +import { getEqualValueMatchers } from './matchers/valueMatchers/equalMatchers'; +import { getRangeValueMatchers } from './matchers/valueMatchers/rangeMatchers'; +import { getSimpleFieldMatchers } from './matchers/simpleFieldMatcher'; +import { getRegexValueMatcher } from './matchers/valueMatchers/regexMatchers'; + +/** + * Registry that contains all of the built in field matchers. + * @public + */ +export const fieldMatchers = new Registry(() => { + return [ + ...getFieldPredicateMatchers(), // Predicates + ...getFieldTypeMatchers(), // by type + ...getFieldNameMatchers(), // by name + ...getSimpleFieldMatchers(), // first + ]; +}); + +/** + * Registry that contains all of the built in frame matchers. + * @public + */ +export const frameMatchers = new Registry(() => { + return [ + ...getFramePredicateMatchers(), // Predicates + ...getFrameNameMatchers(), // by name + ...getRefIdMatchers(), // by query refId + ]; +}); + +/** + * Registry that contains all of the built in value matchers. + * @public + */ +export const valueMatchers = new Registry(() => { + return [ + ...getNullValueMatchers(), + ...getNumericValueMatchers(), + ...getEqualValueMatchers(), + ...getRangeValueMatchers(), + ...getRegexValueMatcher(), + ]; +}); + +/** + * Resolves a field matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getFieldMatcher(config: MatcherConfig): FieldMatcher { + const info = fieldMatchers.get(config.id); + if (!info) { + throw new Error('Unknown field matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a frame matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getFrameMatchers(config: MatcherConfig): FrameMatcher { + const info = frameMatchers.get(config.id); + if (!info) { + throw new Error('Unknown frame matcher: ' + config.id); + } + return info.get(config.options); +} + +/** + * Resolves a value matcher from the registry for given config. + * Will throw an error if matcher can not be resolved. + * @public + */ +export function getValueMatcher(config: MatcherConfig): ValueMatcher { + const info = valueMatchers.get(config.id); + if (!info) { + throw new Error('Unknown value matcher: ' + config.id); + } + return info.get(config.options); +} diff --git a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts new file mode 100644 index 0000000..c6370b0 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts @@ -0,0 +1,23 @@ +import { FieldType } from '../../types/dataFrame'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time }, + { name: 'B', type: FieldType.boolean }, + { name: 'C', type: FieldType.string }, + ], +}); + +describe('Field Type Matcher', () => { + const matcher = fieldMatchers.get(FieldMatcherID.byType); + it('finds numbers', () => { + for (const field of simpleSeriesWithTypes.fields) { + const matches = matcher.get(FieldType.number); + const didMatch = matches(field, simpleSeriesWithTypes, [simpleSeriesWithTypes]); + expect(didMatch).toBe(field.type === FieldType.number); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts new file mode 100644 index 0000000..4cb8201 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts @@ -0,0 +1,59 @@ +import { Field, FieldType, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; + +// General Field matcher +const fieldTypeMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byType, + name: 'Field Type', + description: 'match based on the field type', + defaultOptions: FieldType.number, + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return type === field.type; + }; + }, + + getOptionsDisplayText: (type: FieldType) => { + return `Field type: ${type}`; + }, +}; + +// Numeric Field matcher +// This gets its own entry so it shows up in the dropdown +const numericMatcher: FieldMatcherInfo = { + id: FieldMatcherID.numeric, + name: 'Numeric Fields', + description: 'Fields with type number', + + get: () => { + return fieldTypeMatcher.get(FieldType.number); + }, + + getOptionsDisplayText: () => { + return 'Numeric Fields'; + }, +}; + +// Time Field matcher +const timeMatcher: FieldMatcherInfo = { + id: FieldMatcherID.time, + name: 'Time Fields', + description: 'Fields with type time', + + get: () => { + return fieldTypeMatcher.get(FieldType.time); + }, + + getOptionsDisplayText: () => { + return 'Time Fields'; + }, +}; + +/** + * Registry Initialization + */ +export function getFieldTypeMatchers(): FieldMatcherInfo[] { + return [fieldTypeMatcher, numericMatcher, timeMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts new file mode 100644 index 0000000..cd4c788 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -0,0 +1,55 @@ +// This needs to be in its own file to avoid circular references + +// Builtin Predicates +// not using 'any' and 'never' since they are reserved keywords +export enum MatcherID { + anyMatch = 'anyMatch', // checks children + allMatch = 'allMatch', // checks children + invertMatch = 'invertMatch', // checks child + alwaysMatch = 'alwaysMatch', + neverMatch = 'neverMatch', +} + +export enum FieldMatcherID { + // Specific Types + numeric = 'numeric', + time = 'time', // Can be multiple times + first = 'first', + firstTimeField = 'firstTimeField', // Only the first fime field + + // With arguments + byType = 'byType', + byName = 'byName', + byNames = 'byNames', + byRegexp = 'byRegexp', + byRegexpOrNames = 'byRegexpOrNames', + byFrameRefID = 'byFrameRefID', + // byIndex = 'byIndex', + // byLabel = 'byLabel', +} + +/** + * Field name matchers + */ +export enum FrameMatcherID { + byName = 'byName', + byRefId = 'byRefId', + byIndex = 'byIndex', + byLabel = 'byLabel', +} + +/** + * @public + */ +export enum ValueMatcherID { + regex = 'regex', + isNull = 'isNull', + isNotNull = 'isNotNull', + greater = 'greater', + greaterOrEqual = 'greaterOrEqual', + lower = 'lower', + lowerOrEqual = 'lowerOrEqual', + equal = 'equal', + notEqual = 'notEqual', + between = 'between', +} diff --git a/packages/grafana-data/src/transformations/matchers/matchers.test.ts b/packages/grafana-data/src/transformations/matchers/matchers.test.ts new file mode 100644 index 0000000..81ea76b --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/matchers.test.ts @@ -0,0 +1,11 @@ +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from './ids'; + +describe('Matchers', () => { + it('should load all matchers', () => { + for (const name of Object.keys(FieldMatcherID)) { + const matcher = fieldMatchers.get(name); + expect(matcher.id).toBe(name); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts new file mode 100644 index 0000000..42a653a --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -0,0 +1,388 @@ +import { getFieldMatcher } from '../matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { ByNamesMatcherMode } from './nameMatcher'; + +describe('Field Name by Regexp Matcher', () => { + it('Match all with wildcard regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/.*/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all with decimals regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/^\\d+$/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match complex regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexp, + options: '/\\b(?:\\S+?\\.)+\\S+\\b$/', + }; + + const matcher = getFieldMatcher(config); + let resultCount = 0; + for (const field of seriesWithNames.fields) { + if (matcher(field, seriesWithNames, [seriesWithNames])) { + resultCount++; + } + expect(resultCount).toBe(1); + } + }); +}); + +describe('Field Name Matcher', () => { + it('Match only exact name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byName, + options: 'C', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should respect letter case', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byName, + options: 'c', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match none of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byName, + options: '', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); +}); + +describe('Field Multiple Names Matcher', () => { + it('Match only exact name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should default to include mode', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match should respect letter case', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['c'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match none of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: [], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match all of the field names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.include, + names: ['some.instance.path', '112', '13'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all but supplied names', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byNames, + options: { + mode: ByNamesMatcherMode.exclude, + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name !== 'C'); + } + }); +}); + +describe('Field Regexp or Names Matcher', () => { + it('Match only exact name by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match all starting with AA', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/^AA/', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'AAA'); + } + }); + + it('Match all starting with AA and C', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/^AA/', + names: ['C'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'AAA' || field.name === 'C'); + } + }); + + it('Match should respect letter case by name if not igored in pattern', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['c'], + pattern: '/c/i', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + const didMatch = matcher(field, seriesWithNames, [seriesWithNames]); + expect(didMatch).toBe(field.name === 'C'); + } + }); + + it('Match none of the field names by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: [], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false); + } + }); + + it('Match all of the field names by name', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + names: ['some.instance.path', '112', '13'], + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); + + it('Match all of the field names by regexp', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byRegexpOrNames, + options: { + pattern: '/.*/', + }, + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true); + } + }); +}); + +describe('Fields returned by query with refId', () => { + it('Match all fields in frame with refId: A', () => { + const data = [ + toDataFrame({ + refId: 'A', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + toDataFrame({ + refId: 'B', + fields: [{ name: 'field_1' }, { name: 'field_2' }], + }), + ]; + + const matcher = getFieldMatcher({ + id: FieldMatcherID.byFrameRefID, + options: 'A', + }); + + const frameA = data[0]; + expect(matcher(frameA.fields[0], frameA, data)).toBe(true); + expect(matcher(frameA.fields[1], frameA, data)).toBe(true); + + const frameB = data[1]; + expect(matcher(frameB.fields[0], frameB, data)).toBe(false); + expect(matcher(frameB.fields[1], frameB, data)).toBe(false); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts new file mode 100644 index 0000000..24609ad --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -0,0 +1,200 @@ +import { Field, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID, FrameMatcherID } from './ids'; +import { FieldMatcherInfo, FrameMatcherInfo, FieldMatcher } from '../../types/transformations'; +import { stringToJsRegex } from '../../text/string'; +import { getFieldDisplayName } from '../../field/fieldState'; + +export interface RegexpOrNamesMatcherOptions { + pattern?: string; + names?: string[]; +} + +/** + * Mode to be able to toggle if the names matcher should match fields in provided + * list or all except provided names. + * @public + */ +export enum ByNamesMatcherMode { + exclude = 'exclude', + include = 'include', +} + +/** + * Options to instruct the by names matcher to either match all fields in given list + * or all except the fields in the list. + * @public + */ +export interface ByNamesMatcherOptions { + mode?: ByNamesMatcherMode; + names?: string[]; + readOnly?: boolean; + prefix?: string; +} + +// General Field matcher +const fieldNameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byName, + name: 'Field Name', + description: 'match the field name', + defaultOptions: '', + + get: (name: string): FieldMatcher => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return getFieldDisplayName(field, frame, allFrames) === name; + }; + }, + + getOptionsDisplayText: (name: string) => { + return `Field name: ${name}`; + }, +}; + +const multipleFieldNamesMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byNames, + name: 'Field Names', + description: 'match any of the given the field names', + defaultOptions: { + mode: ByNamesMatcherMode.include, + names: [], + }, + + get: (options: ByNamesMatcherOptions): FieldMatcher => { + const { names, mode = ByNamesMatcherMode.include } = options; + const uniqueNames = new Set(names ?? []); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + if (mode === ByNamesMatcherMode.exclude) { + return !uniqueNames.has(getFieldDisplayName(field, frame, allFrames)); + } + return uniqueNames.has(getFieldDisplayName(field, frame, allFrames)); + }; + }, + + getOptionsDisplayText: (options: ByNamesMatcherOptions): string => { + const { names, mode } = options; + const displayText = (names ?? []).join(', '); + if (mode === ByNamesMatcherMode.exclude) { + return `All except: ${displayText}`; + } + return `All of: ${displayText}`; + }, +}; + +const regexpFieldNameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byRegexp, + name: 'Field Name by Regexp', + description: 'match the field name by a given regexp pattern', + defaultOptions: '/.*/', + + get: (pattern: string): FieldMatcher => { + const regexp = patternToRegex(pattern); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + const displayName = getFieldDisplayName(field, frame, allFrames); + return !!regexp && regexp.test(displayName); + }; + }, + + getOptionsDisplayText: (pattern: string): string => { + return `Field name by pattern: ${pattern}`; + }, +}; + +/** + * Field matcher that will match all fields that exists in a + * data frame with configured refId. + * @public + */ +const fieldsInFrameMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byFrameRefID, + name: 'Fields by frame refId', + description: 'match all fields returned in data frame with refId.', + defaultOptions: '', + + get: (refId: string): FieldMatcher => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return frame.refId === refId; + }; + }, + + getOptionsDisplayText: (refId: string): string => { + return `Math all fields returned by query with reference ID: ${refId}`; + }, +}; + +const regexpOrMultipleNamesMatcher: FieldMatcherInfo = { + id: FieldMatcherID.byRegexpOrNames, + name: 'Field Name by Regexp or Names', + description: 'match the field name by a given regexp pattern or given names', + defaultOptions: { + pattern: '/.*/', + names: [], + }, + + get: (options: RegexpOrNamesMatcherOptions): FieldMatcher => { + const regexpMatcher = regexpFieldNameMatcher.get(options?.pattern || ''); + const namesMatcher = multipleFieldNamesMatcher.get({ + mode: ByNamesMatcherMode.include, + names: options?.names ?? [], + }); + + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return namesMatcher(field, frame, allFrames) || regexpMatcher(field, frame, allFrames); + }; + }, + + getOptionsDisplayText: (options: RegexpOrNamesMatcherOptions): string => { + const pattern = options?.pattern ?? ''; + const names = options?.names?.join(',') ?? ''; + return `Field name by pattern: ${pattern} or names: ${names}`; + }, +}; + +const patternToRegex = (pattern?: string): RegExp | undefined => { + if (!pattern) { + return undefined; + } + + try { + return stringToJsRegex(pattern); + } catch (error) { + console.error(error); + return undefined; + } +}; + +// General Frame matcher +const frameNameMatcher: FrameMatcherInfo = { + id: FrameMatcherID.byName, + name: 'Frame Name', + description: 'match the frame name', + defaultOptions: '/.*/', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (frame: DataFrame) => { + return regex.test(frame.name || ''); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `Frame name: ${pattern}`; + }, +}; + +/** + * Registry Initialization + */ +export function getFieldNameMatchers(): FieldMatcherInfo[] { + return [ + fieldNameMatcher, + regexpFieldNameMatcher, + multipleFieldNamesMatcher, + regexpOrMultipleNamesMatcher, + fieldsInFrameMatcher, + ]; +} + +export function getFrameNameMatchers(): FrameMatcherInfo[] { + return [frameNameMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/predicates.test.ts b/packages/grafana-data/src/transformations/matchers/predicates.test.ts new file mode 100644 index 0000000..ef007b5 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/predicates.test.ts @@ -0,0 +1,41 @@ +import { FieldType } from '../../types/dataFrame'; +import { fieldMatchers } from '../matchers'; +import { simpleSeriesWithTypes } from './fieldTypeMatcher.test'; +import { FieldMatcherID, MatcherID } from './ids'; +import { MatcherConfig } from '../../types/transformations'; + +const matchesNumberConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.number, +}; +const matchesTimeConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.time, +}; +const both = [matchesNumberConfig, matchesTimeConfig]; +const allFrames = [simpleSeriesWithTypes]; + +describe('Check Predicates', () => { + it('can not match both', () => { + const matches = fieldMatchers.get(MatcherID.allMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(false); + } + }); + + it('match either time or number', () => { + const matches = fieldMatchers.get(MatcherID.anyMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe( + field.type === FieldType.number || field.type === FieldType.time + ); + } + }); + + it('match not time', () => { + const matches = fieldMatchers.get(MatcherID.invertMatch).get(matchesTimeConfig); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(field.type !== FieldType.time); + } + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/predicates.ts b/packages/grafana-data/src/transformations/matchers/predicates.ts new file mode 100644 index 0000000..cf0b2a9 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/predicates.ts @@ -0,0 +1,265 @@ +import { Field, DataFrame, FieldType } from '../../types/dataFrame'; +import { MatcherID } from './ids'; +import { getFieldMatcher, fieldMatchers, getFrameMatchers, frameMatchers } from '../matchers'; +import { FieldMatcherInfo, MatcherConfig, FrameMatcherInfo } from '../../types/transformations'; + +const anyFieldMatcher: FieldMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFieldMatcher(option); + }); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + for (const child of children) { + if (child(field, frame, allFrames)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = fieldMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const anyFrameMatcher: FrameMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (child(frame)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFieldsMatcher: FieldMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFieldMatcher(option); + }); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + for (const child of children) { + if (!child(field, frame, allFrames)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = fieldMatchers.get(sub.id); // Ugho what about frame + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFramesMatcher: FrameMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map((option) => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (!child(frame)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const notFieldMatcher: FieldMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFieldMatcher(option); + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return !check(field, frame, allFrames); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = fieldMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +const notFrameMatcher: FrameMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFrameMatchers(option); + return (frame: DataFrame) => { + return !check(frame); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = frameMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +export const alwaysFieldMatcher = (field: Field) => { + return true; +}; + +export const alwaysFrameMatcher = (frame: DataFrame) => { + return true; +}; + +export const neverFieldMatcher = (field: Field) => { + return false; +}; + +export const notTimeFieldMatcher = (field: Field) => { + return field.type !== FieldType.time; +}; + +export const neverFrameMatcher = (frame: DataFrame) => { + return false; +}; + +const alwaysFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Fields', + description: 'Always Match', + + get: (option: any) => { + return alwaysFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const alwaysFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Frames', + description: 'Always Match', + + get: (option: any) => { + return alwaysFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const neverFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Fields', + description: 'Never Match', + excludeFromPicker: true, + + get: (option: any) => { + return neverFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +const neverFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Frames', + description: 'Never Match', + + get: (option: any) => { + return neverFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +export function getFieldPredicateMatchers(): FieldMatcherInfo[] { + return [anyFieldMatcher, allFieldsMatcher, notFieldMatcher, alwaysFieldMatcherInfo, neverFieldMatcherInfo]; +} + +export function getFramePredicateMatchers(): FrameMatcherInfo[] { + return [anyFrameMatcher, allFramesMatcher, notFrameMatcher, alwaysFrameMatcherInfo, neverFrameMatcherInfo]; +} diff --git a/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts new file mode 100644 index 0000000..a8e0aa9 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts @@ -0,0 +1,27 @@ +import { DataFrame } from '../../types/dataFrame'; +import { FrameMatcherID } from './ids'; +import { FrameMatcherInfo } from '../../types/transformations'; +import { stringToJsRegex } from '../../text'; + +// General Field matcher +const refIdMacher: FrameMatcherInfo = { + id: FrameMatcherID.byRefId, + name: 'Query refId', + description: 'match the refId', + defaultOptions: 'A', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (frame: DataFrame) => { + return regex.test(frame.refId || ''); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `RefID: ${pattern}`; + }, +}; + +export function getRefIdMatchers(): FrameMatcherInfo[] { + return [refIdMacher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts b/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts new file mode 100644 index 0000000..b32dfaf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/simpleFieldMatcher.ts @@ -0,0 +1,42 @@ +import { Field, FieldType, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; + +const firstFieldMatcher: FieldMatcherInfo = { + id: FieldMatcherID.first, + name: 'First Field', + description: 'The first field in the frame', + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return field === frame.fields[0]; + }; + }, + + getOptionsDisplayText: () => { + return `First field`; + }, +}; + +const firstTimeFieldMatcher: FieldMatcherInfo = { + id: FieldMatcherID.firstTimeField, + name: 'First time field', + description: 'The first field of type time in a frame', + + get: (type: FieldType) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return field.type === FieldType.time && field === frame.fields.find((f) => f.type === FieldType.time); + }; + }, + + getOptionsDisplayText: () => { + return `First time field`; + }, +}; + +/** + * Registry Initialization + */ +export function getSimpleFieldMatchers(): FieldMatcherInfo[] { + return [firstFieldMatcher, firstTimeFieldMatcher]; +} diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts new file mode 100644 index 0000000..2aba608 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.test.ts @@ -0,0 +1,108 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value equals to matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.equal, + options: { + value: 23, + }, + }); + + it('should match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); +}); + +describe('value not equals matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10, 'asd', '23'], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.notEqual, + options: { + value: 23, + }, + }); + + it('should not match when option value is same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should match when option value is different', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match when option value is different type', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 3; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match when option value is different type but same', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts new file mode 100644 index 0000000..c6b3dea --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/equalMatchers.ts @@ -0,0 +1,42 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.equal, + name: 'Is equal', + description: 'Match where value for given field is equal to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value == options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +const isNotEqualValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.notEqual, + name: 'Is not equal', + description: 'Match where value for given field is not equal to options value.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + // eslint-disable-next-line eqeqeq + return value != options.value; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '' }), +}; + +export const getEqualValueMatchers = (): ValueMatcherInfo[] => [isEqualValueMatcher, isNotEqualValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts new file mode 100644 index 0000000..91b1f06 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.test.ts @@ -0,0 +1,72 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNull, + options: {}, + }); + + it('should match null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value not null matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, null, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.isNotNull, + options: {}, + }); + + it('should match not null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match non-null values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts new file mode 100644 index 0000000..42ce8b1 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/nullMatchers.ts @@ -0,0 +1,40 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { ValueMatcherOptions } from './types'; + +const isNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNull, + name: 'Is null', + description: 'Match where value for given field is null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value === null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +const isNotNullValueMatcher: ValueMatcherInfo = { + id: ValueMatcherID.isNotNull, + name: 'Is not null', + description: 'Match where value for given field is not null.', + get: () => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return value !== null; + }; + }, + getOptionsDisplayText: () => { + return `Matches all rows where field is not null.`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({}), +}; + +export const getNullValueMatchers = (): ValueMatcherInfo[] => [isNullValueMatcher, isNotNullValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts new file mode 100644 index 0000000..7c20d91 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.test.ts @@ -0,0 +1,180 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value greater than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greater, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value greater than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.greaterOrEqual, + options: { + value: 11, + }, + }); + + it('should match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equlas to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lower, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); + +describe('value lower than or equal matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.lowerOrEqual, + options: { + value: 11, + }, + }); + + it('should match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should match values equal to 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts new file mode 100644 index 0000000..129b659 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -0,0 +1,91 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const isGreaterValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greater, + name: 'Is greater', + description: 'Match when field value is greater than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is greater than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.greaterOrEqual, + name: 'Is greater or equal', + description: 'Match when field value is lower or greater than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value >= options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower or greater than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lower, + name: 'Is lower', + description: 'Match when field value is lower than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value < options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +const isLowerOrEqualValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.lowerOrEqual, + name: 'Is lower or equal', + description: 'Match when field value is lower or equal than option.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value <= options.value; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is lower or equal than: ${options.value}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ value: 0 }), +}; + +export const getNumericValueMatchers = (): ValueMatcherInfo[] => [ + isGreaterValueMatcher, + isGreaterOrEqualValueMatcher, + isLowerValueMatcher, + isLowerOrEqualValueMatcher, +]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts new file mode 100644 index 0000000..710ed50 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.test.ts @@ -0,0 +1,49 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('value between matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: [23, 11, 10, 25], + }, + ], + }), + ]; + + const matcher = getValueMatcher({ + id: ValueMatcherID.between, + options: { + from: 10, + to: 25, + }, + }); + + it('should match values greater than 10 but lower than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match values greater than 25', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 4; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + + it('should not match values lower than 11', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts new file mode 100644 index 0000000..17edb0d --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/rangeMatchers.ts @@ -0,0 +1,26 @@ +import { Field, FieldType } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { RangeValueMatcherOptions } from './types'; + +const isBetweenValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.between, + name: 'Is between', + description: 'Match when field value is between given option values.', + get: (options) => { + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + if (isNaN(value)) { + return false; + } + return value > options.from && value < options.to; + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is between ${options.from} and ${options.to}.`; + }, + isApplicable: (field) => field.type === FieldType.number, + getDefaultOptions: () => ({ from: 0, to: 100 }), +}; + +export const getRangeValueMatchers = (): ValueMatcherInfo[] => [isBetweenValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts new file mode 100644 index 0000000..379a6cf --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.test.ts @@ -0,0 +1,85 @@ +import { toDataFrame } from '../../../dataframe'; +import { DataFrame } from '../../../types/dataFrame'; +import { getValueMatcher } from '../../matchers'; +import { ValueMatcherID } from '../ids'; + +describe('regex value matcher', () => { + const data: DataFrame[] = [ + toDataFrame({ + fields: [ + { + name: 'temp', + values: ['.', 'asdf', 100, '25.5'], + }, + ], + }), + ]; + + describe('option with value .*', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '.*', + }, + }); + + it('should match all values', () => { + const frame = data[0]; + const field = frame.fields[0]; + + for (let i = 0; i < field.values.length; i++) { + expect(matcher(i, field, frame, data)).toBeTruthy(); + } + }); + }); + + describe('option with value \\w+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\w+', + }, + }); + + it('should match wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-wordy values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 0; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); + + describe('option with value \\d+', () => { + const matcher = getValueMatcher({ + id: ValueMatcherID.regex, + options: { + value: '\\d+', + }, + }); + + it('should match numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 2; + + expect(matcher(valueIndex, field, frame, data)).toBeTruthy(); + }); + + it('should not match non-numeric values', () => { + const frame = data[0]; + const field = frame.fields[0]; + const valueIndex = 1; + + expect(matcher(valueIndex, field, frame, data)).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts new file mode 100644 index 0000000..b4b1590 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/regexMatchers.ts @@ -0,0 +1,25 @@ +import { Field } from '../../../types/dataFrame'; +import { ValueMatcherInfo } from '../../../types/transformations'; +import { ValueMatcherID } from '../ids'; +import { BasicValueMatcherOptions } from './types'; + +const regexValueMatcher: ValueMatcherInfo> = { + id: ValueMatcherID.regex, + name: 'Regex', + description: 'Match when field value is matching regex.', + get: (options) => { + const regex = new RegExp(options.value); + + return (valueIndex: number, field: Field) => { + const value = field.values.get(valueIndex); + return regex.test(value); + }; + }, + getOptionsDisplayText: (options) => { + return `Matches all rows where field value is matching regex: ${options.value}`; + }, + isApplicable: () => true, + getDefaultOptions: () => ({ value: '.*' }), +}; + +export const getRegexValueMatcher = (): ValueMatcherInfo[] => [regexValueMatcher]; diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts new file mode 100644 index 0000000..f292129 --- /dev/null +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/types.ts @@ -0,0 +1,23 @@ +/** + * Describes a empty value matcher option. + * @public + */ +export interface ValueMatcherOptions {} + +/** + * Describes a basic value matcher option that has a single value. + * @public + */ +export interface BasicValueMatcherOptions extends ValueMatcherOptions { + value: T; +} + +/** + * Describes a range value matcher option that has a to and a from value to + * be able to match a range. + * @public + */ +export interface RangeValueMatcherOptions extends ValueMatcherOptions { + from: T; + to: T; +} diff --git a/packages/grafana-data/src/transformations/standardTransformersRegistry.ts b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts new file mode 100644 index 0000000..62a804f --- /dev/null +++ b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts @@ -0,0 +1,32 @@ +import React from 'react'; +import { DataFrame, DataTransformerInfo } from '../types'; +import { Registry, RegistryItem } from '../utils/Registry'; + +export interface TransformerUIProps { + /** + * Transformer configuration, persisted on panel's model + */ + options: T; + /** + * Pre-transform data frames + */ + input: DataFrame[]; + onChange: (options: T) => void; +} + +export interface TransformerRegistryItem extends RegistryItem { + /** + * Object describing transformer configuration + */ + transformation: DataTransformerInfo; + /** + * React component used as UI for the transformer + */ + editor: React.ComponentType>; +} + +/** + * Registry of transformation options that can be driven by + * stored configuration files. + */ +export const standardTransformersRegistry = new Registry>(); diff --git a/packages/grafana-data/src/transformations/transformDataFrame.ts b/packages/grafana-data/src/transformations/transformDataFrame.ts new file mode 100644 index 0000000..dbcb019 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformDataFrame.ts @@ -0,0 +1,68 @@ +import { MonoTypeOperatorFunction, Observable, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +import { DataFrame, DataTransformerConfig } from '../types'; +import { standardTransformersRegistry, TransformerRegistryItem } from './standardTransformersRegistry'; + +const getOperator = (config: DataTransformerConfig): MonoTypeOperatorFunction => (source) => { + const info = standardTransformersRegistry.get(config.id); + + if (!info) { + return source; + } + + const defaultOptions = info.transformation.defaultOptions ?? {}; + const options = { ...defaultOptions, ...config.options }; + + return source.pipe( + mergeMap((before) => of(before).pipe(info.transformation.operator(options), postProcessTransform(before, info))) + ); +}; + +const postProcessTransform = ( + before: DataFrame[], + info: TransformerRegistryItem +): MonoTypeOperatorFunction => (source) => + source.pipe( + map((after) => { + if (after === before) { + return after; + } + + // Add a key to the metadata if the data changed + for (const series of after) { + if (!series.meta) { + series.meta = {}; + } + + if (!series.meta.transformations) { + series.meta.transformations = [info.id]; + } else { + series.meta.transformations = [...series.meta.transformations, info.id]; + } + } + + return after; + }) + ); + +/** + * Apply configured transformations to the input data + */ +export function transformDataFrame(options: DataTransformerConfig[], data: DataFrame[]): Observable { + const stream = of(data); + + if (!options.length) { + return stream; + } + + const operators: Array> = []; + + for (let index = 0; index < options.length; index++) { + const config = options[index]; + operators.push(getOperator(config)); + } + + // @ts-ignore TypeScript has a hard time understanding this construct + return stream.pipe.apply(stream, operators); +} diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts new file mode 100644 index 0000000..c76c703 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -0,0 +1,42 @@ +import { reduceTransformer } from './transformers/reduce'; +import { concatenateTransformer } from './transformers/concat'; +import { calculateFieldTransformer } from './transformers/calculateField'; +import { filterFieldsTransformer, filterFramesTransformer } from './transformers/filter'; +import { filterFieldsByNameTransformer } from './transformers/filterByName'; +import { noopTransformer } from './transformers/noop'; +import { filterFramesByRefIdTransformer } from './transformers/filterByRefId'; +import { orderFieldsTransformer } from './transformers/order'; +import { organizeFieldsTransformer } from './transformers/organize'; +import { seriesToColumnsTransformer } from './transformers/seriesToColumns'; +import { seriesToRowsTransformer } from './transformers/seriesToRows'; +import { renameFieldsTransformer } from './transformers/rename'; +import { labelsToFieldsTransformer } from './transformers/labelsToFields'; +import { ensureColumnsTransformer } from './transformers/ensureColumns'; +import { groupByTransformer } from './transformers/groupBy'; +import { sortByTransformer } from './transformers/sortBy'; +import { mergeTransformer } from './transformers/merge'; +import { renameByRegexTransformer } from './transformers/renameByRegex'; +import { filterByValueTransformer } from './transformers/filterByValue'; + +export const standardTransformers = { + noopTransformer, + filterFieldsTransformer, + filterFieldsByNameTransformer, + filterFramesTransformer, + filterFramesByRefIdTransformer, + filterByValueTransformer, + orderFieldsTransformer, + organizeFieldsTransformer, + reduceTransformer, + concatenateTransformer, + calculateFieldTransformer, + seriesToColumnsTransformer, + seriesToRowsTransformer, + renameFieldsTransformer, + labelsToFieldsTransformer, + ensureColumnsTransformer, + groupByTransformer, + sortByTransformer, + mergeTransformer, + renameByRegexTransformer, +}; diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.test.ts b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts new file mode 100644 index 0000000..d119af9 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/calculateField.test.ts @@ -0,0 +1,222 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { ReducerID } from '../fieldReducer'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { CalculateFieldMode, calculateFieldTransformer, ReduceOptions } from './calculateField'; +import { DataFrameView } from '../../dataframe'; +import { BinaryOperationID } from '../../utils'; + +const seriesA = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], +}); + +const seriesBC = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + { name: 'E', type: FieldType.boolean, values: [true, false] }, + ], +}); + +describe('calculateField transformer w/ timeseries', () => { + beforeAll(() => { + mockTransformationsRegistry([calculateFieldTransformer]); + }); + + it('will filter and alias', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + // defaults to `sum` ReduceRow + alias: 'The Total', + }, + }; + + await expect(transformDataFrame([cfg], [seriesA, seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + A: 1, + B: 2, + C: 3, + D: 'first', + E: true, + 'The Total': 6, + TheTime: 1000, + }, + { + A: 100, + B: 200, + C: 300, + D: 'second', + E: false, + 'The Total': 600, + TheTime: 2000, + }, + ]); + }); + }); + + it('will replace other fields', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + reducer: ReducerID.mean, + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesA, seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + Mean: 2, + TheTime: 1000, + }, + { + Mean: 200, + TheTime: 2000, + }, + ]); + }); + }); + + it('will filter by name', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + include: ['B'], + reducer: ReducerID.mean, + } as ReduceOptions, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + Mean: 2, + TheTime: 1000, + }, + { + Mean: 200, + TheTime: 2000, + }, + ]); + }); + }); + + it('binary math', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'B', + operator: BinaryOperationID.Add, + right: 'C', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + 'B + C': 5, + TheTime: 1000, + }, + { + 'B + C': 500, + TheTime: 2000, + }, + ]); + }); + }); + + it('field + static number', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'B', + operator: BinaryOperationID.Add, + right: '2', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toEqual([ + { + 'B + 2': 4, + TheTime: 1000, + }, + { + 'B + 2': 202, + TheTime: 2000, + }, + ]); + }); + }); + + it('boolean field', async () => { + const cfg = { + id: DataTransformerID.calculateField, + options: { + mode: CalculateFieldMode.BinaryOperation, + binary: { + left: 'E', + operator: BinaryOperationID.Multiply, + right: '1', + }, + replaceFields: true, + }, + }; + + await expect(transformDataFrame([cfg], [seriesBC])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + const rows = new DataFrameView(filtered).toArray(); + expect(rows).toMatchInlineSnapshot(` + Array [ + Object { + "E * 1": 1, + "TheTime": 1000, + }, + Object { + "E * 1": 0, + "TheTime": 2000, + }, + ] + `); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts new file mode 100644 index 0000000..8dd1812 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -0,0 +1,238 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, DataTransformerInfo, Field, FieldType, NullValueMode, Vector } from '../../types'; +import { DataTransformerID } from './ids'; +import { doStandardCalcs, fieldReducers, ReducerID } from '../fieldReducer'; +import { getFieldMatcher } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; +import { RowVector } from '../../vector/RowVector'; +import { ArrayVector, BinaryOperationVector, ConstantVector } from '../../vector'; +import { AsNumberVector } from '../../vector/AsNumberVector'; +import { getTimeField } from '../../dataframe/processDataFrame'; +import defaults from 'lodash/defaults'; +import { BinaryOperationID, binaryOperators } from '../../utils/binaryOperators'; +import { ensureColumnsTransformer } from './ensureColumns'; +import { getFieldDisplayName } from '../../field'; +import { noopTransformer } from './noop'; + +export enum CalculateFieldMode { + ReduceRow = 'reduceRow', + BinaryOperation = 'binary', +} + +export interface ReduceOptions { + include?: string[]; // Assume all fields + reducer: ReducerID; + nullValueMode?: NullValueMode; +} + +export interface BinaryOptions { + left: string; + operator: BinaryOperationID; + right: string; +} + +const defaultReduceOptions: ReduceOptions = { + reducer: ReducerID.sum, +}; + +const defaultBinaryOptions: BinaryOptions = { + left: '', + operator: BinaryOperationID.Add, + right: '', +}; + +export interface CalculateFieldTransformerOptions { + // True/False or auto + timeSeries?: boolean; + mode: CalculateFieldMode; // defaults to 'reduce' + + // Only one should be filled + reduce?: ReduceOptions; + binary?: BinaryOptions; + + // Remove other fields + replaceFields?: boolean; + + // Output field properties + alias?: string; // The output field name + // TODO: config?: FieldConfig; or maybe field overrides? since the UI exists +} + +type ValuesCreator = (data: DataFrame) => Vector; + +export const calculateFieldTransformer: DataTransformerInfo = { + id: DataTransformerID.calculateField, + name: 'Add field from calculation', + description: 'Use the row values to calculate a new field', + defaultOptions: { + mode: CalculateFieldMode.ReduceRow, + reduce: { + reducer: ReducerID.sum, + }, + }, + operator: (options) => (outerSource) => { + const operator = + options && options.timeSeries !== false ? ensureColumnsTransformer.operator(null) : noopTransformer.operator({}); + + return outerSource.pipe( + operator, + map((data) => { + const mode = options.mode ?? CalculateFieldMode.ReduceRow; + let creator: ValuesCreator | undefined = undefined; + + if (mode === CalculateFieldMode.ReduceRow) { + creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data); + } else if (mode === CalculateFieldMode.BinaryOperation) { + creator = getBinaryCreator(defaults(options.binary, defaultBinaryOptions), data); + } + + // Nothing configured + if (!creator) { + return data; + } + + return data.map((frame) => { + // delegate field creation to the specific function + const values = creator!(frame); + if (!values) { + return frame; + } + + const field = { + name: getNameFromOptions(options), + type: FieldType.number, + config: {}, + values, + }; + let fields: Field[] = []; + + // Replace all fields with the single field + if (options.replaceFields) { + const { timeField } = getTimeField(frame); + if (timeField && options.timeSeries !== false) { + fields = [timeField, field]; + } else { + fields = [field]; + } + } else { + fields = [...frame.fields, field]; + } + return { + ...frame, + fields, + }; + }); + }) + ); + }, +}; + +function getReduceRowCreator(options: ReduceOptions, allFrames: DataFrame[]): ValuesCreator { + let matcher = getFieldMatcher({ + id: FieldMatcherID.numeric, + }); + + if (options.include && options.include.length) { + matcher = getFieldMatcher({ + id: FieldMatcherID.byNames, + options: { + names: options.include, + }, + }); + } + + const info = fieldReducers.get(options.reducer); + + if (!info) { + throw new Error(`Unknown reducer: ${options.reducer}`); + } + + const reducer = info.reduce ?? doStandardCalcs; + const ignoreNulls = options.nullValueMode === NullValueMode.Ignore; + const nullAsZero = options.nullValueMode === NullValueMode.AsZero; + + return (frame: DataFrame) => { + // Find the columns that should be examined + const columns: Vector[] = []; + for (const field of frame.fields) { + if (matcher(field, frame, allFrames)) { + columns.push(field.values); + } + } + + // Prepare a "fake" field for the row + const iter = new RowVector(columns); + const row: Field = { + name: 'temp', + values: iter, + type: FieldType.number, + config: {}, + }; + const vals: number[] = []; + + for (let i = 0; i < frame.length; i++) { + iter.rowIndex = i; + const val = reducer(row, ignoreNulls, nullAsZero)[options.reducer]; + vals.push(val); + } + + return new ArrayVector(vals); + }; +} + +function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string, allFrames: DataFrame[]): Vector | undefined { + if (!name) { + return undefined; + } + + for (const f of frame.fields) { + if (name === getFieldDisplayName(f, frame, allFrames)) { + if (f.type === FieldType.boolean) { + return new AsNumberVector(f.values); + } + return f.values; + } + } + + const v = parseFloat(name); + if (!isNaN(v)) { + return new ConstantVector(v, frame.length); + } + + return undefined; +} + +function getBinaryCreator(options: BinaryOptions, allFrames: DataFrame[]): ValuesCreator { + const operator = binaryOperators.getIfExists(options.operator); + + return (frame: DataFrame) => { + const left = findFieldValuesWithNameOrConstant(frame, options.left, allFrames); + const right = findFieldValuesWithNameOrConstant(frame, options.right, allFrames); + if (!left || !right || !operator) { + return (undefined as unknown) as Vector; + } + + return new BinaryOperationVector(left, right, operator.operation); + }; +} + +export function getNameFromOptions(options: CalculateFieldTransformerOptions) { + if (options.alias?.length) { + return options.alias; + } + + if (options.mode === CalculateFieldMode.BinaryOperation) { + const { binary } = options; + return `${binary?.left ?? ''} ${binary?.operator ?? ''} ${binary?.right ?? ''}`; + } + + if (options.mode === CalculateFieldMode.ReduceRow) { + const r = fieldReducers.getIfExists(options.reduce?.reducer); + if (r) { + return r.name; + } + } + + return 'math'; +} diff --git a/packages/grafana-data/src/transformations/transformers/concat.test.ts b/packages/grafana-data/src/transformations/transformers/concat.test.ts new file mode 100644 index 0000000..f7d0932 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/concat.test.ts @@ -0,0 +1,136 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { concatenateFields, ConcatenateFrameNameMode } from './concat'; + +export const simpleABC = toDataFrame({ + name: 'ABC', + fields: [ + { name: 'A', values: [1, 2] }, + { name: 'B', values: [1, 2] }, + { name: 'C', values: [1, 2] }, + ], +}); + +export const simpleXYZ = toDataFrame({ + name: 'XYZ', + fields: [ + { name: 'X', values: [1, 2, 3] }, + { name: 'Y', values: [1, 2, 3] }, + { name: 'Z', values: [1, 2, 3] }, + ], +}); + +describe('Concat Transformer', () => { + it('dropping frame name', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.Drop }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": undefined, + "name": "A", + }, + Object { + "labels": undefined, + "name": "B", + }, + Object { + "labels": undefined, + "name": "C", + }, + Object { + "labels": undefined, + "name": "X", + }, + Object { + "labels": undefined, + "name": "Y", + }, + Object { + "labels": undefined, + "name": "Z", + }, + ] + `); + }); + + it('using field name', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { frameNameMode: ConcatenateFrameNameMode.FieldName }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": undefined, + "name": "ABC · A", + }, + Object { + "labels": undefined, + "name": "ABC · B", + }, + Object { + "labels": undefined, + "name": "ABC · C", + }, + Object { + "labels": undefined, + "name": "XYZ · X", + }, + Object { + "labels": undefined, + "name": "XYZ · Y", + }, + Object { + "labels": undefined, + "name": "XYZ · Z", + }, + ] + `); + }); + + it('using field label', () => { + const frame = concatenateFields([simpleABC, simpleXYZ], { + frameNameMode: ConcatenateFrameNameMode.Label, + frameNameLabel: 'sensor', + }); + expect(frame.length).toBe(3); + expect(frame.fields.map((f) => ({ name: f.name, labels: f.labels }))).toMatchInlineSnapshot(` + Array [ + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "A", + }, + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "B", + }, + Object { + "labels": Object { + "sensor": "ABC", + }, + "name": "C", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "X", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "Y", + }, + Object { + "labels": Object { + "sensor": "XYZ", + }, + "name": "Z", + }, + ] + `); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/concat.ts b/packages/grafana-data/src/transformations/transformers/concat.ts new file mode 100644 index 0000000..e7489d3 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/concat.ts @@ -0,0 +1,105 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field, TIME_SERIES_VALUE_FIELD_NAME } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector'; + +export enum ConcatenateFrameNameMode { + /** + * Ignore the source frame name when moving to the destination + */ + Drop = 'drop', + + /** + * Copy the source frame name to the destination field. The final field will contain + * both the frame and field name + */ + FieldName = 'field', + + /** + * Copy the source frame name to a label on the field. The label key is controlled + * by frameNameLabel + */ + Label = 'label', +} + +export interface ConcatenateTransformerOptions { + frameNameMode?: ConcatenateFrameNameMode; + frameNameLabel?: string; +} + +export const concatenateTransformer: DataTransformerInfo = { + id: DataTransformerID.concatenate, + name: 'Concatenate fields', + description: + 'Combine all fields into a single frame. Values will be appended with undefined values if not the same length.', + defaultOptions: { + frameNameMode: ConcatenateFrameNameMode.FieldName, + frameNameLabel: 'frame', + }, + operator: (options) => (source) => + source.pipe( + map((dataFrames) => { + if (!Array.isArray(dataFrames) || dataFrames.length < 2) { + return dataFrames; // noop with single frame + } + return [concatenateFields(dataFrames, options)]; + }) + ), +}; + +/** + * @internal only exported for tests + */ +export function concatenateFields(data: DataFrame[], opts: ConcatenateTransformerOptions): DataFrame { + let sameLength = true; + let maxLength = data[0].length; + const frameNameLabel = opts.frameNameLabel ?? 'frame'; + let fields: Field[] = []; + + for (const frame of data) { + if (maxLength !== frame.length) { + sameLength = false; + maxLength = Math.max(maxLength, frame.length); + } + + for (const f of frame.fields) { + const copy = { ...f }; + copy.state = undefined; + if (frame.name) { + if (opts.frameNameMode === ConcatenateFrameNameMode.Drop) { + // nothing -- skip the name + } else if (opts.frameNameMode === ConcatenateFrameNameMode.Label) { + copy.labels = { ...f.labels }; + copy.labels[frameNameLabel] = frame.name; + } else if (!copy.name || copy.name === TIME_SERIES_VALUE_FIELD_NAME) { + copy.name = frame.name; + } else { + copy.name = `${frame.name} · ${f.name}`; + } + } + fields.push(copy); + } + } + + // Make sure all fields have the same length + if (!sameLength) { + fields = fields.map((f) => { + if (f.values.length === maxLength) { + return f; + } + const values = f.values.toArray(); + values.length = maxLength; + return { + ...f, + values: new ArrayVector(values), + }; + }); + } + + return { + fields, + length: maxLength, + }; +} diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts new file mode 100644 index 0000000..7b7d349 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts @@ -0,0 +1,144 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { ensureColumnsTransformer } from './ensureColumns'; +import { seriesToColumnsTransformer } from './seriesToColumns'; + +const seriesA = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], +}); + +const seriesBC = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + ], +}); + +const seriesNoTime = toDataFrame({ + fields: [ + { name: 'B', type: FieldType.number, values: [2, 200] }, + { name: 'C', type: FieldType.number, values: [3, 300] }, + { name: 'D', type: FieldType.string, values: ['first', 'second'] }, + ], +}); + +describe('ensureColumns transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([ensureColumnsTransformer, seriesToColumnsTransformer]); + }); + + it('will transform to columns if time field exists and multiple frames', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesA, seriesBC]; + + await expect(transformDataFrame([cfg], data)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.length).toEqual(1); + + const frame = filtered[0]; + expect(frame.fields.length).toEqual(5); + expect(filtered[0]).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "name": "TheTime", + "state": Object { + "displayName": "TheTime", + }, + "type": "time", + "values": Array [ + 1000, + 2000, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "A", + "state": Object {}, + "type": "number", + "values": Array [ + 1, + 100, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "B", + "state": Object {}, + "type": "number", + "values": Array [ + 2, + 200, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "C", + "state": Object {}, + "type": "number", + "values": Array [ + 3, + 300, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "D", + "state": Object {}, + "type": "string", + "values": Array [ + "first", + "second", + ], + }, + ], + "length": 2, + "meta": Object { + "transformations": Array [ + "ensureColumns", + ], + }, + } + `); + }); + }); + + it('will not transform to columns if time field is missing for any of the series', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesBC, seriesNoTime]; + + await expect(transformDataFrame([cfg], data)).toEmitValues([data]); + }); + + it('will not transform to columns if only one series', async () => { + const cfg = { + id: DataTransformerID.ensureColumns, + options: {}, + }; + + const data = [seriesBC]; + + await expect(transformDataFrame([cfg], data)).toEmitValues([data]); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.ts new file mode 100644 index 0000000..b8068ce --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.ts @@ -0,0 +1,51 @@ +import { of } from 'rxjs'; + +import { seriesToColumnsTransformer } from './seriesToColumns'; +import { DataFrame } from '../../types/dataFrame'; +import { getTimeField } from '../../dataframe/processDataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataTransformerID } from './ids'; +import { mergeMap } from 'rxjs/operators'; + +export const ensureColumnsTransformer: DataTransformerInfo = { + id: DataTransformerID.ensureColumns, + name: 'Ensure Columns Transformer', + description: 'Will check if current data frames is series or columns. If in series it will convert to columns.', + operator: (options = {}) => (source) => + source.pipe( + mergeMap((data) => { + // Assume timeseries should first be joined by time + const timeFieldName = findConsistentTimeFieldName(data); + + if (data.length > 1 && timeFieldName) { + return of(data).pipe( + seriesToColumnsTransformer.operator({ + byField: timeFieldName, + }) + ); + } + + return of(data); + }) + ), +}; + +/** + * Find the name for the time field used in all frames (if one exists) + */ +function findConsistentTimeFieldName(data: DataFrame[]): string | undefined { + let name: string | undefined = undefined; + for (const frame of data) { + const { timeField } = getTimeField(frame); + if (!timeField) { + return undefined; // Not timeseries + } + if (!name) { + name = timeField.name; + } else if (name !== timeField.name) { + // Second frame has a different time column?! + return undefined; + } + } + return name; +} diff --git a/packages/grafana-data/src/transformations/transformers/filter.test.ts b/packages/grafana-data/src/transformations/transformers/filter.test.ts new file mode 100644 index 0000000..525acc9 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filter.test.ts @@ -0,0 +1,38 @@ +import { FieldType } from '../../types/dataFrame'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFieldsTransformer } from './filter'; +import { transformDataFrame } from '../transformDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'C', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('Filter Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFieldsTransformer]); + }); + + it('filters by include', async () => { + const cfg = { + id: DataTransformerID.filterFields, + options: { + include: { id: FieldMatcherID.numeric }, + }, + }; + + await expect(transformDataFrame([cfg], [simpleSeriesWithTypes])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('D'); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filter.ts b/packages/grafana-data/src/transformations/transformers/filter.ts new file mode 100644 index 0000000..4ab327f --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filter.ts @@ -0,0 +1,107 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { getFieldMatcher, getFrameMatchers } from '../matchers'; + +export interface FilterOptions { + include?: MatcherConfig; + exclude?: MatcherConfig; +} + +export const filterFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFields, + name: 'Filter Fields', + description: 'select a subset of fields', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options: FilterOptions) => (source) => { + if (!options.include && !options.exclude) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + const include = options.include ? getFieldMatcher(options.include) : null; + const exclude = options.exclude ? getFieldMatcher(options.exclude) : null; + + const processed: DataFrame[] = []; + for (const series of data) { + // Find the matching field indexes + const fields: Field[] = []; + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + + if (exclude) { + if (exclude(field, series, data)) { + continue; + } + if (!include) { + fields.push(field); + } + } + if (include && include(field, series, data)) { + fields.push(field); + } + } + + if (!fields.length) { + continue; + } + const copy = { + ...series, // all the other properties + fields, // but a different set of fields + }; + processed.push(copy); + } + return processed; + }) + ); + }, +}; + +export const filterFramesTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFrames, + name: 'Filter Frames', + description: 'select a subset of frames', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => { + if (!options.include && !options.exclude) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + const include = options.include ? getFrameMatchers(options.include) : null; + const exclude = options.exclude ? getFrameMatchers(options.exclude) : null; + + const processed: DataFrame[] = []; + for (const series of data) { + if (exclude) { + if (exclude(series)) { + continue; + } + if (!include) { + processed.push(series); + } + } + if (include && include(series)) { + processed.push(series); + } + } + return processed; + }) + ); + }, +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.test.ts b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts new file mode 100644 index 0000000..9d4f06c --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts @@ -0,0 +1,198 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFieldsByNameTransformer } from './filterByName'; +import { filterFieldsTransformer } from './filter'; +import { transformDataFrame } from '../transformDataFrame'; + +export const seriesWithNamesToMatch = toDataFrame({ + fields: [ + { name: 'startsWithA', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'startsWithC', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('filterByName transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFieldsByNameTransformer, filterFieldsTransformer]); + }); + + it('returns original series if no options provided', async () => { + const cfg = { + id: DataTransformerID.filterFields, + options: {}, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(4); + }); + }); + + describe('respects', () => { + it('inclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + pattern: '/^(startsWith)/', + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + pattern: '/^(startsWith)/', + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by pattern', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { pattern: '/^(startsWith)/' }, + include: { pattern: '/^(B)$/' }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + names: ['startsWithA', 'startsWithC'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + names: ['startsWithA', 'startsWithC'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by names', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { names: ['startsWithA', 'startsWithC'] }, + include: { names: ['B'] }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + include: { + pattern: '/^(startsWith)/', + names: ['startsWithA'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('startsWithA'); + }); + }); + + it('exclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { + pattern: '/^(startsWith)/', + names: ['startsWithA'], + }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(2); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + + it('inclusion and exclusion by both', async () => { + const cfg = { + id: DataTransformerID.filterFieldsByName, + options: { + exclude: { names: ['startsWithA', 'startsWithC'] }, + include: { pattern: '/^(B)$/' }, + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithNamesToMatch])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('B'); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByName.ts b/packages/grafana-data/src/transformations/transformers/filterByName.ts new file mode 100644 index 0000000..f6f58cb --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByName.ts @@ -0,0 +1,51 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; +import { filterFieldsTransformer } from './filter'; +import { RegexpOrNamesMatcherOptions } from '../matchers/nameMatcher'; + +export interface FilterFieldsByNameTransformerOptions { + include?: RegexpOrNamesMatcherOptions; + exclude?: RegexpOrNamesMatcherOptions; +} + +export const filterFieldsByNameTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFieldsByName, + name: 'Filter fields by name', + description: 'select a subset of fields', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + filterFieldsTransformer.operator({ + include: getMatcherConfig(options.include), + exclude: getMatcherConfig(options.exclude), + }) + ), +}; + +const getMatcherConfig = (options?: RegexpOrNamesMatcherOptions): MatcherConfig | undefined => { + if (!options) { + return undefined; + } + + const { names, pattern } = options; + + if ((!Array.isArray(names) || names.length === 0) && !pattern) { + return undefined; + } + + if (!pattern) { + return { id: FieldMatcherID.byNames, options: { names } }; + } + + if (!Array.isArray(names) || names.length === 0) { + return { id: FieldMatcherID.byRegexp, options: pattern }; + } + + return { id: FieldMatcherID.byRegexpOrNames, options }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts b/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts new file mode 100644 index 0000000..a989e28 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByRefId.test.ts @@ -0,0 +1,54 @@ +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { filterFramesByRefIdTransformer } from './filterByRefId'; +import { transformDataFrame } from '../transformDataFrame'; + +export const allSeries = [ + toDataFrame({ + refId: 'A', + fields: [], + }), + toDataFrame({ + refId: 'B', + fields: [], + }), + toDataFrame({ + refId: 'C', + fields: [], + }), +]; + +describe('filterByRefId transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterFramesByRefIdTransformer]); + }); + + it('returns all series if no options provided', async () => { + const cfg = { + id: DataTransformerID.filterByRefId, + options: {}, + }; + + await expect(transformDataFrame([cfg], allSeries)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.length).toBe(3); + }); + }); + + describe('respects', () => { + it('inclusion', async () => { + const cfg = { + id: DataTransformerID.filterByRefId, + options: { + include: 'A|B', + }, + }; + + await expect(transformDataFrame([cfg], allSeries)).toEmitValuesWith((received) => { + const filtered = received[0]; + expect(filtered.map((f) => f.refId)).toEqual(['A', 'B']); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByRefId.ts b/packages/grafana-data/src/transformations/transformers/filterByRefId.ts new file mode 100644 index 0000000..25b7b09 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByRefId.ts @@ -0,0 +1,38 @@ +import { DataTransformerID } from './ids'; +import { filterFramesTransformer, FilterOptions } from './filter'; +import { DataTransformerInfo } from '../../types/transformations'; +import { FrameMatcherID } from '../matchers/ids'; + +export interface FilterFramesByRefIdTransformerOptions { + include?: string; + exclude?: string; +} + +export const filterFramesByRefIdTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByRefId, + name: 'Filter data by query refId', + description: 'select a subset of results', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => { + const filterOptions: FilterOptions = {}; + if (options.include) { + filterOptions.include = { + id: FrameMatcherID.byRefId, + options: options.include, + }; + } + if (options.exclude) { + filterOptions.exclude = { + id: FrameMatcherID.byRefId, + options: options.exclude, + }; + } + + return source.pipe(filterFramesTransformer.operator(filterOptions)); + }, +}; diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts new file mode 100644 index 0000000..19a5a92 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.test.ts @@ -0,0 +1,219 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, FieldType, MatcherConfig } from '../../types'; +import { ArrayVector } from '../../vector'; +import { transformDataFrame } from '../transformDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { + FilterByValueMatch, + filterByValueTransformer, + FilterByValueTransformerOptions, + FilterByValueType, +} from './filterByValue'; +import { DataTransformerID } from './ids'; +import { ValueMatcherID } from '../matchers/ids'; +import { BasicValueMatcherOptions } from '../matchers/valueMatchers/types'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + length: 7, + fields: [ + { name: 'time', type: FieldType.time, values: new ArrayVector([1000, 2000, 3000, 4000, 5000, 6000, 7000]) }, + { name: 'numbers', type: FieldType.number, values: new ArrayVector([1, 2, 3, 4, 5, 6, 7]) }, + ], +}); + +describe('FilterByValue transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([filterByValueTransformer]); + }); + + it('should exclude values', async () => { + const lower: MatcherConfig> = { + id: ValueMatcherID.lower, + options: { value: 6 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.exclude, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lower, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([6000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([6, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should include values', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match any condition', async () => { + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 4 }, + }; + + const equal: MatcherConfig> = { + id: ValueMatcherID.equal, + options: { value: 7 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.any, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: equal, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([1000, 2000, 3000, 4000, 7000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([1, 2, 3, 4, 7]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); + + it('should match all condition', async () => { + const greaterOrEqual: MatcherConfig> = { + id: ValueMatcherID.greaterOrEqual, + options: { value: 4 }, + }; + + const lowerOrEqual: MatcherConfig> = { + id: ValueMatcherID.lowerOrEqual, + options: { value: 5 }, + }; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.filterByValue, + options: { + type: FilterByValueType.include, + match: FilterByValueMatch.all, + filters: [ + { + fieldName: 'numbers', + config: lowerOrEqual, + }, + { + fieldName: 'numbers', + config: greaterOrEqual, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + + expect(processed.length).toEqual(1); + expect(processed[0].fields).toEqual([ + { + name: 'time', + type: FieldType.time, + values: new ArrayVector([4000, 5000]), + state: { displayName: 'time' }, + config: {}, + }, + { + name: 'numbers', + type: FieldType.number, + values: new ArrayVector([4, 5]), + state: { displayName: 'numbers' }, + config: {}, + }, + ]); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/filterByValue.ts b/packages/grafana-data/src/transformations/transformers/filterByValue.ts new file mode 100644 index 0000000..335a80f --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/filterByValue.ts @@ -0,0 +1,159 @@ +import { map } from 'rxjs/operators'; + +import { noopTransformer } from './noop'; +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { getValueMatcher } from '../matchers'; +import { ArrayVector } from '../../vector/ArrayVector'; + +export enum FilterByValueType { + exclude = 'exclude', + include = 'include', +} + +export enum FilterByValueMatch { + all = 'all', + any = 'any', +} + +export interface FilterByValueFilter { + fieldName: string; + config: MatcherConfig; +} + +export interface FilterByValueTransformerOptions { + filters: FilterByValueFilter[]; + type: FilterByValueType; + match: FilterByValueMatch; +} + +export const filterByValueTransformer: DataTransformerInfo = { + id: DataTransformerID.filterByValue, + name: 'Filter data by values', + description: 'select a subset of results based on values', + defaultOptions: { + filters: [], + type: FilterByValueType.include, + match: FilterByValueMatch.any, + }, + + operator: (options) => (source) => { + const filters = options.filters; + const matchAll = options.match === FilterByValueMatch.all; + const include = options.type === FilterByValueType.include; + + if (!Array.isArray(filters) || filters.length === 0) { + return source.pipe(noopTransformer.operator({})); + } + + return source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + const rows = new Set(); + + for (const frame of data) { + const fieldIndexByName = groupFieldIndexByName(frame, data); + const matchers = createFilterValueMatchers(filters, fieldIndexByName); + + for (let index = 0; index < frame.length; index++) { + if (rows.has(index)) { + continue; + } + + let matching = true; + + for (const matcher of matchers) { + const match = matcher(index, frame, data); + + if (!matchAll && match) { + matching = true; + break; + } + + if (matchAll && !match) { + matching = false; + break; + } + + matching = match; + } + + if (matching) { + rows.add(index); + } + } + } + + const processed: DataFrame[] = []; + const frameLength = include ? rows.size : data[0].length - rows.size; + + for (const frame of data) { + const fields: Field[] = []; + + for (const field of frame.fields) { + const buffer = []; + + for (let index = 0; index < frame.length; index++) { + if (include && rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + + if (!include && !rows.has(index)) { + buffer.push(field.values.get(index)); + continue; + } + } + + // TODO: what parts needs to be excluded from field. + fields.push({ + ...field, + values: new ArrayVector(buffer), + config: {}, + }); + } + + processed.push({ + ...frame, + fields: fields, + length: frameLength, + }); + } + + return processed; + }) + ); + }, +}; + +const createFilterValueMatchers = ( + filters: FilterByValueFilter[], + fieldIndexByName: Record +): Array<(index: number, frame: DataFrame, data: DataFrame[]) => boolean> => { + const noop = () => false; + + return filters.map((filter) => { + const fieldIndex = fieldIndexByName[filter.fieldName] ?? -1; + + if (fieldIndex < 0) { + console.warn(`[FilterByValue] Could not find index for field name: ${filter.fieldName}`); + return noop; + } + + const matcher = getValueMatcher(filter.config); + return (index, frame, data) => matcher(index, frame.fields[fieldIndex], frame, data); + }); +}; + +const groupFieldIndexByName = (frame: DataFrame, data: DataFrame[]): Record => { + return frame.fields.reduce((all: Record, field, fieldIndex) => { + const fieldName = getFieldDisplayName(field, frame, data); + all[fieldName] = fieldIndex; + return all; + }, {}); +}; diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.test.ts b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts new file mode 100644 index 0000000..337de2f --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts @@ -0,0 +1,263 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { GroupByOperationID, groupByTransformer, GroupByTransformerOptions } from './groupBy'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { ArrayVector } from '../../vector'; +import { ReducerID } from '../fieldReducer'; +import { DataTransformerConfig } from '@grafana/data'; + +describe('GroupBy transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([groupByTransformer]); + }); + + it('should not apply transformation if config is missing group by fields', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.count], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0]).toBe(testSeries); + }); + }); + + it('should group values by message', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group values by message and summarize values', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group by and compute a few calculations for each group of values', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }); + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + time: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.count, ReducerID.last], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'time (count)', + type: FieldType.number, + values: new ArrayVector([1, 2, 3]), + config: {}, + }, + { + name: 'time (last)', + type: FieldType.time, + values: new ArrayVector([3000, 5000, 8000]), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expected); + }); + }); + + it('should group values in data frames individually', async () => { + const testSeries = [ + toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [1, 2, 2, 3, 3, 3] }, + ], + }), + toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] }, + { name: 'message', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] }, + { name: 'values', type: FieldType.string, values: [0, 2, 5, 3, 3, 2] }, + ], + }), + ]; + + const cfg: DataTransformerConfig = { + id: DataTransformerID.groupBy, + options: { + fields: { + message: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + operation: GroupByOperationID.aggregate, + aggregations: [ReducerID.sum], + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], testSeries)).toEmitValuesWith((received) => { + const result = received[0]; + const expectedA: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([1, 4, 9]), + config: {}, + }, + ]; + + const expectedB: Field[] = [ + { + name: 'message', + type: FieldType.string, + values: new ArrayVector(['one', 'two', 'three']), + config: {}, + }, + { + name: 'values (sum)', + type: FieldType.number, + values: new ArrayVector([0, 7, 8]), + config: {}, + }, + ]; + + expect(result[0].fields).toEqual(expectedA); + expect(result[1].fields).toEqual(expectedB); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts new file mode 100644 index 0000000..ae70dba --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -0,0 +1,188 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { reduceField, ReducerID } from '../fieldReducer'; +import { MutableField } from '../../dataframe/MutableDataFrame'; + +export enum GroupByOperationID { + aggregate = 'aggregate', + groupBy = 'groupby', +} + +export interface GroupByFieldOptions { + aggregations: ReducerID[]; + operation: GroupByOperationID | null; +} + +export interface GroupByTransformerOptions { + fields: Record; +} + +export const groupByTransformer: DataTransformerInfo = { + id: DataTransformerID.groupBy, + name: 'Group by', + description: 'Group the data by a field values then process calculations for each group', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const hasValidConfig = Object.keys(options.fields).find( + (name) => options.fields[name].operation === GroupByOperationID.groupBy + ); + + if (!hasValidConfig) { + return data; + } + + const processed: DataFrame[] = []; + + for (const frame of data) { + const groupByFields: Field[] = []; + + for (const field of frame.fields) { + if (shouldGroupOnField(field, options)) { + groupByFields.push(field); + } + } + + if (groupByFields.length === 0) { + continue; // No group by field in this frame, ignore the frame + } + + // Group the values by fields and groups so we can get all values for a + // group for a given field. + const valuesByGroupKey: Record> = {}; + for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { + const groupKey = String(groupByFields.map((field) => field.values.get(rowIndex))); + const valuesByField = valuesByGroupKey[groupKey] ?? {}; + + if (!valuesByGroupKey[groupKey]) { + valuesByGroupKey[groupKey] = valuesByField; + } + + for (let field of frame.fields) { + const fieldName = getFieldDisplayName(field); + + if (!valuesByField[fieldName]) { + valuesByField[fieldName] = { + name: fieldName, + type: field.type, + config: { ...field.config }, + values: new ArrayVector(), + }; + } + + valuesByField[fieldName].values.add(field.values.get(rowIndex)); + } + } + + const fields: Field[] = []; + const groupKeys = Object.keys(valuesByGroupKey); + + for (const field of groupByFields) { + const values = new ArrayVector(); + const fieldName = getFieldDisplayName(field); + + for (let key of groupKeys) { + const valuesByField = valuesByGroupKey[key]; + values.add(valuesByField[fieldName].values.get(0)); + } + + fields.push({ + name: field.name, + type: field.type, + config: { + ...field.config, + }, + values: values, + }); + } + + // Then for each calculations configured, compute and add a new field (column) + for (const field of frame.fields) { + if (!shouldCalculateField(field, options)) { + continue; + } + + const fieldName = getFieldDisplayName(field); + const aggregations = options.fields[fieldName].aggregations; + const valuesByAggregation: Record = {}; + + for (const groupKey of groupKeys) { + const fieldWithValuesForGroup = valuesByGroupKey[groupKey][fieldName]; + const results = reduceField({ + field: fieldWithValuesForGroup, + reducers: aggregations, + }); + + for (const aggregation of aggregations) { + if (!Array.isArray(valuesByAggregation[aggregation])) { + valuesByAggregation[aggregation] = []; + } + valuesByAggregation[aggregation].push(results[aggregation]); + } + } + + for (const aggregation of aggregations) { + const aggregationField: Field = { + name: `${fieldName} (${aggregation})`, + values: new ArrayVector(valuesByAggregation[aggregation]), + type: FieldType.other, + config: {}, + }; + + aggregationField.type = detectFieldType(aggregation, field, aggregationField); + fields.push(aggregationField); + } + } + + processed.push({ + fields, + length: groupKeys.length, + }); + } + + return processed; + }) + ), +}; + +const shouldGroupOnField = (field: Field, options: GroupByTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return options?.fields[fieldName]?.operation === GroupByOperationID.groupBy; +}; + +const shouldCalculateField = (field: Field, options: GroupByTransformerOptions): boolean => { + const fieldName = getFieldDisplayName(field); + return ( + options?.fields[fieldName]?.operation === GroupByOperationID.aggregate && + Array.isArray(options?.fields[fieldName].aggregations) && + options?.fields[fieldName].aggregations.length > 0 + ); +}; + +const detectFieldType = (aggregation: string, sourceField: Field, targetField: Field): FieldType => { + switch (aggregation) { + case ReducerID.allIsNull: + return FieldType.boolean; + case ReducerID.last: + case ReducerID.lastNotNull: + case ReducerID.first: + case ReducerID.firstNotNull: + return sourceField.type; + default: + return guessFieldTypeForField(targetField) ?? FieldType.string; + } +}; diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts new file mode 100644 index 0000000..1aaf577 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -0,0 +1,25 @@ +export enum DataTransformerID { + // join = 'join', // Pick a field and merge all series based on that field + append = 'append', + // rotate = 'rotate', // Columns to rows + reduce = 'reduce', + order = 'order', + organize = 'organize', + rename = 'rename', + calculateField = 'calculateField', + seriesToColumns = 'seriesToColumns', + seriesToRows = 'seriesToRows', + merge = 'merge', + concatenate = 'concatenate', + labelsToFields = 'labelsToFields', + filterFields = 'filterFields', + filterFieldsByName = 'filterFieldsByName', + filterFrames = 'filterFrames', + filterByRefId = 'filterByRefId', + renameByRegex = 'renameByRegex', + filterByValue = 'filterByValue', + noop = 'noop', + ensureColumns = 'ensureColumns', + groupBy = 'groupBy', + sortBy = 'sortBy', +} diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts new file mode 100644 index 0000000..5f6a5a1 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts @@ -0,0 +1,297 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { ArrayVector } from '../../vector'; +import { calculateFieldTransformer } from './calculateField'; +import { isLikelyAscendingVector, outerJoinDataFrames } from './joinDataFrames'; + +describe('align frames', () => { + beforeAll(() => { + mockTransformationsRegistry([calculateFieldTransformer]); + }); + + it('by first time field', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], + }); + + const series2 = toDataFrame({ + fields: [ + { name: '_time', type: FieldType.time, values: [1000, 1500, 2000] }, + { name: 'A', type: FieldType.number, values: [2, 20, 200] }, + { name: 'B', type: FieldType.number, values: [3, 30, 300] }, + { name: 'C', type: FieldType.string, values: ['first', 'second', 'third'] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1, series2] })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1000, + 1500, + 2000, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + undefined, + 100, + ], + }, + Object { + "name": "A", + "values": Array [ + 2, + 20, + 200, + ], + }, + Object { + "name": "B", + "values": Array [ + 3, + 30, + 300, + ], + }, + Object { + "name": "C", + "values": Array [ + "first", + "second", + "third", + ], + }, + ] + `); + }); + + it('unsorted input keep indexes', () => { + //---------- + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000, 1500] }, + { name: 'A1', type: FieldType.number, values: [1, 2, 15] }, + ], + }); + + const series3 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [2000, 1000] }, + { name: 'A2', type: FieldType.number, values: [2, 1] }, + ], + }); + + let out = outerJoinDataFrames({ frames: [series1, series3], keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + state: f.state, + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "state": Object { + "origin": Object { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "values": Array [ + 1000, + 1500, + 2000, + ], + }, + Object { + "name": "A1", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "values": Array [ + 1, + 15, + 2, + ], + }, + Object { + "name": "A2", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "values": Array [ + 1, + undefined, + 2, + ], + }, + ] + `); + + // Fast path still adds origin indecies + out = outerJoinDataFrames({ frames: [series1], keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + state: f.state, + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "state": Object { + "origin": Object { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + }, + Object { + "name": "A1", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + }, + ] + `); + }); + + it('sort single frame', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [6000, 2000, 1500] }, + { name: 'A1', type: FieldType.number, values: [1, 22, 15] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1], enforceSort: true, keepOriginIndices: true })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1500, + 2000, + 6000, + ], + }, + Object { + "name": "A1", + "values": Array [ + 15, + 22, + 1, + ], + }, + ] + `); + }); + + it('supports duplicate times', () => { + //---------- + // NOTE!!! + // * ideally we would *keep* dupicate fields + //---------- + const series1 = toDataFrame({ + fields: [ + { name: 'TheTime', type: FieldType.time, values: [1000, 2000] }, + { name: 'A', type: FieldType.number, values: [1, 100] }, + ], + }); + + const series3 = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1000, 1000, 1000] }, + { name: 'A', type: FieldType.number, values: [2, 20, 200] }, + ], + }); + + const out = outerJoinDataFrames({ frames: [series1, series3] })!; + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "TheTime", + "values": Array [ + 1000, + 2000, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + 100, + ], + }, + Object { + "name": "A", + "values": Array [ + 200, + undefined, + ], + }, + ] + `); + }); + + describe('check ascending data', () => { + it('simple ascending', () => { + const v = new ArrayVector([1, 2, 3, 4, 5]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + }); + it('simple ascending with null', () => { + const v = new ArrayVector([null, 2, 3, 4, null]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + }); + it('single value', () => { + const v = new ArrayVector([null, null, null, 4, null]); + expect(isLikelyAscendingVector(v)).toBeTruthy(); + expect(isLikelyAscendingVector(new ArrayVector([4]))).toBeTruthy(); + expect(isLikelyAscendingVector(new ArrayVector([]))).toBeTruthy(); + }); + + it('middle values', () => { + const v = new ArrayVector([null, null, 5, 4, null]); + expect(isLikelyAscendingVector(v)).toBeFalsy(); + }); + + it('decending', () => { + expect(isLikelyAscendingVector(new ArrayVector([7, 6, null]))).toBeFalsy(); + expect(isLikelyAscendingVector(new ArrayVector([7, 8, 6]))).toBeFalsy(); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts new file mode 100644 index 0000000..f5966b7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -0,0 +1,318 @@ +import { DataFrame, Field, FieldMatcher, FieldType, Vector } from '../../types'; +import { ArrayVector } from '../../vector'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; +import { getTimeField, sortDataFrame } from '../../dataframe'; + +export function pickBestJoinField(data: DataFrame[]): FieldMatcher { + const { timeField } = getTimeField(data[0]); + if (timeField) { + return fieldMatchers.get(FieldMatcherID.firstTimeField).get({}); + } + let common: string[] = []; + for (const f of data[0].fields) { + if (f.type === FieldType.number) { + common.push(f.name); + } + } + + for (let i = 1; i < data.length; i++) { + const names: string[] = []; + for (const f of data[0].fields) { + if (f.type === FieldType.number) { + names.push(f.name); + } + } + common = common.filter((v) => !names.includes(v)); + } + + return fieldMatchers.get(FieldMatcherID.byName).get(common[0]); +} + +/** + * @alpha + */ +export interface JoinOptions { + /** + * The input fields + */ + frames: DataFrame[]; + + /** + * The field to join -- frames that do not have this field will be droppped + */ + joinBy?: FieldMatcher; + + /** + * Optionally filter the non-join fields + */ + keep?: FieldMatcher; + + /** + * When the result is a single frame, this will to a quick check to see if the values are sorted, + * and sort if necessary. If the first/last values are in order the whole vector is assumed to be + * sorted + */ + enforceSort?: boolean; + + /** + * @internal -- used when we need to keep a reference to the original frame/field index + */ + keepOriginIndices?: boolean; +} + +function getJoinMatcher(options: JoinOptions): FieldMatcher { + return options.joinBy ?? pickBestJoinField(options.frames); +} + +/** + * This will return a single frame joined by the first matching field. When a join field is not specified, + * the default will use the first time field + */ +export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined { + if (!options.frames?.length) { + return undefined; + } + + if (options.frames.length === 1) { + let frame = options.frames[0]; + if (options.keepOriginIndices) { + frame = { + ...frame, + fields: frame.fields.map((f, fieldIndex) => { + const copy = { ...f }; + const origin = { + frameIndex: 0, + fieldIndex, + }; + if (copy.state) { + copy.state.origin = origin; + } else { + copy.state = { origin }; + } + return copy; + }), + }; + } + if (options.enforceSort) { + const joinFieldMatcher = getJoinMatcher(options); + const joinIndex = frame.fields.findIndex((f) => joinFieldMatcher(f, frame, options.frames)); + if (joinIndex >= 0) { + if (!isLikelyAscendingVector(frame.fields[joinIndex].values)) { + return sortDataFrame(frame, joinIndex); + } + } + } + return frame; + } + + const nullModes: JoinNullMode[][] = []; + const allData: AlignedData[] = []; + const originalFields: Field[] = []; + const joinFieldMatcher = getJoinMatcher(options); + + for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) { + const frame = options.frames[frameIndex]; + + if (!frame || !frame.fields?.length) { + continue; // skip the frame + } + + const nullModesFrame: JoinNullMode[] = [NULL_REMOVE]; + let join: Field | undefined = undefined; + let fields: Field[] = []; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + field.state = field.state || {}; + + if (!join && joinFieldMatcher(field, frame, options.frames)) { + join = field; + } else { + if (options.keep && !options.keep(field, frame, options.frames)) { + continue; // skip field + } + + // Support the standard graph span nulls field config + nullModesFrame.push(field.config.custom?.spanNulls ? NULL_REMOVE : NULL_EXPAND); + + let labels = field.labels ?? {}; + if (frame.name) { + labels = { ...labels, name: frame.name }; + } + + fields.push({ + ...field, + labels, // add the name label from frame + }); + } + + if (options.keepOriginIndices) { + field.state.origin = { + frameIndex, + fieldIndex, + }; + } + } + + if (!join) { + continue; // skip the frame + } + + if (originalFields.length === 0) { + originalFields.push(join); // first join field + } + + nullModes.push(nullModesFrame); + const a: AlignedData = [join.values.toArray()]; // + + for (const field of fields) { + a.push(field.values.toArray()); + originalFields.push(field); + // clear field displayName state + delete field.state?.displayName; + } + + allData.push(a); + } + + const joined = join(allData, nullModes); + return { + // ...options.data[0], // keep name, meta? + length: joined[0].length, + fields: originalFields.map((f, index) => ({ + ...f, + values: new ArrayVector(joined[index]), + })), + }; +} + +//-------------------------------------------------------------------------------- +// Below here is copied from uplot (MIT License) +// https://github.com/leeoniya/uPlot/blob/master/src/utils.js#L325 +// This avoids needing to import uplot into the data package +//-------------------------------------------------------------------------------- + +// Copied from uplot +type AlignedData = [number[], ...Array>]; + +// nullModes +const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true) +const NULL_RETAIN = 1; // nulls are retained, with alignment artifacts set to undefined (default) +const NULL_EXPAND = 2; // nulls are expanded to include any adjacent alignment artifacts + +type JoinNullMode = number; // NULL_IGNORE | NULL_RETAIN | NULL_EXPAND; + +// sets undefined values to nulls when adjacent to existing nulls (minesweeper) +function nullExpand(yVals: Array, nullIdxs: number[], alignedLen: number) { + for (let i = 0, xi, lastNullIdx = -1; i < nullIdxs.length; i++) { + let nullIdx = nullIdxs[i]; + + if (nullIdx > lastNullIdx) { + xi = nullIdx - 1; + while (xi >= 0 && yVals[xi] == null) { + yVals[xi--] = null; + } + + xi = nullIdx + 1; + while (xi < alignedLen && yVals[xi] == null) { + yVals[(lastNullIdx = xi++)] = null; + } + } + } +} + +// nullModes is a tables-matched array indicating how to treat nulls in each series +function join(tables: AlignedData[], nullModes: number[][]) { + const xVals = new Set(); + + for (let ti = 0; ti < tables.length; ti++) { + let t = tables[ti]; + let xs = t[0]; + let len = xs.length; + + for (let i = 0; i < len; i++) { + xVals.add(xs[i]); + } + } + + let data = [Array.from(xVals).sort((a, b) => a - b)]; + + let alignedLen = data[0].length; + + let xIdxs = new Map(); + + for (let i = 0; i < alignedLen; i++) { + xIdxs.set(data[0][i], i); + } + + for (let ti = 0; ti < tables.length; ti++) { + let t = tables[ti]; + let xs = t[0]; + + for (let si = 1; si < t.length; si++) { + let ys = t[si]; + + let yVals = Array(alignedLen).fill(undefined); + + let nullMode = nullModes ? nullModes[ti][si] : NULL_RETAIN; + + let nullIdxs = []; + + for (let i = 0; i < ys.length; i++) { + let yVal = ys[i]; + let alignedIdx = xIdxs.get(xs[i]); + + if (yVal == null) { + if (nullMode !== NULL_REMOVE) { + yVals[alignedIdx] = yVal; + + if (nullMode === NULL_EXPAND) { + nullIdxs.push(alignedIdx); + } + } + } else { + yVals[alignedIdx] = yVal; + } + } + + nullExpand(yVals, nullIdxs, alignedLen); + + data.push(yVals); + } + } + + return data; +} + +// Quick test if the first and last points look to be ascending +// Only exported for tests +export function isLikelyAscendingVector(data: Vector): boolean { + let first: any = undefined; + + for (let idx = 0; idx < data.length; idx++) { + const v = data.get(idx); + if (v != null) { + if (first != null) { + if (first > v) { + return false; // descending + } + break; + } + first = v; + } + } + + let idx = data.length - 1; + while (idx >= 0) { + const v = data.get(idx--); + if (v != null) { + if (first > v) { + return false; + } + return true; + } + } + + return true; // only one non-null point +} diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts new file mode 100644 index 0000000..1f82865 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.test.ts @@ -0,0 +1,124 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { LabelsToFieldsOptions, labelsToFieldsTransformer } from './labelsToFields'; +import { DataTransformerConfig, FieldDTO, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame, toDataFrameDTO } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; + +describe('Labels as Columns', () => { + beforeAll(() => { + mockTransformationsRegistry([labelsToFieldsTransformer]); + }); + + it('data frame with two labels', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'Value', type: FieldType.number, values: [1, 2], labels: { location: 'inside', feelsLike: 'ok' } }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { + name: 'location', + type: FieldType.string, + values: ['inside', 'inside'], + config: {}, + }, + { name: 'feelsLike', type: FieldType.string, values: ['ok', 'ok'], config: {} }, + { name: 'Value', type: FieldType.number, values: [1, 2], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); + + it('data frame with two labels and valueLabel option', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: { valueLabel: 'name' }, + }; + + const source = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { + name: 'Value', + type: FieldType.number, + values: [1, 2], + labels: { location: 'inside', name: 'Request' }, + config: { + displayName: 'Custom1', + displayNameFromDS: 'Custom2', + }, + }, + ], + }); + + await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { + name: 'location', + type: FieldType.string, + values: ['inside', 'inside'], + config: {}, + }, + { name: 'Request', type: FieldType.number, values: [1, 2], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); + + it('two data frames with 1 value and 1 label', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.labelsToFields, + options: {}, + }; + + const oneValueOneLabelA = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'temp', type: FieldType.number, values: [1], labels: { location: 'inside' } }, + ], + }); + + const oneValueOneLabelB = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [2000] }, + { name: 'temp', type: FieldType.number, values: [-1], labels: { location: 'outside' } }, + ], + }); + + await expect(transformDataFrame([cfg], [oneValueOneLabelA, oneValueOneLabelB])).toEmitValuesWith((received) => { + const data = received[0]; + const result = toDataFrameDTO(data[0]); + + const expected: FieldDTO[] = [ + { name: 'time', type: FieldType.time, values: [1000, 2000], config: {} }, + { name: 'location', type: FieldType.string, values: ['inside', 'outside'], config: {} }, + { name: 'temp', type: FieldType.number, values: [1, -1], config: {} }, + ]; + + expect(result.fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/labelsToFields.ts b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts new file mode 100644 index 0000000..67b718d --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/labelsToFields.ts @@ -0,0 +1,76 @@ +import { map } from 'rxjs/operators'; + +import { DataFrame, DataTransformerInfo, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { ArrayVector } from '../../vector'; +import { mergeTransformer } from './merge'; + +export interface LabelsToFieldsOptions { + /* + * If set this will use this label's value as the value field name. + */ + valueLabel?: string; +} + +export const labelsToFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.labelsToFields, + name: 'Labels to fields', + description: 'Extract time series labels to fields (columns)', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((data) => { + const result: DataFrame[] = []; + + for (const frame of data) { + const newFields: Field[] = []; + + for (const field of frame.fields) { + if (!field.labels) { + newFields.push(field); + continue; + } + + let name = field.name; + + for (const labelName of Object.keys(field.labels)) { + // if we should use this label as the value field name store it and skip adding this as a separate field + if (options.valueLabel === labelName) { + name = field.labels[labelName]; + continue; + } + + const values = new Array(frame.length).fill(field.labels[labelName]); + newFields.push({ + name: labelName, + type: FieldType.string, + values: new ArrayVector(values), + config: {}, + }); + } + + // add the value field but clear out any labels or displayName + newFields.push({ + ...field, + name, + config: { + ...field.config, + // we need to clear thes for this transform as these can contain label names that we no longer want + displayName: undefined, + displayNameFromDS: undefined, + }, + labels: undefined, + }); + } + + result.push({ + fields: newFields, + length: frame.length, + }); + } + + return result; + }), + mergeTransformer.operator({}) + ), +}; diff --git a/packages/grafana-data/src/transformations/transformers/merge.test.ts b/packages/grafana-data/src/transformations/transformers/merge.test.ts new file mode 100644 index 0000000..a56c446 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/merge.test.ts @@ -0,0 +1,587 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, DisplayProcessor, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; +import { ArrayVector } from '../../vector'; +import { mergeTransformer, MergeTransformerOptions } from './merge'; + +describe('Merge multiple to single', () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.merge, + options: {}, + }; + + beforeAll(() => { + mockTransformationsRegistry([mergeTransformer]); + }); + + it('combine two series into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 2000]), + createField('Temp', FieldType.number, [1, -1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two series with multiple values into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three series into one', async () => { + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const seriesC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB, seriesC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 2000, 500]), + createField('Temp', FieldType.number, [1, -1, 2]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine one serie and two tables into one table', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + { name: 'Humidity', type: FieldType.number, values: [10] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const tableB = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + { name: 'Humidity', type: FieldType.number, values: [5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, seriesB, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [1000, 1000, 500]), + createField('Temp', FieldType.number, [1, -1, 2]), + createField('Humidity', FieldType.number, [10, undefined, 5]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine one serie and two tables with ISO dates into one table', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-10-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + { name: 'Humidity', type: FieldType.number, values: [10] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-09-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const tableC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: ['2019-11-01T11:10:23Z'] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + { name: 'Humidity', type: FieldType.number, values: [5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, seriesB, tableC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, ['2019-10-01T11:10:23Z', '2019-09-01T11:10:23Z', '2019-11-01T11:10:23Z']), + createField('Temp', FieldType.number, [1, -1, 2]), + createField('Humidity', FieldType.number, [10, undefined, 5]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two tables, where first is partial overlapping, into one', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { + name: 'Country', + type: FieldType.string, + values: ['United States', 'United States', 'Mexico', 'Germany', 'Canada', 'Canada'], + }, + { + name: 'AgeGroup', + type: FieldType.string, + values: ['50 or over', '35 - 49', '0 - 17', '35 - 49', '35 - 49', '25 - 34'], + }, + { name: 'Sum', type: FieldType.number, values: [998, 1193, 1675, 146, 166, 219] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { name: 'AgeGroup', type: FieldType.string, values: ['0 - 17', '18 - 24', '25 - 34', '35 - 49', '50 or over'] }, + { name: 'Count', type: FieldType.number, values: [1, 3, 2, 4, 2] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Country', FieldType.string, [ + 'United States', + 'United States', + 'Mexico', + 'Germany', + 'Canada', + 'Canada', + undefined, + ]), + createField('AgeGroup', FieldType.string, [ + '50 or over', + '35 - 49', + '0 - 17', + '35 - 49', + '35 - 49', + '25 - 34', + '18 - 24', + ]), + createField('Sum', FieldType.number, [998, 1193, 1675, 146, 166, 219, undefined]), + createField('Count', FieldType.number, [2, 4, 1, 4, 4, 2, 3]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two tables, where second is partial overlapping, into one', async () => { + /** + * This behavior feels wrong. I would expect the same behavior regardless of the order + * of the frames. But when testing the old table panel it had this behavior so I am + * sticking with it. + */ + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'AgeGroup', type: FieldType.string, values: ['0 - 17', '18 - 24', '25 - 34', '35 - 49', '50 or over'] }, + { name: 'Count', type: FieldType.number, values: [1, 3, 2, 4, 2] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { + name: 'Country', + type: FieldType.string, + values: ['United States', 'United States', 'Mexico', 'Germany', 'Canada', 'Canada'], + }, + { + name: 'AgeGroup', + type: FieldType.string, + values: ['50 or over', '35 - 49', '0 - 17', '35 - 49', '35 - 49', '25 - 34'], + }, + { name: 'Sum', type: FieldType.number, values: [998, 1193, 1675, 146, 166, 219] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('AgeGroup', FieldType.string, [ + '0 - 17', + '18 - 24', + '25 - 34', + '35 - 49', + '50 or over', + '35 - 49', + '35 - 49', + ]), + createField('Count', FieldType.number, [1, 3, 2, 4, 2, undefined, undefined]), + createField('Country', FieldType.string, [ + 'Mexico', + undefined, + 'Canada', + 'United States', + 'United States', + 'Germany', + 'Canada', + ]), + createField('Sum', FieldType.number, [1675, undefined, 219, 1193, 998, 146, 166]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three tables with multiple values into one', async () => { + const tableA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + { name: 'Humidity', type: FieldType.number, values: [10, 14, 55] }, + ], + }); + + const tableB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + { name: 'Enabled', type: FieldType.boolean, values: [true, false, true] }, + ], + }); + + const tableC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 124, 149] }, + { name: 'Humidity', type: FieldType.number, values: [22, 25, 30] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + await expect(transformDataFrame([cfg], [tableA, tableB, tableC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126, 100, 124, 149]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3, 1, 4, 5]), + createField('Humidity', FieldType.number, [10, 14, 55, undefined, undefined, undefined, 22, 25, 30]), + createField('Enabled', FieldType.boolean, [ + undefined, + undefined, + undefined, + true, + false, + true, + undefined, + undefined, + undefined, + ]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has displayName, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], config: { displayName: 'Random time' } }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { displayName: 'Temp' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has display processor, into one', async () => { + const displayProcessor: DisplayProcessor = jest.fn(); + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], display: displayProcessor }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126], {}, displayProcessor), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[0].display).toBe(displayProcessor); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has units, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { units: 'celsius' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3], { units: 'celsius' }), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({ units: 'celsius' }); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where second serie fields has units, into one', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3], config: { units: 'celsius' } }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]), + createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine one regular serie with an empty serie should return the regular serie', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200]), + createField('Temp', FieldType.number, [1, 4, 5]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two regular series with an empty serie should return the combination of the regular series', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + const serieC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Humidity', type: FieldType.number, values: [6, 7, 8] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB, serieC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + createField('Time', FieldType.time, [100, 150, 200]), + createField('Temp', FieldType.number, [1, 4, 5]), + createField('Humidity', FieldType.number, [6, 7, 8]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[1].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine multiple empty series should return one empty serie', async () => { + const serieA = toDataFrame({ + name: 'A', + fields: [], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [], + }); + + const serieC = toDataFrame({ + name: 'C', + fields: [], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB, serieC])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = []; + const fields = unwrap(result[0].fields); + + expect(fields).toEqual(expected); + expect(result.length).toEqual(1); + }); + }); +}); + +const createField = (name: string, type: FieldType, values: any[], config = {}, display?: DisplayProcessor): Field => { + return { name, type, values: new ArrayVector(values), config, labels: undefined, display }; +}; + +const unwrap = (fields: Field[]): Field[] => { + return fields.map((field) => + createField( + field.name, + field.type, + field.values.toArray().map((value: any) => value), + field.config, + field.display + ) + ); +}; diff --git a/packages/grafana-data/src/transformations/transformers/merge.ts b/packages/grafana-data/src/transformations/transformers/merge.ts new file mode 100644 index 0000000..ecc0e76 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/merge.ts @@ -0,0 +1,217 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { omit } from 'lodash'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { MutableDataFrame } from '../../dataframe'; + +interface ValuePointer { + key: string; + index: number; +} + +export interface MergeTransformerOptions {} + +export const mergeTransformer: DataTransformerInfo = { + id: DataTransformerID.merge, + name: 'Merge series/tables', + description: 'Merges multiple series/tables into a single serie/table', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((dataFrames) => { + if (!Array.isArray(dataFrames) || dataFrames.length === 0) { + return dataFrames; + } + + const data = dataFrames.filter((frame) => frame.fields.length > 0); + + if (data.length === 0) { + return [dataFrames[0]]; + } + + const fieldNames = new Set(); + const fieldIndexByName: Record> = {}; + const fieldNamesForKey: string[] = []; + const dataFrame = new MutableDataFrame(); + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + + if (!fieldNames.has(field.name)) { + dataFrame.addField(copyFieldStructure(field)); + fieldNames.add(field.name); + } + + fieldIndexByName[field.name] = fieldIndexByName[field.name] || {}; + fieldIndexByName[field.name][frameIndex] = fieldIndex; + + if (data.length - 1 !== frameIndex) { + continue; + } + + if (fieldExistsInAllFrames(fieldIndexByName, field, data)) { + fieldNamesForKey.push(field.name); + } + } + } + + if (fieldNamesForKey.length === 0) { + return dataFrames; + } + + const valuesByKey: Record>> = {}; + const valuesInOrder: ValuePointer[] = []; + const keyFactory = createKeyFactory(data, fieldIndexByName, fieldNamesForKey); + const valueMapper = createValueMapper(data, fieldNames, fieldIndexByName); + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let valueIndex = 0; valueIndex < frame.length; valueIndex++) { + const key = keyFactory(frameIndex, valueIndex); + const value = valueMapper(frameIndex, valueIndex); + + if (!Array.isArray(valuesByKey[key])) { + valuesByKey[key] = [value]; + valuesInOrder.push(createPointer(key, valuesByKey)); + continue; + } + + let valueWasMerged = false; + + valuesByKey[key] = valuesByKey[key].map((existing) => { + if (!isMergable(existing, value)) { + return existing; + } + valueWasMerged = true; + return { ...existing, ...value }; + }); + + if (!valueWasMerged) { + valuesByKey[key].push(value); + valuesInOrder.push(createPointer(key, valuesByKey)); + } + } + } + + for (const pointer of valuesInOrder) { + const value = valuesByKey[pointer.key][pointer.index]; + + if (value) { + dataFrame.add(value); + } + } + + return [dataFrame]; + }) + ), +}; + +const copyFieldStructure = (field: Field): Field => { + return { + ...omit(field, ['values', 'state', 'labels', 'config']), + values: new ArrayVector(), + config: { + ...omit(field.config, 'displayName'), + }, + }; +}; + +const createKeyFactory = ( + data: DataFrame[], + fieldPointerByName: Record>, + keyFieldNames: string[] +) => { + const factoryIndex = keyFieldNames.reduce((index: Record, fieldName) => { + return Object.keys(fieldPointerByName[fieldName]).reduce((index: Record, frameIndex) => { + index[frameIndex] = index[frameIndex] || []; + index[frameIndex].push(fieldPointerByName[fieldName][frameIndex]); + return index; + }, index); + }, {}); + + return (frameIndex: number, valueIndex: number): string => { + return factoryIndex[frameIndex].reduce((key: string, fieldIndex: number) => { + return key + data[frameIndex].fields[fieldIndex].values.get(valueIndex); + }, ''); + }; +}; + +const createValueMapper = ( + data: DataFrame[], + fieldByName: Set, + fieldIndexByName: Record> +) => { + return (frameIndex: number, valueIndex: number) => { + const value: Record = {}; + const fieldNames = Array.from(fieldByName); + + for (const fieldName of fieldNames) { + const fieldIndexByFrameIndex = fieldIndexByName[fieldName]; + if (!fieldIndexByFrameIndex) { + continue; + } + + const fieldIndex = fieldIndexByFrameIndex[frameIndex]; + if (typeof fieldIndex !== 'number') { + continue; + } + + const frame = data[frameIndex]; + if (!frame || !frame.fields) { + continue; + } + + const field = frame.fields[fieldIndex]; + if (!field || !field.values) { + continue; + } + + value[fieldName] = field.values.get(valueIndex); + } + + return value; + }; +}; + +const isMergable = (existing: Record, value: Record): boolean => { + let mergable = true; + + for (const prop in value) { + if (typeof existing[prop] === 'undefined') { + continue; + } + + if (existing[prop] === null) { + continue; + } + + if (existing[prop] !== value[prop]) { + mergable = false; + break; + } + } + + return mergable; +}; + +const fieldExistsInAllFrames = ( + fieldIndexByName: Record>, + field: Field, + data: DataFrame[] +) => { + return Object.keys(fieldIndexByName[field.name]).length === data.length; +}; + +const createPointer = (key: string, valuesByKey: Record>>): ValuePointer => { + return { + key, + index: valuesByKey[key].length - 1, + }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/noop.ts b/packages/grafana-data/src/transformations/transformers/noop.ts new file mode 100644 index 0000000..32c6010 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/noop.ts @@ -0,0 +1,20 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; + +export interface NoopTransformerOptions { + include?: string; + exclude?: string; +} + +export const noopTransformer: DataTransformerInfo = { + id: DataTransformerID.noop, + name: 'noop', + description: 'No-operation transformer', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options: NoopTransformerOptions) => (source) => source, +}; diff --git a/packages/grafana-data/src/transformations/transformers/order.test.ts b/packages/grafana-data/src/transformations/transformers/order.test.ts new file mode 100644 index 0000000..c9e4ea7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/order.test.ts @@ -0,0 +1,182 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { orderFieldsTransformer, OrderFieldsTransformerOptions } from './order'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Order Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([orderFieldsTransformer]); + }); + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should order according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'temperature', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + labels: undefined, + state: { + displayName: 'temperature', + }, + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + labels: undefined, + state: { + displayName: 'humidity', + }, + }, + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + labels: undefined, + state: { + displayName: 'time', + }, + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should append fields missing in config at the end', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + labels: undefined, + state: { + displayName: 'humidity', + }, + }, + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + labels: undefined, + state: { + displayName: 'time', + }, + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + labels: undefined, + state: { + displayName: 'pressure', + }, + }, + ]); + }); + }); + }); + + describe('when transforming with empty configuration', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should keep the same order as in the incoming data', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.order, + options: { + indexByName: {}, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const ordered = data[0]; + expect(ordered.fields).toEqual([ + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/order.ts b/packages/grafana-data/src/transformations/transformers/order.ts new file mode 100644 index 0000000..d1b48dd --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/order.ts @@ -0,0 +1,64 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { map } from 'rxjs/operators'; + +export interface OrderFieldsTransformerOptions { + indexByName: Record; +} + +export const orderFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.order, + name: 'Order fields by name', + description: 'Order fields based on configuration given by user', + defaultOptions: { + indexByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const orderer = createFieldsOrderer(options.indexByName); + + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + return data.map((frame) => ({ + ...frame, + fields: orderer(frame.fields, data, frame), + })); + }) + ), +}; + +export const createOrderFieldsComparer = (indexByName: Record) => (a: string, b: string) => { + return indexOfField(a, indexByName) - indexOfField(b, indexByName); +}; + +const createFieldsOrderer = (indexByName: Record) => ( + fields: Field[], + data: DataFrame[], + frame: DataFrame +) => { + if (!Array.isArray(fields) || fields.length === 0) { + return fields; + } + if (!indexByName || Object.keys(indexByName).length === 0) { + return fields; + } + const comparer = createOrderFieldsComparer(indexByName); + return fields.sort((a, b) => comparer(getFieldDisplayName(a, frame, data), getFieldDisplayName(b, frame, data))); +}; + +const indexOfField = (fieldName: string, indexByName: Record) => { + if (Number.isInteger(indexByName[fieldName])) { + return indexByName[fieldName]; + } + return Number.MAX_SAFE_INTEGER; +}; diff --git a/packages/grafana-data/src/transformations/transformers/organize.test.ts b/packages/grafana-data/src/transformations/transformers/organize.test.ts new file mode 100644 index 0000000..b0dff65 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/organize.test.ts @@ -0,0 +1,134 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { organizeFieldsTransformer, OrganizeFieldsTransformerOptions } from './organize'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('OrganizeFields Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([organizeFieldsTransformer]); + }); + + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should order and filter according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.organize, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + excludeByName: { + time: true, + }, + renameByName: { + humidity: 'renamed_humidity', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const organized = data[0]; + expect(organized.fields).toEqual([ + { + config: {}, + labels: undefined, + name: 'temperature', + state: { + displayName: 'temperature', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'renamed_humidity', + }, + labels: undefined, + name: 'humidity', + state: { + displayName: 'renamed_humidity', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should append fields missing in config at the end', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.organize, + options: { + indexByName: { + time: 2, + temperature: 0, + humidity: 1, + }, + excludeByName: { + humidity: true, + }, + renameByName: { + time: 'renamed_time', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const organized = data[0]; + expect(organized.fields).toEqual([ + { + labels: undefined, + config: { + displayName: 'renamed_time', + }, + name: 'time', + state: { + displayName: 'renamed_time', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + labels: undefined, + name: 'pressure', + state: { + displayName: 'pressure', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/organize.ts b/packages/grafana-data/src/transformations/transformers/organize.ts new file mode 100644 index 0000000..7913cdb --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/organize.ts @@ -0,0 +1,43 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { orderFieldsTransformer, OrderFieldsTransformerOptions } from './order'; +import { filterFieldsByNameTransformer } from './filterByName'; +import { renameFieldsTransformer, RenameFieldsTransformerOptions } from './rename'; + +export interface OrganizeFieldsTransformerOptions + extends OrderFieldsTransformerOptions, + RenameFieldsTransformerOptions { + excludeByName: Record; +} + +export const organizeFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.organize, + name: 'Organize fields by name', + description: 'Order, filter and rename fields based on configuration given by user', + defaultOptions: { + excludeByName: {}, + indexByName: {}, + renameByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + filterFieldsByNameTransformer.operator({ + exclude: { names: mapToExcludeArray(options.excludeByName) }, + }), + orderFieldsTransformer.operator(options), + renameFieldsTransformer.operator(options) + ), +}; + +const mapToExcludeArray = (excludeByName: Record): string[] => { + if (!excludeByName) { + return []; + } + + return Object.keys(excludeByName).filter((name) => excludeByName[name]); +}; diff --git a/packages/grafana-data/src/transformations/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts new file mode 100644 index 0000000..b14d1d5 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -0,0 +1,369 @@ +import { ReducerID } from '../fieldReducer'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { reduceFields, reduceTransformer } from './reduce'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { ArrayVector } from '../../vector'; +import { notTimeFieldMatcher } from '../matchers/predicates'; +import { DataFrameView } from '../../dataframe'; + +const seriesAWithSingleField = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] }, + ], +}); + +const seriesAWithMultipleFields = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], +}); + +const seriesBWithSingleField = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + ], +}); + +const seriesBWithMultipleFields = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + { name: 'humidity', type: FieldType.number, values: [11000.1, 11000.3, 11000.5, 11000.7] }, + ], +}); + +describe('Reducer Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([reduceTransformer]); + }); + + it('reduces multiple data frames with many fields', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithMultipleFields, seriesBWithMultipleFields])).toEmitValuesWith( + (received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'A humidity', 'B temperature', 'B humidity']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 10000.3, 1, 11000.1]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 10000.3, 1, 11000.1]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10000.6, 7, 11000.7]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 10000.6, 7, 11000.7]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(4); + expect(processed[0].fields).toEqual(expected); + } + ); + }); + + it('reduces multiple data frames with single field', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField, seriesBWithSingleField])).toEmitValuesWith( + (received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'B temperature']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 1]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 1]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 7]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 7]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + } + ); + }); + + it('reduces single data frame with many fields', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithMultipleFields])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature', 'A humidity']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3, 10000.3]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3, 10000.3]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10000.6]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6, 10000.6]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces single data frame with single field', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last], + }, + }; + + await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['A temperature']), + config: {}, + }, + { + name: 'First', + type: FieldType.number, + values: new ArrayVector([3]), + config: {}, + }, + { + name: 'Min', + type: FieldType.number, + values: new ArrayVector([3]), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6]), + config: {}, + }, + { + name: 'Last', + type: FieldType.number, + values: new ArrayVector([6]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(1); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces fields with single calculator', () => { + const frames = reduceFields( + [seriesAWithSingleField, seriesAWithMultipleFields], // data + notTimeFieldMatcher, // skip time fields + [ReducerID.last] // only one + ); + + // Convert each frame to a structure with the same fields + expect(frames.length).toEqual(2); + expect(frames[0].length).toEqual(1); + expect(frames[1].length).toEqual(1); + + const view0 = new DataFrameView(frames[0]); + const view1 = new DataFrameView(frames[1]); + expect({ ...view0.get(0) }).toMatchInlineSnapshot(` + Object { + "temperature": 6, + } + `); + expect({ ...view1.get(0) }).toMatchInlineSnapshot(` + Object { + "humidity": 10000.6, + "temperature": 6, + } + `); + }); + + it('reduces multiple data frames with decimal display name (https://github.com/grafana/grafana/issues/31580)', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.max], + }, + }; + + const seriesA = toDataFrame({ + name: 'a', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'value', type: FieldType.number, values: [3, 4, 5, 6], state: { displayName: 'a' } }, + ], + }); + + const seriesB = toDataFrame({ + name: '2021', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'value', type: FieldType.number, values: [7, 8, 9, 10], state: { displayName: '2021' } }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['a', '2021']), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); + + it('reduces multiple data frames with decimal fields name (https://github.com/grafana/grafana/issues/31580)', async () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.max], + }, + }; + + const seriesA = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'a', type: FieldType.number, values: [3, 4, 5, 6] }, + ], + }); + + const seriesB = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: '2021', type: FieldType.number, values: [7, 8, 9, 10] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const processed = received[0]; + const expected: Field[] = [ + { + name: 'Field', + type: FieldType.string, + values: new ArrayVector(['a', '2021']), + config: {}, + }, + { + name: 'Max', + type: FieldType.number, + values: new ArrayVector([6, 10]), + config: {}, + }, + ]; + + expect(processed.length).toEqual(1); + expect(processed[0].length).toEqual(2); + expect(processed[0].fields).toEqual(expected); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts new file mode 100644 index 0000000..413f6c5 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -0,0 +1,216 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo, FieldMatcher, MatcherConfig } from '../../types/transformations'; +import { fieldReducers, reduceField, ReducerID } from '../fieldReducer'; +import { alwaysFieldMatcher, notTimeFieldMatcher } from '../matchers/predicates'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { KeyValue } from '../../types/data'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldMatcher } from '../matchers'; +import { getFieldDisplayName } from '../../field'; + +export enum ReduceTransformerMode { + SeriesToRows = 'seriesToRows', // default + ReduceFields = 'reduceFields', // same structure, add additional row for each type +} +export interface ReduceTransformerOptions { + reducers: ReducerID[]; + fields?: MatcherConfig; // Assume all fields + mode?: ReduceTransformerMode; + includeTimeField?: boolean; +} + +export const reduceTransformer: DataTransformerInfo = { + id: DataTransformerID.reduce, + name: 'Reduce', + description: 'Reduce all rows or data points to a single value using a function like max, min, mean or last', + defaultOptions: { + reducers: [ReducerID.max], + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!options?.reducers?.length) { + return data; // nothing selected + } + + const matcher = options.fields + ? getFieldMatcher(options.fields) + : options.includeTimeField && options.mode === ReduceTransformerMode.ReduceFields + ? alwaysFieldMatcher + : notTimeFieldMatcher; + + // Collapse all matching fields into a single row + if (options.mode === ReduceTransformerMode.ReduceFields) { + return reduceFields(data, matcher, options.reducers); + } + + // Add a row for each series + const res = reduceSeriesToRows(data, matcher, options.reducers); + return res ? [res] : []; + }) + ), +}; + +/** + * @internal only exported for testing + */ +export function reduceSeriesToRows( + data: DataFrame[], + matcher: FieldMatcher, + reducerId: ReducerID[] +): DataFrame | undefined { + const calculators = fieldReducers.list(reducerId); + const reducers = calculators.map((c) => c.id); + const processed: DataFrame[] = []; + + for (const series of data) { + const values: ArrayVector[] = []; + const fields: Field[] = []; + const byId: KeyValue = {}; + + values.push(new ArrayVector()); // The name + fields.push({ + name: 'Field', + type: FieldType.string, + values: values[0], + config: {}, + }); + + for (const info of calculators) { + const vals = new ArrayVector(); + byId[info.id] = vals; + values.push(vals); + + fields.push({ + name: info.name, + type: FieldType.other, // UNKNOWN until after we call the functions + values: values[values.length - 1], + config: {}, + }); + } + + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + + if (matcher(field, series, data)) { + const results = reduceField({ + field, + reducers, + }); + + // Update the name list + const fieldName = getFieldDisplayName(field, series, data); + + values[0].buffer.push(fieldName); + + for (const info of calculators) { + const v = results[info.id]; + byId[info.id].buffer.push(v); + } + } + } + + for (const f of fields) { + const t = guessFieldTypeForField(f); + + if (t) { + f.type = t; + } + } + + processed.push({ + ...series, // Same properties, different fields + fields, + length: values[0].length, + }); + } + + return mergeResults(processed); +} + +/** + * @internal only exported for testing + */ +export function mergeResults(data: DataFrame[]): DataFrame | undefined { + if (!data?.length) { + return undefined; + } + + const baseFrame = data[0]; + + for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) { + const series = data[seriesIndex]; + + for (let baseIndex = 0; baseIndex < baseFrame.fields.length; baseIndex++) { + const baseField = baseFrame.fields[baseIndex]; + for (let fieldIndex = 0; fieldIndex < series.fields.length; fieldIndex++) { + const field = series.fields[fieldIndex]; + const isFirstField = baseIndex === 0 && fieldIndex === 0; + const isSameField = baseField.type === field.type && baseField.name === field.name; + + if (isFirstField || isSameField) { + const baseValues: any[] = baseField.values.toArray(); + const values: any[] = field.values.toArray(); + ((baseField.values as unknown) as ArrayVector).buffer = baseValues.concat(values); + } + } + } + } + + baseFrame.name = undefined; + baseFrame.length = baseFrame.fields[0].values.length; + return baseFrame; +} + +/** + * @internal -- only exported for testing + */ +export function reduceFields(data: DataFrame[], matcher: FieldMatcher, reducerId: ReducerID[]): DataFrame[] { + const calculators = fieldReducers.list(reducerId); + const reducers = calculators.map((c) => c.id); + const processed: DataFrame[] = []; + + for (const series of data) { + const fields: Field[] = []; + for (const field of series.fields) { + if (matcher(field, series, data)) { + const results = reduceField({ + field, + reducers, + }); + for (const reducer of reducers) { + const value = results[reducer]; + const copy = { + ...field, + values: new ArrayVector([value]), + }; + copy.state = undefined; + if (reducers.length > 1) { + if (!copy.labels) { + copy.labels = {}; + } + copy.labels['reducer'] = fieldReducers.get(reducer).name; + } + fields.push(copy); + } + } + } + if (fields.length) { + processed.push({ + ...series, + fields, + length: 1, // always one row + }); + } + } + + return processed; +} diff --git a/packages/grafana-data/src/transformations/transformers/rename.test.ts b/packages/grafana-data/src/transformations/transformers/rename.test.ts new file mode 100644 index 0000000..641ab9b --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/rename.test.ts @@ -0,0 +1,193 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { renameFieldsTransformer, RenameFieldsTransformerOptions } from './rename'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Rename Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([renameFieldsTransformer]); + }); + + describe('when consistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should rename according to config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: { + time: 'Total time', + humidity: 'Moistness', + temperature: 'how cold is it?', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: { + displayName: 'Total time', + }, + labels: undefined, + name: 'time', + state: { + displayName: 'Total time', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: { + displayName: 'how cold is it?', + }, + labels: undefined, + name: 'temperature', + state: { + displayName: 'how cold is it?', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'Moistness', + }, + name: 'humidity', + labels: undefined, + state: { + displayName: 'Moistness', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when inconsistent data is received', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should not rename fields missing in config', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: { + time: 'ttl', + temperature: 'temp', + humidity: 'hum', + }, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: { + displayName: 'ttl', + }, + name: 'time', + labels: undefined, + state: { + displayName: 'ttl', + }, + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + labels: undefined, + name: 'pressure', + state: { + displayName: 'pressure', + }, + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: { + displayName: 'hum', + }, + labels: undefined, + name: 'humidity', + state: { + displayName: 'hum', + }, + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); + + describe('when transforming with empty configuration', () => { + const data = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + it('should keep the same names as in the incoming data', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.rename, + options: { + renameByName: {}, + }, + }; + + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const renamed = data[0]; + expect(renamed.fields).toEqual([ + { + config: {}, + name: 'time', + type: FieldType.time, + values: new ArrayVector([3000, 4000, 5000, 6000]), + }, + { + config: {}, + name: 'pressure', + type: FieldType.number, + values: new ArrayVector([10.3, 10.4, 10.5, 10.6]), + }, + { + config: {}, + name: 'humidity', + type: FieldType.number, + values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]), + }, + ]); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/rename.ts b/packages/grafana-data/src/transformations/transformers/rename.ts new file mode 100644 index 0000000..0f22557 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/rename.ts @@ -0,0 +1,65 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; +import { map } from 'rxjs/operators'; + +export interface RenameFieldsTransformerOptions { + renameByName: Record; +} + +export const renameFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.rename, + name: 'Rename fields by name', + description: 'Rename fields based on configuration given by user', + defaultOptions: { + renameByName: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + const renamer = createRenamer(options.renameByName); + + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + return data.map((frame) => ({ + ...frame, + fields: renamer(frame), + })); + }) + ), +}; + +const createRenamer = (renameByName: Record) => (frame: DataFrame): Field[] => { + if (!renameByName || Object.keys(renameByName).length === 0) { + return frame.fields; + } + + return frame.fields.map((field) => { + const displayName = getFieldDisplayName(field, frame); + const renameTo = renameByName[displayName]; + + if (typeof renameTo !== 'string' || renameTo.length === 0) { + return field; + } + + return { + ...field, + config: { + ...field.config, + displayName: renameTo, + }, + state: { + ...field.state, + displayName: renameTo, + }, + }; + }); +}; diff --git a/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts new file mode 100644 index 0000000..2c4cac8 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/renameByRegex.test.ts @@ -0,0 +1,180 @@ +import { DataTransformerConfig, DataTransformerID, FieldType, toDataFrame, transformDataFrame } from '@grafana/data'; +import { renameByRegexTransformer, RenameByRegexTransformerOptions } from './renameByRegex'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('Rename By Regex Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([renameByRegexTransformer]); + }); + + describe('when regex and replacement pattern', () => { + const data = toDataFrame({ + name: 'web-01.example.com', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: { name: 'Time' }, + values: [3000, 4000, 5000, 6000], + }, + { + name: 'Value', + type: FieldType.number, + config: { displayName: 'web-01.example.com' }, + values: [10000.3, 10000.4, 10000.5, 10000.6], + }, + ], + }); + + it('should rename matches using references', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '([^.]+).example.com', + renamePattern: '$1', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01", + }, + "name": "Value", + "state": Object { + "displayName": "web-01", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + + it('should not rename misses', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '([^.]+).bad-domain.com', + renamePattern: '$1', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01.example.com", + }, + "name": "Value", + "state": Object { + "displayName": "web-01.example.com", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + + it('should not rename with empty regex and repacement pattern', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.renameByRegex, + options: { + regex: '', + renamePattern: '', + }, + }; + await expect(transformDataFrame([cfg], [data])).toEmitValuesWith((received) => { + const data = received[0]; + const frame = data[0]; + expect(frame.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "displayName": "Time", + "name": "Time", + }, + "name": "Time", + "state": Object { + "displayName": "Time", + }, + "type": "time", + "values": Array [ + 3000, + 4000, + 5000, + 6000, + ], + }, + Object { + "config": Object { + "displayName": "web-01.example.com", + }, + "name": "Value", + "state": Object { + "displayName": "web-01.example.com", + }, + "type": "number", + "values": Array [ + 10000.3, + 10000.4, + 10000.5, + 10000.6, + ], + }, + ] + `); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/renameByRegex.ts b/packages/grafana-data/src/transformations/transformers/renameByRegex.ts new file mode 100644 index 0000000..be93f52 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/renameByRegex.ts @@ -0,0 +1,62 @@ +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { map } from 'rxjs/operators'; +import { DataFrame } from '../../types/dataFrame'; +import { getFieldDisplayName } from '../../field/fieldState'; + +/** + * Options for renameByRegexTransformer + * + * @public + */ +export interface RenameByRegexTransformerOptions { + regex: string; + renamePattern: string; +} + +/** + * Replaces the displayName of a field by applying a regular expression + * to match the name and a pattern for the replacement. + * + * @public + */ +export const renameByRegexTransformer: DataTransformerInfo = { + id: DataTransformerID.renameByRegex, + name: 'Rename fields by regex', + description: 'Rename fields based on regular expression by users.', + defaultOptions: { + regex: '(.*)', + renamePattern: '$1', + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + return data.map(renameFieldsByRegex(options)); + }) + ), +}; + +const renameFieldsByRegex = (options: RenameByRegexTransformerOptions) => (frame: DataFrame) => { + const regex = new RegExp(options.regex); + const fields = frame.fields.map((field) => { + const displayName = getFieldDisplayName(field, frame); + if (!regex.test(displayName)) { + return field; + } + const newDisplayName = displayName.replace(regex, options.renamePattern); + return { + ...field, + config: { ...field.config, displayName: newDisplayName }, + state: { ...field.state, displayName: newDisplayName }, + }; + }); + return { ...frame, fields }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts new file mode 100644 index 0000000..d97edad --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts @@ -0,0 +1,488 @@ +import { + ArrayVector, + DataTransformerConfig, + DataTransformerID, + FieldType, + toDataFrame, + transformDataFrame, +} from '@grafana/data'; +import { SeriesToColumnsOptions, seriesToColumnsTransformer } from './seriesToColumns'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +describe('SeriesToColumns Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([seriesToColumnsTransformer]); + }); + + const everySecondSeries = toDataFrame({ + name: 'even', + fields: [ + { name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] }, + { name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] }, + { name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] }, + ], + }); + + const everyOtherSecondSeries = toDataFrame({ + name: 'odd', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] }, + { name: 'temperature', type: FieldType.number, values: [11.1, 11.3, 11.5, 11.7] }, + { name: 'humidity', type: FieldType.number, values: [11000.1, 11000.3, 11000.5, 11000.7] }, + ], + }); + + it('joins by time field', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], + }, + ] + `); + } + ); + }); + + it('joins by temperature field', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'temperature', + }, + }; + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(`Array []`); + } + ); + }); + + it('joins by time field in reverse order', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + everySecondSeries.fields[0].values = new ArrayVector(everySecondSeries.fields[0].values.toArray().reverse()); + everySecondSeries.fields[1].values = new ArrayVector(everySecondSeries.fields[1].values.toArray().reverse()); + everySecondSeries.fields[2].values = new ArrayVector(everySecondSeries.fields[2].values.toArray().reverse()); + + await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 3000, + 4000, + 5000, + 6000, + 7000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10.3, + 10.4, + 10.5, + 10.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "even", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + undefined, + 10000.3, + 10000.4, + 10000.5, + 10000.6, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 11.1, + 11.3, + undefined, + 11.5, + undefined, + 11.7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "odd", + }, + "name": "humidity", + "state": Object {}, + "type": "number", + "values": Array [ + 11000.1, + 11000.3, + undefined, + 11000.5, + undefined, + 11000.7, + ], + }, + ] + `); + } + ); + }); + + describe('Field names', () => { + const seriesWithSameFieldAndDataFrameName = toDataFrame({ + name: 'temperature', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'temperature', type: FieldType.number, values: [2, 4, 6, 8] }, + ], + }); + + it('when dataframe and field share the same name then use the field name', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + await expect(transformDataFrame([cfg], [seriesWithSameFieldAndDataFrameName, seriesB])).toEmitValuesWith( + (received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1000, + 2000, + 3000, + 4000, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "temperature", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 1, + 3, + 5, + 7, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "B", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 2, + 4, + 6, + 8, + ], + }, + ] + `); + } + ); + }); + }); + + it('joins if fields are missing', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + const frame1 = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'temperature', type: FieldType.number, values: [10, 11, 12] }, + ], + }); + + const frame2 = toDataFrame({ + name: 'B', + fields: [], + }); + + const frame3 = toDataFrame({ + name: 'C', + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'temperature', type: FieldType.number, values: [20, 22, 24] }, + ], + }); + + await expect(transformDataFrame([cfg], [frame1, frame2, frame3])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1, + 2, + 3, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "A", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 10, + 11, + 12, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "name": "C", + }, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 20, + 22, + 24, + ], + }, + ] + `); + }); + }); + + it('handles duplicate field name', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToColumns, + options: { + byField: 'time', + }, + }; + + const frame1 = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1] }, + { name: 'temperature', type: FieldType.number, values: [10] }, + ], + }); + + const frame2 = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1] }, + { name: 'temperature', type: FieldType.number, values: [20] }, + ], + }); + + await expect(transformDataFrame([cfg], [frame1, frame2])).toEmitValuesWith((received) => { + const data = received[0]; + const filtered = data[0]; + expect(filtered.fields).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "displayName": "time", + }, + "type": "time", + "values": Array [ + 1, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 10, + ], + }, + Object { + "config": Object {}, + "labels": Object {}, + "name": "temperature", + "state": Object {}, + "type": "number", + "values": Array [ + 20, + ], + }, + ] + `); + }); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts b/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts new file mode 100644 index 0000000..07acaf8 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToColumns.ts @@ -0,0 +1,36 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerInfo, FieldMatcher } from '../../types'; +import { DataTransformerID } from './ids'; +import { outerJoinDataFrames } from './joinDataFrames'; +import { fieldMatchers } from '../matchers'; +import { FieldMatcherID } from '../matchers/ids'; + +export interface SeriesToColumnsOptions { + byField?: string; // empty will pick the field automatically +} + +export const seriesToColumnsTransformer: DataTransformerInfo = { + id: DataTransformerID.seriesToColumns, + name: 'Series as columns', // Called 'Outer join' in the UI! + description: 'Groups series by field and returns values as columns', + defaultOptions: { + byField: undefined, // DEFAULT_KEY_FIELD, + }, + operator: (options) => (source) => + source.pipe( + map((data) => { + if (data.length > 1) { + let joinBy: FieldMatcher | undefined = undefined; + if (options.byField) { + joinBy = fieldMatchers.get(FieldMatcherID.byName).get(options.byField); + } + const joined = outerJoinDataFrames({ frames: data, joinBy }); + if (joined) { + return [joined]; + } + } + return data; + }) + ), +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts new file mode 100644 index 0000000..bb43826 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.test.ts @@ -0,0 +1,260 @@ +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { DataTransformerConfig, Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../../dataframe'; +import { transformDataFrame } from '../transformDataFrame'; +import { ArrayVector } from '../../vector'; +import { seriesToRowsTransformer, SeriesToRowsTransformerOptions } from './seriesToRows'; + +describe('Series to rows', () => { + beforeAll(() => { + mockTransformationsRegistry([seriesToRowsTransformer]); + }); + + it('combine two series into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [2000, 1000]), + createField('Metric', FieldType.string, ['B', 'A']), + createField('Value', FieldType.number, [-1, 1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two series with multiple values into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine three series into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const seriesA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1000] }, + { name: 'Temp', type: FieldType.number, values: [1] }, + ], + }); + + const seriesB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [2000] }, + { name: 'Temp', type: FieldType.number, values: [-1] }, + ], + }); + + const seriesC = toDataFrame({ + name: 'C', + fields: [ + { name: 'Time', type: FieldType.time, values: [500] }, + { name: 'Temp', type: FieldType.number, values: [2] }, + ], + }); + + await expect(transformDataFrame([cfg], [seriesA, seriesB, seriesC])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [2000, 1000, 500]), + createField('Metric', FieldType.string, ['B', 'A', 'C']), + createField('Value', FieldType.number, [-1, 1, 2]), + ]; + + expect(unwrap(result[0].fields)).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has displayName, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200], config: { displayName: 'Random time' } }, + { + name: 'Temp', + type: FieldType.number, + values: [1, 4, 5], + config: { displayName: 'Temp', displayNameFromDS: 'dsName' }, + }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where first serie fields has units, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5], config: { units: 'celsius' } }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3] }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1], { units: 'celsius' }), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({ units: 'celsius' }); + expect(fields).toEqual(expected); + }); + }); + + it('combine two time series, where second serie fields has units, into one', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.seriesToRows, + options: {}, + }; + + const serieA = toDataFrame({ + name: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 150, 200] }, + { name: 'Temp', type: FieldType.number, values: [1, 4, 5] }, + ], + }); + + const serieB = toDataFrame({ + name: 'B', + fields: [ + { name: 'Time', type: FieldType.time, values: [100, 125, 126] }, + { name: 'Temp', type: FieldType.number, values: [-1, 2, 3], config: { units: 'celsius' } }, + ], + }); + + await expect(transformDataFrame([cfg], [serieA, serieB])).toEmitValuesWith((received) => { + const result = received[0]; + + const expected: Field[] = [ + createField('Time', FieldType.time, [200, 150, 126, 125, 100, 100]), + createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']), + createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]), + ]; + + const fields = unwrap(result[0].fields); + + expect(fields[2].config).toEqual({}); + expect(fields).toEqual(expected); + }); + }); +}); + +const createField = (name: string, type: FieldType, values: any[], config = {}): Field => { + return { name, type, values: new ArrayVector(values), config, labels: undefined }; +}; + +const unwrap = (fields: Field[]): Field[] => { + return fields.map((field) => + createField( + field.name, + field.type, + field.values.toArray().map((value: any) => value), + field.config + ) + ); +}; diff --git a/packages/grafana-data/src/transformations/transformers/seriesToRows.ts b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts new file mode 100644 index 0000000..a2ddb11 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/seriesToRows.ts @@ -0,0 +1,99 @@ +import { omit } from 'lodash'; +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { + Field, + FieldType, + TIME_SERIES_METRIC_FIELD_NAME, + TIME_SERIES_TIME_FIELD_NAME, + TIME_SERIES_VALUE_FIELD_NAME, +} from '../../types/dataFrame'; +import { isTimeSeries } from '../../dataframe/utils'; +import { MutableDataFrame, sortDataFrame } from '../../dataframe'; +import { ArrayVector } from '../../vector'; +import { getFrameDisplayName } from '../../field/fieldState'; + +export interface SeriesToRowsTransformerOptions {} + +export const seriesToRowsTransformer: DataTransformerInfo = { + id: DataTransformerID.seriesToRows, + name: 'Series to rows', + description: 'Combines multiple series into a single serie and appends a column with metric name per value.', + defaultOptions: {}, + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length <= 1) { + return data; + } + + if (!isTimeSeries(data)) { + return data; + } + + const timeFieldByIndex: Record = {}; + const targetFields = new Set(); + const dataFrame = new MutableDataFrame(); + const metricField: Field = { + name: TIME_SERIES_METRIC_FIELD_NAME, + values: new ArrayVector(), + config: {}, + type: FieldType.string, + }; + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { + const field = frame.fields[fieldIndex]; + + if (field.type === FieldType.time) { + timeFieldByIndex[frameIndex] = fieldIndex; + + if (!targetFields.has(TIME_SERIES_TIME_FIELD_NAME)) { + dataFrame.addField(copyFieldStructure(field, TIME_SERIES_TIME_FIELD_NAME)); + dataFrame.addField(metricField); + targetFields.add(TIME_SERIES_TIME_FIELD_NAME); + } + continue; + } + + if (!targetFields.has(TIME_SERIES_VALUE_FIELD_NAME)) { + dataFrame.addField(copyFieldStructure(field, TIME_SERIES_VALUE_FIELD_NAME)); + targetFields.add(TIME_SERIES_VALUE_FIELD_NAME); + } + } + } + + for (let frameIndex = 0; frameIndex < data.length; frameIndex++) { + const frame = data[frameIndex]; + + for (let valueIndex = 0; valueIndex < frame.length; valueIndex++) { + const timeFieldIndex = timeFieldByIndex[frameIndex]; + const valueFieldIndex = timeFieldIndex === 0 ? 1 : 0; + + dataFrame.add({ + [TIME_SERIES_TIME_FIELD_NAME]: frame.fields[timeFieldIndex].values.get(valueIndex), + [TIME_SERIES_METRIC_FIELD_NAME]: getFrameDisplayName(frame), + [TIME_SERIES_VALUE_FIELD_NAME]: frame.fields[valueFieldIndex].values.get(valueIndex), + }); + } + } + + return [sortDataFrame(dataFrame, 0, true)]; + }) + ), +}; + +const copyFieldStructure = (field: Field, name: string): Field => { + return { + ...omit(field, ['values', 'state', 'labels', 'config', 'name']), + name: name, + values: new ArrayVector(), + config: { + ...omit(field.config, ['displayName', 'displayNameFromDS']), + }, + }; +}; diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.test.ts b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts new file mode 100644 index 0000000..534bd56 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/sortBy.test.ts @@ -0,0 +1,99 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { sortByTransformer, SortByTransformerOptions } from './sortBy'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { transformDataFrame } from '../transformDataFrame'; +import { Field, FieldType } from '../../types'; +import { DataTransformerID } from './ids'; +import { DataTransformerConfig } from '@grafana/data'; + +const testFrame = toDataFrame({ + name: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] }, // desc + { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c'] }, + { name: 'count', type: FieldType.string, values: [1, 2, 3, 4, 5] }, // asc + ], +}); + +describe('SortBy transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([sortByTransformer]); + }); + + it('should not apply transformation if config is missing sort fields', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [], // nothing + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + const result = received[0]; + expect(result[0]).toBe(testFrame); + }); + }); + + it('should sort time asc', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [ + { + field: 'time', + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` + Object { + "name": "time", + "values": Array [ + 5, + 6, + 7, + 8, + 9, + 10, + ], + } + `); + }); + }); + + it('should sort time (desc)', async () => { + const cfg: DataTransformerConfig = { + id: DataTransformerID.sortBy, + options: { + sort: [ + { + field: 'time', + desc: true, + }, + ], + }, + }; + + await expect(transformDataFrame([cfg], [testFrame])).toEmitValuesWith((received) => { + expect(getFieldSnapshot(received[0][0].fields[0])).toMatchInlineSnapshot(` + Object { + "name": "time", + "values": Array [ + 10, + 9, + 8, + 7, + 6, + 5, + ], + } + `); + }); + }); +}); + +function getFieldSnapshot(f: Field): Object { + return { name: f.name, values: f.values.toArray() }; +} diff --git a/packages/grafana-data/src/transformations/transformers/sortBy.ts b/packages/grafana-data/src/transformations/transformers/sortBy.ts new file mode 100644 index 0000000..2e37729 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/sortBy.ts @@ -0,0 +1,65 @@ +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame } from '../../types'; +import { getFieldDisplayName } from '../../field'; +import { sortDataFrame } from '../../dataframe'; + +export interface SortByField { + field: string; + desc?: boolean; + index?: number; +} + +export interface SortByTransformerOptions { + // NOTE: this structure supports an array, however only the first entry is used + // future versions may support multi-sort options + sort: SortByField[]; +} + +export const sortByTransformer: DataTransformerInfo = { + id: DataTransformerID.sortBy, + name: 'Sort by', + description: 'Sort fields in a frame', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0 || !options?.sort?.length) { + return data; + } + return sortDataFrames(data, options.sort); + }) + ), +}; + +export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFrame[] { + return data.map((frame) => { + const s = attachFieldIndex(frame, sort); + if (s.length && s[0].index != null) { + return sortDataFrame(frame, s[0].index, s[0].desc); + } + return frame; + }); +} + +function attachFieldIndex(frame: DataFrame, sort: SortByField[]): SortByField[] { + return sort.map((s) => { + if (s.index != null) { + // null or undefined + return s; + } + return { + ...s, + index: frame.fields.findIndex((f) => s.field === getFieldDisplayName(f, frame)), + }; + }); +} diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts new file mode 100644 index 0000000..6d2db5b --- /dev/null +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -0,0 +1,95 @@ +import { ComponentType } from 'react'; +import { RegistryItem, Registry } from '../utils/Registry'; +import { + NumberFieldConfigSettings, + SliderFieldConfigSettings, + SelectFieldConfigSettings, + StringFieldConfigSettings, +} from '../field'; +import { OptionEditorConfig } from './options'; + +/** + * Option editor registry item + */ +export interface OptionsEditorItem + extends RegistryItem, + OptionEditorConfig { + /** + * React component used to edit the options property + */ + editor: ComponentType; + + /* + * @param value + */ + getItemsCount?: (value?: TValue) => number; +} + +/** + * Describes an API for option editors UI builder + */ +export interface OptionsUIRegistryBuilderAPI< + TOptions, + TEditorProps, + T extends OptionsEditorItem +> { + addNumberInput?( + config: OptionEditorConfig + ): this; + + addSliderInput?( + config: OptionEditorConfig + ): this; + + addTextInput?( + config: OptionEditorConfig + ): this; + + addStringArray?( + config: OptionEditorConfig + ): this; + + addSelect?>( + config: OptionEditorConfig + ): this; + + addRadio? = SelectFieldConfigSettings>( + config: OptionEditorConfig + ): this; + + addBooleanSwitch?(config: OptionEditorConfig): this; + + addUnitPicker?(config: OptionEditorConfig): this; + + addColorPicker?(config: OptionEditorConfig): this; + + /** + * Enables custom editor definition + * @param config + */ + addCustomEditor(config: OptionsEditorItem): this; + + /** + * Returns registry of option editors + */ + getRegistry: () => Registry; +} + +export abstract class OptionsUIRegistryBuilder< + TOptions, + TEditorProps, + T extends OptionsEditorItem +> implements OptionsUIRegistryBuilderAPI { + private properties: T[] = []; + + addCustomEditor(config: T & OptionsEditorItem): this { + this.properties.push(config); + return this; + } + + getRegistry() { + return new Registry(() => { + return this.properties; + }); + } +} diff --git a/packages/grafana-data/src/types/ScopedVars.ts b/packages/grafana-data/src/types/ScopedVars.ts new file mode 100644 index 0000000..b066d3a --- /dev/null +++ b/packages/grafana-data/src/types/ScopedVars.ts @@ -0,0 +1,7 @@ +export interface ScopedVar { + text: any; + value: T; + [key: string]: any; +} + +export interface ScopedVars extends Record {} diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts new file mode 100644 index 0000000..e907725 --- /dev/null +++ b/packages/grafana-data/src/types/annotations.ts @@ -0,0 +1,91 @@ +import { Observable } from 'rxjs'; +import { ComponentType } from 'react'; + +import { DataQuery, QueryEditorProps } from './datasource'; +import { DataFrame } from './dataFrame'; + +/** + * This JSON object is stored in the dashboard json model. + */ +export interface AnnotationQuery { + datasource: string; + enable: boolean; + name: string; + iconColor: string; + hide?: boolean; + + // Standard datasource query + target?: TQuery; + + // Convert a dataframe to an AnnotationEvent + mappings?: AnnotationEventMappings; +} + +export interface AnnotationEvent { + id?: string; + annotation?: any; + dashboardId?: number; + panelId?: number; + userId?: number; + login?: string; + email?: string; + avatarUrl?: string; + time?: number; + timeEnd?: number; + isRegion?: boolean; + title?: string; + text?: string; + type?: string; + tags?: string[]; + color?: string; + + // Currently used to merge annotations from alerts and dashboard + source?: any; // source.type === 'dashboard' +} + +/** + * @alpha -- any value other than `field` is experimental + */ +export enum AnnotationEventFieldSource { + Field = 'field', // Default -- find the value with a matching key + Text = 'text', // Write a constant string into the value + Skip = 'skip', // Do not include the field +} + +export interface AnnotationEventFieldMapping { + source?: AnnotationEventFieldSource; // defaults to 'field' + value?: string; + regex?: string; +} + +export type AnnotationEventMappings = Partial>; + +/** + * Since Grafana 7.2 + * + * This offers a generic approach to annotation processing + */ +export interface AnnotationSupport> { + /** + * This hook lets you manipulate any existing stored values before running them though the processor. + * This is particularly helpful when dealing with migrating old formats. ie query as a string vs object + */ + prepareAnnotation?(json: any): TAnno; + + /** + * Convert the stored JSON model to a standard datasource query object. + * This query will be executed in the datasource and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery?(anno: TAnno): TQuery | undefined; + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents?(anno: TAnno, data: DataFrame[]): Observable; + + /** + * Specify a custom QueryEditor for the annotation page. If not specified, the standard one will be used + */ + QueryEditor?: ComponentType>; +} diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts new file mode 100644 index 0000000..ed76f8b --- /dev/null +++ b/packages/grafana-data/src/types/app.ts @@ -0,0 +1,89 @@ +import { ComponentClass } from 'react'; +import { KeyValue } from './data'; +import { NavModel } from './navModel'; +import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; + +export enum CoreApp { + Dashboard = 'dashboard', + Explore = 'explore', +} + +export interface AppRootProps { + meta: AppPluginMeta; + + path: string; // The URL path to this page + query: KeyValue; // The URL query parameters + + /** + * Pass the nav model to the container... is there a better way? + */ + onNavChanged: (nav: NavModel) => void; +} + +export interface AppPluginMeta extends PluginMeta { + // TODO anything specific to apps? +} + +export class AppPlugin extends GrafanaPlugin> { + // Content under: /a/${plugin-id}/* + root?: ComponentClass>; + rootNav?: NavModel; // Initial navigation model + + // Old style pages + angularPages?: { [component: string]: any }; + + /** + * Called after the module has loaded, and before the app is used. + * This function may be called multiple times on the same instance. + * The first time, `this.meta` will be undefined + */ + init(meta: AppPluginMeta) {} + + /** + * Set the component displayed under: + * /a/${plugin-id}/* + * + * If the NavModel is configured, the page will have a managed frame, otheriwse it has full control. + * + * NOTE: this structure will change in 7.2+ so that it is managed with a normal react router + */ + setRootPage(root: ComponentClass>, rootNav?: NavModel) { + this.root = root; + this.rootNav = rootNav; + return this; + } + + setComponentsFromLegacyExports(pluginExports: any) { + if (pluginExports.ConfigCtrl) { + this.angularConfigCtrl = pluginExports.ConfigCtrl; + } + + if (this.meta && this.meta.includes) { + for (const include of this.meta.includes) { + if (include.type === PluginIncludeType.page && include.component) { + const exp = pluginExports[include.component]; + + if (!exp) { + console.warn('App Page uses unknown component: ', include.component, this.meta); + continue; + } + + if (!this.angularPages) { + this.angularPages = {}; + } + + this.angularPages[include.component] = exp; + } + } + } + } +} + +/** + * Defines life cycle of a feature + * @internal + */ +export enum FeatureState { + alpha = 'alpha', + beta = 'beta', +} diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts new file mode 100644 index 0000000..23f2962 --- /dev/null +++ b/packages/grafana-data/src/types/config.ts @@ -0,0 +1,130 @@ +import { DataSourceInstanceSettings } from './datasource'; +import { PanelPluginMeta } from './panel'; +import { GrafanaTheme } from './theme'; +import { SystemDateFormatSettings } from '../datetime'; + +/** + * Describes the build information that will be available via the Grafana configuration. + * + * @public + */ +export interface BuildInfo { + version: string; + commit: string; + /** + * Is set to true when running Grafana Enterprise edition. + * + * @deprecated use `licenseInfo.hasLicense` instead + */ + isEnterprise: boolean; + env: string; + edition: GrafanaEdition; + latestVersion: string; + hasUpdate: boolean; + hideVersion: boolean; +} + +/** + * @internal + */ +export enum GrafanaEdition { + OpenSource = 'Open Source', + Pro = 'Pro', + Enterprise = 'Enterprise', +} + +/** + * Describes available feature toggles in Grafana. These can be configured via the + * `conf/custom.ini` to enable features under development or not yet available in + * stable version. + * + * @public + */ +export interface FeatureToggles { + live: boolean; + ngalert: boolean; + panelLibrary: boolean; + + /** + * @remarks + * Available only in Grafana Enterprise + */ + meta: boolean; + reportVariables: boolean; +} + +/** + * Describes the license information about the current running instance of Grafana. + * + * @public + */ +export interface LicenseInfo { + hasLicense: boolean; + expiry: number; + licenseUrl: string; + stateInfo: string; + hasValidLicense: boolean; + edition: GrafanaEdition; +} + +/** + * Describes Sentry integration config + * + * @public + */ +export interface SentryConfig { + enabled: boolean; + dsn: string; + customEndpoint: string; + sampleRate: number; +} + +/** + * Describes all the different Grafana configuration values available for an instance. + * + * @public + */ +export interface GrafanaConfig { + datasources: { [str: string]: DataSourceInstanceSettings }; + panels: { [key: string]: PanelPluginMeta }; + minRefreshInterval: string; + appSubUrl: string; + windowTitlePrefix: string; + buildInfo: BuildInfo; + newPanelTitle: string; + bootData: any; + externalUserMngLinkUrl: string; + externalUserMngLinkName: string; + externalUserMngInfo: string; + allowOrgCreate: boolean; + disableLoginForm: boolean; + defaultDatasource: string; + alertingEnabled: boolean; + alertingErrorOrTimeout: string; + alertingNoDataOrNullValues: string; + alertingMinInterval: number; + authProxyEnabled: boolean; + exploreEnabled: boolean; + ldapEnabled: boolean; + sigV4AuthEnabled: boolean; + samlEnabled: boolean; + autoAssignOrg: boolean; + verifyEmailEnabled: boolean; + oauth: any; + disableUserSignUp: boolean; + loginHint: any; + passwordHint: any; + loginError: any; + navTree: any; + viewersCanEdit: boolean; + editorsCanAdmin: boolean; + disableSanitizeHtml: boolean; + theme: GrafanaTheme; + pluginsToPreload: string[]; + featureToggles: FeatureToggles; + licenseInfo: LicenseInfo; + http2Enabled: boolean; + dateFormats?: SystemDateFormatSettings; + sentry: SentryConfig; + customTheme?: any; +} diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts new file mode 100644 index 0000000..bb0c8b9 --- /dev/null +++ b/packages/grafana-data/src/types/data.ts @@ -0,0 +1,168 @@ +import { FieldConfig } from './dataFrame'; +import { DataTransformerConfig } from './transformations'; +import { ApplyFieldOverrideOptions } from './fieldOverrides'; + +export type KeyValue = Record; + +/** + * Represent panel data loading state. + * @public + */ +export enum LoadingState { + NotStarted = 'NotStarted', + Loading = 'Loading', + Streaming = 'Streaming', + Done = 'Done', + Error = 'Error', +} + +export enum DataTopic { + Annotations = 'annotations', +} + +export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace' | 'nodeGraph'; + +/** + * @public + */ +export interface QueryResultMeta { + /** DatasSource Specific Values */ + custom?: Record; + + /** Stats */ + stats?: QueryResultMetaStat[]; + + /** Meta Notices */ + notices?: QueryResultMetaNotice[]; + + /** Used to track transformation ids that where part of the processing */ + transformations?: string[]; + + /** Currently used to show results in Explore only in preferred visualisation option */ + preferredVisualisationType?: PreferredVisualisationType; + + /** + * Optionally identify which topic the frame should be assigned to. + * A value specified in the response will override what the request asked for. + */ + dataTopic?: DataTopic; + + /** + * This is the raw query sent to the underlying system. All macros and templating + * as been applied. When metadata contains this value, it will be shown in the query inspector + */ + executedQueryString?: string; + + /** + * A browsable path on the datasource + */ + path?: string; + + /** + * defaults to '/' + */ + pathSeparator?: string; + + /** + * Legacy data source specific, should be moved to custom + * */ + alignmentPeriod?: number; // used by cloud monitoring + searchWords?: string[]; // used by log models and loki + limit?: number; // used by log models and loki + json?: boolean; // used to keep track of old json doc values + instant?: boolean; +} + +export interface QueryResultMetaStat extends FieldConfig { + displayName: string; + value: number; +} + +/** + * QueryResultMetaNotice is a structure that provides user notices for query result data + * @public + */ +export interface QueryResultMetaNotice { + /** + * Specify the notice severity + */ + severity: 'info' | 'warning' | 'error'; + + /** + * Notice descriptive text + */ + text: string; + + /** + * An optional link that may be displayed in the UI. + * This value may be an absolute URL or relative to grafana root + */ + link?: string; + + /** + * Optionally suggest an appropriate tab for the panel inspector + */ + inspect?: 'meta' | 'error' | 'data' | 'stats'; +} + +/** + * @public + */ +export interface QueryResultBase { + /** + * Matches the query target refId + */ + refId?: string; + + /** + * Used by some backend data sources to communicate back info about the execution (generated sql, timing) + */ + meta?: QueryResultMeta; +} + +export interface Labels { + [key: string]: string; +} + +export interface Column { + text: string; // For a Column, the 'text' is the field name + filterable?: boolean; + unit?: string; + custom?: Record; +} + +export interface TableData extends QueryResultBase { + name?: string; + columns: Column[]; + rows: any[][]; + type?: string; +} + +export type TimeSeriesValue = number | null; + +export type TimeSeriesPoints = TimeSeriesValue[][]; + +export interface TimeSeries extends QueryResultBase { + target: string; + /** + * If name is manually configured via an alias / legend pattern + */ + title?: string; + datapoints: TimeSeriesPoints; + unit?: string; + tags?: Labels; +} + +export enum NullValueMode { + Null = 'null', + Ignore = 'connected', + AsZero = 'null as zero', +} + +/** + * Describes and API for exposing panel specific data configurations. + */ +export interface DataConfigSource { + getTransformations: () => DataTransformerConfig[] | undefined; + getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined; +} diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts new file mode 100644 index 0000000..154d64a --- /dev/null +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -0,0 +1,222 @@ +import { ThresholdsConfig } from './thresholds'; +import { ValueMapping } from './valueMapping'; +import { QueryResultBase, Labels, NullValueMode } from './data'; +import { DisplayProcessor, DisplayValue } from './displayValue'; +import { DataLink, LinkModel } from './dataLink'; +import { Vector } from './vector'; +import { FieldColor } from './fieldColor'; +import { ScopedVars } from './ScopedVars'; + +export enum FieldType { + time = 'time', // or date + number = 'number', + string = 'string', + boolean = 'boolean', + // Used to detect that the value is some kind of trace data to help with the visualisation and processing. + trace = 'trace', + other = 'other', // Object, Array, etc +} + +/** + * Every property is optional + * + * Plugins may extend this with additional properties. Something like series overrides + */ +export interface FieldConfig { + /** + * The display value for this field. This supports template variables blank is auto + */ + displayName?: string; + + /** + * This can be used by data sources that return and explicit naming structure for values and labels + * When this property is configured, this value is used rather than the default naming strategy. + */ + displayNameFromDS?: string; + + /** + * Human readable field metadata + */ + description?: string; + + /** + * An explict path to the field in the datasource. When the frame meta includes a path, + * This will default to `${frame.meta.path}/${field.name} + * + * When defined, this value can be used as an identifier within the datasource scope, and + * may be used to update the results + */ + path?: string; + + /** + * True if data source can write a value to the path. Auth/authz are supported separately + */ + writeable?: boolean; + + /** + * True if data source field supports ad-hoc filters + */ + filterable?: boolean; + + // Numeric Options + unit?: string; + decimals?: number | null; // Significant digits (for display) + min?: number | null; + max?: number | null; + + // Convert input values into a display string + mappings?: ValueMapping[]; + + // Map numeric values to states + thresholds?: ThresholdsConfig; + + // Map values to a display color + color?: FieldColor; + + // Used when reducing field values + nullValueMode?: NullValueMode; + + // The behavior when clicking on a result + links?: DataLink[]; + + // Alternative to empty string + noValue?: string; + + // Panel Specific Values + custom?: TOptions; +} + +export interface ValueLinkConfig { + /** + * Result of field reduction + */ + calculatedValue?: DisplayValue; + /** + * Index of the value row within Field. Should be provided only when value is not a result of a reduction + */ + valueRowIndex?: number; +} + +export interface Field> { + /** + * Name of the field (column) + */ + name: string; + /** + * Field value type (string, number, etc) + */ + type: FieldType; + /** + * Meta info about how field and how to display it + */ + config: FieldConfig; + values: V; // The raw field values + labels?: Labels; + + /** + * Cached values with appropriate display and id values + */ + state?: FieldState | null; + + /** + * Convert text to the field value + */ + parse?: (value: any) => T; + + /** + * Convert a value for display + */ + display?: DisplayProcessor; + + /** + * Get value data links with variables interpolated + */ + getLinks?: (config: ValueLinkConfig) => Array>; +} + +export interface FieldState { + /** + * An appropriate name for the field (does not include frame info) + */ + displayName?: string | null; + + /** + * Cache of reduced values + */ + calcs?: FieldCalcs; + + /** + * The numeric range for values in this field. This value will respect the min/max + * set in field config, or when set to `auto` this will have the min/max for all data + * in the response + */ + range?: NumericRange; + + /** + * Appropriate values for templating + */ + scopedVars?: ScopedVars; + + /** + * Series index is index for this field in a larger data set that can span multiple DataFrames + * Useful for assigning color to series by looking up a color in a palette using this index + */ + seriesIndex?: number; + + /** + * Location of this field within the context frames results + * + * @internal -- we will try to make this unnecessary + */ + origin?: DataFrameFieldIndex; +} + +export interface NumericRange { + min?: number | null; + max?: number | null; + delta: number; +} + +export interface DataFrame extends QueryResultBase { + name?: string; + fields: Field[]; // All fields of equal length + + // The number of rows + length: number; +} + +/** + * Like a field, but properties are optional and values may be a simple array + */ +export interface FieldDTO { + name: string; // The column name + type?: FieldType; + config?: FieldConfig; + values?: Vector | T[]; // toJSON will always be T[], input could be either + labels?: Labels; +} + +/** + * Like a DataFrame, but fields may be a FieldDTO + */ +export interface DataFrameDTO extends QueryResultBase { + name?: string; + fields: Array; +} + +export interface FieldCalcs extends Record {} + +export const TIME_SERIES_VALUE_FIELD_NAME = 'Value'; +export const TIME_SERIES_TIME_FIELD_NAME = 'Time'; +export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric'; + +/** + * Describes where a specific data frame field is located within a + * dataset of type DataFrame[] + * + * @internal -- we will try to make this unnecessary + */ +export interface DataFrameFieldIndex { + frameIndex: number; + fieldIndex: number; +} diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts new file mode 100644 index 0000000..b83f182 --- /dev/null +++ b/packages/grafana-data/src/types/dataLink.ts @@ -0,0 +1,91 @@ +import { DataQuery } from './datasource'; +import { InterpolateFunction } from './panel'; + +/** + * Callback info for DataLink click events + */ +export interface DataLinkClickEvent { + origin: T; + replaceVariables: InterpolateFunction | undefined; + e?: any; // mouse|react event +} + +/** + * Link configuration. The values may contain variables that need to be + * processed before showing the link to user. + * + * TODO: is not strictly true for internal links as we do not need refId for example but all + * data source defined queries extend this so this is more for documentation. + */ +export interface DataLink { + title: string; + targetBlank?: boolean; + + // 3: The URL if others did not set it first + url: string; + + // 2: If exists, use this to construct the URL + // Not saved in JSON/DTO + onBuildUrl?: (event: DataLinkClickEvent) => string; + + // 1: If exists, handle click directly + // Not saved in JSON/DTO + onClick?: (event: DataLinkClickEvent) => void; + + // If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular + // data source that we want to show to the user. Usually this results in a link to explore but can also lead to + // more custom onClick behaviour if needed. + // @internal and subject to change in future releases + internal?: InternalDataLink; +} + +/** @internal */ +export interface InternalDataLink { + query: T; + datasourceUid: string; + datasourceName: string; +} + +export type LinkTarget = '_blank' | '_self' | undefined; + +/** + * Processed Link Model. The values are ready to use + */ +export interface LinkModel { + href: string; + title: string; + target: LinkTarget; + origin: T; + + // When a click callback exists, this is passed the raw mouse|react event + onClick?: (e: any) => void; +} + +/** + * Provides a way to produce links on demand + * + * TODO: ScopedVars in in GrafanaUI package! + */ +export interface LinkModelSupplier { + getLinks(replaceVariables?: InterpolateFunction): Array>; +} + +export enum VariableOrigin { + Series = 'series', + Field = 'field', + Fields = 'fields', + Value = 'value', + BuiltIn = 'built-in', + Template = 'template', +} + +export interface VariableSuggestion { + value: string; + label: string; + documentation?: string; + origin: VariableOrigin; +} + +export enum VariableSuggestionsScope { + Values = 'values', +} diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts new file mode 100644 index 0000000..6a7aff0 --- /dev/null +++ b/packages/grafana-data/src/types/datasource.ts @@ -0,0 +1,639 @@ +import { Observable } from 'rxjs'; +import { ComponentType } from 'react'; +import { GrafanaPlugin, PluginMeta } from './plugin'; +import { PanelData } from './panel'; +import { LogRowModel } from './logs'; +import { AnnotationEvent, AnnotationSupport } from './annotations'; +import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data'; +import { DataFrame, DataFrameDTO } from './dataFrame'; +import { RawTimeRange, TimeRange } from './time'; +import { ScopedVars } from './ScopedVars'; +import { CoreApp } from './app'; +import { LiveChannelSupport } from './live'; +import { CustomVariableSupport, DataSourceVariableSupport, StandardVariableSupport } from './variables'; + +export interface DataSourcePluginOptionsEditorProps { + options: DataSourceSettings; + onOptionsChange: (options: DataSourceSettings) => void; +} + +// Utility type to extract the query type TQuery from a class extending DataSourceApi +export type DataSourceQueryType = DSType extends DataSourceApi ? TQuery : never; + +// Utility type to extract the options type TOptions from a class extending DataSourceApi +export type DataSourceOptionsType = DSType extends DataSourceApi ? TOptions : never; + +export class DataSourcePlugin< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType, + TSecureOptions = {} +> extends GrafanaPlugin> { + components: DataSourcePluginComponents = {}; + + constructor(public DataSourceClass: DataSourceConstructor) { + super(); + } + + setConfigEditor(editor: ComponentType>) { + this.components.ConfigEditor = editor; + return this; + } + + setConfigCtrl(ConfigCtrl: any) { + this.angularConfigCtrl = ConfigCtrl; + return this; + } + + setQueryCtrl(QueryCtrl: any) { + this.components.QueryCtrl = QueryCtrl; + return this; + } + + setAnnotationQueryCtrl(AnnotationsQueryCtrl: any) { + this.components.AnnotationsQueryCtrl = AnnotationsQueryCtrl; + return this; + } + + setQueryEditor(QueryEditor: ComponentType>) { + this.components.QueryEditor = QueryEditor; + return this; + } + + setExploreQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreQueryField = ExploreQueryField; + return this; + } + + setExploreMetricsQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreMetricsQueryField = ExploreQueryField; + return this; + } + + setExploreLogsQueryField(ExploreQueryField: ComponentType>) { + this.components.ExploreLogsQueryField = ExploreQueryField; + return this; + } + + setQueryEditorHelp(QueryEditorHelp: ComponentType) { + this.components.QueryEditorHelp = QueryEditorHelp; + return this; + } + + /** + * @deprecated prefer using `setQueryEditorHelp` + */ + setExploreStartPage(ExploreStartPage: ComponentType) { + return this.setQueryEditorHelp(ExploreStartPage); + } + + /* + * @deprecated -- prefer using {@link StandardVariableSupport} or {@link CustomVariableSupport} or {@link DataSourceVariableSupport} in data source instead + * */ + setVariableQueryEditor(VariableQueryEditor: any) { + this.components.VariableQueryEditor = VariableQueryEditor; + return this; + } + + setMetadataInspector(MetadataInspector: ComponentType>) { + this.components.MetadataInspector = MetadataInspector; + return this; + } + + setComponentsFromLegacyExports(pluginExports: any) { + this.angularConfigCtrl = pluginExports.ConfigCtrl; + + this.components.QueryCtrl = pluginExports.QueryCtrl; + this.components.AnnotationsQueryCtrl = pluginExports.AnnotationsQueryCtrl; + this.components.ExploreQueryField = pluginExports.ExploreQueryField; + this.components.QueryEditor = pluginExports.QueryEditor; + this.components.QueryEditorHelp = pluginExports.QueryEditorHelp; + this.components.VariableQueryEditor = pluginExports.VariableQueryEditor; + } +} + +export interface DataSourcePluginMeta extends PluginMeta { + builtIn?: boolean; // Is this for all + metrics?: boolean; + logs?: boolean; + annotations?: boolean; + alerting?: boolean; + tracing?: boolean; + mixed?: boolean; + hasQueryHelp?: boolean; + category?: string; + queryOptions?: PluginMetaQueryOptions; + sort?: number; + streaming?: boolean; + unlicensed?: boolean; +} + +interface PluginMetaQueryOptions { + cacheTimeout?: boolean; + maxDataPoints?: boolean; + minInterval?: boolean; +} + +export interface DataSourcePluginComponents< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData, + TSecureOptions = {} +> { + QueryCtrl?: any; + AnnotationsQueryCtrl?: any; + VariableQueryEditor?: any; + QueryEditor?: ComponentType>; + ExploreQueryField?: ComponentType>; + ExploreMetricsQueryField?: ComponentType>; + ExploreLogsQueryField?: ComponentType>; + QueryEditorHelp?: ComponentType; + ConfigEditor?: ComponentType>; + MetadataInspector?: ComponentType>; +} + +// Only exported for tests +export interface DataSourceConstructor< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> { + new (instanceSettings: DataSourceInstanceSettings, ...args: any[]): DSType; +} + +/** + * The main data source abstraction interface, represents an instance of a data source + * + * Although this is a class, datasource implementations do not *yet* need to extend it. + * As such, we can not yet add functions with default implementations. + */ +export abstract class DataSourceApi< + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> { + /** + * Set in constructor + */ + readonly name: string; + + /** + * Set in constructor + */ + readonly id: number; + + /** + * Set in constructor + */ + readonly type: string; + + /** + * Set in constructor + */ + readonly uid: string; + + /** + * min interval range + */ + interval?: string; + + constructor(instanceSettings: DataSourceInstanceSettings) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.type = instanceSettings.type; + this.meta = {} as DataSourcePluginMeta; + this.uid = instanceSettings.uid; + } + + /** + * Imports queries from a different datasource + */ + importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise; + + /** + * Initializes a datasource after instantiation + */ + init?: () => void; + + /** + * Query for data, and optionally stream results + */ + abstract query(request: DataQueryRequest): Promise | Observable; + + /** + * Test & verify datasource settings & connection details + */ + abstract testDatasource(): Promise; + + /** + * Get hints for query improvements + */ + getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[]; + + /** + * Convert a query to a simple text string + */ + getQueryDisplayText?(query: TQuery): string; + + /** + * Retrieve context for a given log row + */ + getLogRowContext?: ( + row: LogRowModel, + options?: TContextQueryOptions + ) => Promise; + + /** + * Variable query action. + */ + metricFindQuery?(query: any, options?: any): Promise; + + /** + * Get tag keys for adhoc filters + */ + getTagKeys?(options?: any): Promise; + + /** + * Get tag values for adhoc filters + */ + getTagValues?(options: any): Promise; + + /** + * Set after constructor call, as the data source instance is the most common thing to pass around + * we attach the components to this instance for easy access + */ + components?: DataSourcePluginComponents, TQuery, TOptions>; + + /** + * static information about the datasource + */ + meta: DataSourcePluginMeta; + + /** + * Used by alerting to check if query contains template variables + */ + targetContainsTemplate?(query: TQuery): boolean; + + /** + * Used in explore + */ + modifyQuery?(query: TQuery, action: QueryFixAction): TQuery; + + /** + * Used in explore + */ + getHighlighterExpression?(query: TQuery): string[]; + + /** + * Used in explore + */ + languageProvider?: any; + + getVersion?(optionalOptions?: any): Promise; + + showContextToggle?(row?: LogRowModel): boolean; + + interpolateVariablesInQueries?(queries: TQuery[], scopedVars: ScopedVars | {}): TQuery[]; + + /** + * An annotation processor allows explicit control for how annotations are managed. + * + * It is only necessary to configure an annotation processor if the default behavior is not desirable + */ + annotations?: AnnotationSupport; + + /** + * Can be optionally implemented to allow datasource to be a source of annotations for dashboard. + * This function will only be called if an angular {@link AnnotationsQueryCtrl} is configured and + * the {@link annotations} is undefined + * + * @deprecated -- prefer using {@link AnnotationSupport} + */ + annotationQuery?(options: AnnotationQueryRequest): Promise; + + /** + * Define live streaming behavior within this datasource settings + * + * Note: `plugin.json` must also define `live: true` + * + * @alpha -- experimental + */ + channelSupport?: LiveChannelSupport; + + /** + * Defines new variable support + * @alpha -- experimental + */ + variables?: + | StandardVariableSupport> + | CustomVariableSupport> + | DataSourceVariableSupport>; +} + +export interface MetadataInspectorProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> { + datasource: DSType; + + // All Data from this DataSource + data: DataFrame[]; +} + +export interface QueryEditorProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData, + TVQuery extends DataQuery = TQuery +> { + datasource: DSType; + query: TVQuery; + onRunQuery: () => void; + onChange: (value: TVQuery) => void; + onBlur?: () => void; + /** + * Contains query response filtered by refId of QueryResultBase and possible query error + */ + data?: PanelData; + range?: TimeRange; + exploreId?: any; + history?: HistoryItem[]; + queries?: DataQuery[]; +} + +// TODO: not really needed but used as type in some data sources and in DataQueryRequest +export enum ExploreMode { + Logs = 'Logs', + Metrics = 'Metrics', + Tracing = 'Tracing', +} + +export interface ExploreQueryFieldProps< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> extends QueryEditorProps { + history: any[]; + onBlur?: () => void; + exploreId?: any; +} + +export interface QueryEditorHelpProps { + datasource: DataSourceApi; + onClickExample: (query: DataQuery) => void; + exploreId?: any; +} + +/** + * Starting in v6.2 DataFrame can represent both TimeSeries and TableData + */ +export type LegacyResponseData = TimeSeries | TableData | any; + +export type DataQueryResponseData = DataFrame | DataFrameDTO | LegacyResponseData; + +export interface DataQueryResponse { + /** + * The response data. When streaming, this may be empty + * or a partial result set + */ + data: DataQueryResponseData[]; + + /** + * When returning multiple partial responses or streams + * Use this key to inform Grafana how to combine the partial responses + * Multiple responses with same key are replaced (latest used) + */ + key?: string; + + /** + * Optionally include error info along with the response data + */ + error?: DataQueryError; + + /** + * Use this to control which state the response should have + * Defaults to LoadingState.Done if state is not defined + */ + state?: LoadingState; +} + +/** + * These are the common properties available to all queries in all datasources + * Specific implementations will extend this interface adding the required properties + * for the given context + */ +export interface DataQuery { + /** + * A - Z + */ + refId: string; + + /** + * true if query is disabled (ie should not be returned to the dashboard) + */ + hide?: boolean; + + /** + * Unique, guid like, string used in explore mode + */ + key?: string; + + /** + * Specify the query flavor + */ + queryType?: string; + + /** + * The data topic results should be attached to + */ + dataTopic?: DataTopic; + + /** + * For mixed data sources the selected datasource is on the query level. + * For non mixed scenarios this is undefined. + */ + datasource?: string | null; +} + +export enum DataQueryErrorType { + Cancelled = 'cancelled', + Timeout = 'timeout', + Unknown = 'unknown', +} + +export interface DataQueryError { + data?: { + message?: string; + error?: string; + }; + message?: string; + status?: string; + statusText?: string; + refId?: string; + type?: DataQueryErrorType; +} + +export interface DataQueryRequest { + requestId: string; // Used to identify results and optionally cancel the request in backendSrv + + interval: string; + intervalMs: number; + maxDataPoints?: number; + range: TimeRange; + reverse?: boolean; + scopedVars: ScopedVars; + targets: TQuery[]; + timezone: string; + app: CoreApp | string; + + cacheTimeout?: string; + exploreMode?: ExploreMode; + rangeRaw?: RawTimeRange; + timeInfo?: string; // The query time description (blue text in the upper right) + panelId?: number; + dashboardId?: number; + + // Request Timing + startTime: number; + endTime?: number; + + // Explore state used by various datasources + liveStreaming?: boolean; + /** + * @deprecated showingGraph and showingTable are always set to true + */ + showingGraph?: boolean; + showingTable?: boolean; +} + +export interface DataQueryTimings { + dataProcessingTime: number; +} + +export interface QueryFix { + label: string; + action?: QueryFixAction; +} + +export interface QueryFixAction { + type: string; + query?: string; + preventSubmit?: boolean; +} + +export interface QueryHint { + type: string; + label: string; + fix?: QueryFix; +} + +export interface MetricFindValue { + text: string; + value?: string | number; + expandable?: boolean; +} + +export interface DataSourceJsonData { + authType?: string; + defaultRegion?: string; + profile?: string; +} + +/** + * Data Source instance edit model. This is returned from: + * /api/datasources + */ +export interface DataSourceSettings { + id: number; + orgId: number; + name: string; + typeLogoUrl: string; + type: string; + typeName: string; + access: string; + url: string; + password: string; + user: string; + database: string; + basicAuth: boolean; + basicAuthPassword: string; + basicAuthUser: string; + isDefault: boolean; + jsonData: T; + secureJsonData?: S; + secureJsonFields: KeyValue; + readOnly: boolean; + withCredentials: boolean; + version?: number; +} + +/** + * Frontend settings model that is passed to Datasource constructor. This differs a bit from the model above + * as this data model is available to every user who has access to a data source (Viewers+). This is loaded + * in bootData (on page load), or from: /api/frontend/settings + */ +export interface DataSourceInstanceSettings { + id: number; + uid: string; + type: string; + name: string; + meta: DataSourcePluginMeta; + url?: string; + jsonData: T; + username?: string; + password?: string; // when access is direct, for some legacy datasources + database?: string; + isDefault?: boolean; + + /** + * This is the full Authorization header if basic auth is enabled. + * Only available here when access is Browser (direct), when access is Server (proxy) + * The basic auth header, username & password is never exposed to browser/Frontend + * so this will be empty then. + */ + basicAuth?: string; + withCredentials?: boolean; +} + +/** + * @deprecated -- use {@link DataSourceInstanceSettings} instead + */ +export interface DataSourceSelectItem { + name: string; + value: string | null; + meta: DataSourcePluginMeta; +} + +/** + * Options passed to the datasource.annotationQuery method. See docs/plugins/developing/datasource.md + * + * @deprecated -- use {@link AnnotationSupport} + */ +export interface AnnotationQueryRequest { + range: TimeRange; + rangeRaw: RawTimeRange; + // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. + dashboard: any; + annotation: { + datasource: string; + enable: boolean; + name: string; + } & MoreOptions; +} + +export interface HistoryItem { + ts: number; + query: TQuery; +} + +export abstract class LanguageProvider { + abstract datasource: DataSourceApi; + abstract request: (url: string, params?: any) => Promise; + + /** + * Returns startTask that resolves with a task list when main syntax is loaded. + * Task list consists of secondary promises that load more detailed language features. + */ + abstract start: () => Promise>>; + startTask?: Promise; +} diff --git a/packages/grafana-data/src/types/displayValue.ts b/packages/grafana-data/src/types/displayValue.ts new file mode 100644 index 0000000..4d0a42d --- /dev/null +++ b/packages/grafana-data/src/types/displayValue.ts @@ -0,0 +1,45 @@ +import { FormattedValue } from '../valueFormats'; + +export type DisplayProcessor = (value: any) => DisplayValue; + +export interface DisplayValue extends FormattedValue { + /** + * Use isNaN to check if it is a real number + */ + numeric: number; + /** + * 0-1 between min & max + */ + percent?: number; + /** + * Color based on configs or Threshold + */ + color?: string; + title?: string; +} + +/** + * Explicit control for text settings + */ +export interface TextDisplayOptions { + /* Explicit text size */ + titleSize?: number; + + /* Explicit text size */ + valueSize?: number; +} + +/** + * These represents the display value with the longest title and text. + * Used to align widths and heights when displaying multiple DisplayValues + */ +export interface DisplayValueAlignmentFactors extends FormattedValue { + title: string; +} + +export type DecimalCount = number | null | undefined; + +export interface DecimalInfo { + decimals: DecimalCount; + scaledDecimals: DecimalCount; +} diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts new file mode 100644 index 0000000..18abab2 --- /dev/null +++ b/packages/grafana-data/src/types/explore.ts @@ -0,0 +1,10 @@ +import { RawTimeRange } from './time'; + +/** @internal */ +export interface ExploreUrlState { + datasource: string; + queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense + range: RawTimeRange; + originPanelId?: number; + context?: string; +} diff --git a/packages/grafana-data/src/types/fieldColor.ts b/packages/grafana-data/src/types/fieldColor.ts new file mode 100644 index 0000000..cb4b89b --- /dev/null +++ b/packages/grafana-data/src/types/fieldColor.ts @@ -0,0 +1,28 @@ +/** + * @public + */ +export enum FieldColorModeId { + Thresholds = 'thresholds', + PaletteClassic = 'palette-classic', + PaletteSaturated = 'palette-saturated', + Fixed = 'fixed', +} + +/** + * @public + */ +export interface FieldColor { + /** The main color scheme mode */ + mode: FieldColorModeId; + /** Stores the fixed color value if mode is fixed */ + fixedColor?: string; + /** Some visualizations need to know how to assign a series color from by value color schemes */ + seriesBy?: FieldColorSeriesByMode; +} + +/** + * @beta + */ +export type FieldColorSeriesByMode = 'min' | 'max' | 'last'; + +export const FALLBACK_COLOR = 'gray'; diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts new file mode 100644 index 0000000..ae99ac3 --- /dev/null +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -0,0 +1,132 @@ +import { ComponentType } from 'react'; +import { MatcherConfig, FieldConfig, Field, DataFrame, GrafanaTheme, TimeZone } from '../types'; +import { InterpolateFunction } from './panel'; +import { StandardEditorProps, FieldConfigOptionsRegistry, StandardEditorContext } from '../field'; +import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; +import { OptionEditorConfig } from './options'; + +export interface DynamicConfigValue { + id: string; + value?: any; +} + +export interface ConfigOverrideRule { + matcher: MatcherConfig; + properties: DynamicConfigValue[]; +} + +/** + * Describes config override rules created when interacting with Grafana. + * + * @internal + */ +export interface SystemConfigOverrideRule extends ConfigOverrideRule { + __systemRef: string; +} + +/** + * Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}. + * It will only return true if the {@link SystemConfigOverrideRule} has the passed systemRef. + * + * @param ref system override reference + * @internal + */ +export function isSystemOverrideWithRef(ref: string) { + return (override: ConfigOverrideRule): override is T => { + return (override as T)?.__systemRef === ref; + }; +} + +/** + * Guard functionality to check if an override rule is of type {@link SystemConfigOverrideRule}. + * It will return true if the {@link SystemConfigOverrideRule} has any systemRef set. + * + * @internal + */ +export const isSystemOverride = (override: ConfigOverrideRule): override is SystemConfigOverrideRule => { + return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string'; +}; + +export interface FieldConfigSource { + // Defaults applied to all numeric fields + defaults: FieldConfig; + + // Rules to override individual values + overrides: ConfigOverrideRule[]; +} + +export interface FieldOverrideContext extends StandardEditorContext { + field?: Field; + dataFrameIndex?: number; // The index for the selected field frame + data: DataFrame[]; // All results +} +export interface FieldConfigEditorProps + extends Omit, 'item'> { + item: FieldConfigPropertyItem; // The property info + value: TValue; + context: FieldOverrideContext; + onChange: (value?: TValue) => void; +} + +export interface FieldOverrideEditorProps extends Omit, 'item'> { + item: FieldConfigPropertyItem; + context: FieldOverrideContext; +} + +export interface FieldConfigEditorConfig + extends OptionEditorConfig { + /** + * Function that allows specifying whether or not this field config should apply to a given field. + * @param field + */ + shouldApply?: (field: Field) => boolean; + + /** Indicates that option shoukd not be available in the Field config tab */ + hideFromDefaults?: boolean; + + /** Indicates that option should not be available for the overrides */ + hideFromOverrides?: boolean; +} + +export interface FieldConfigPropertyItem + extends OptionsEditorItem, TValue> { + // An editor that can be filled in with context info (template variables etc) + override: ComponentType>; + + /** true for plugin field config properties */ + isCustom?: boolean; + + /** Hides option from the Field config tab */ + hideFromDefaults?: boolean; + + /** Indicates that option should not be available for the overrides */ + hideFromOverrides?: boolean; + + /** Convert the override value to a well typed value */ + process: (value: any, context: FieldOverrideContext, settings?: TSettings) => TValue | undefined | null; + + /** Checks if field should be processed */ + shouldApply: (field: Field) => boolean; +} + +export interface ApplyFieldOverrideOptions { + data?: DataFrame[]; + fieldConfig: FieldConfigSource; + fieldConfigRegistry?: FieldConfigOptionsRegistry; + replaceVariables: InterpolateFunction; + theme: GrafanaTheme; + timeZone?: TimeZone; +} + +export enum FieldConfigProperty { + Unit = 'unit', + Min = 'min', + Max = 'max', + Decimals = 'decimals', + DisplayName = 'displayName', + NoValue = 'noValue', + Thresholds = 'thresholds', + Mappings = 'mappings', + Links = 'links', + Color = 'color', +} diff --git a/packages/grafana-data/src/types/flot.ts b/packages/grafana-data/src/types/flot.ts new file mode 100644 index 0000000..d9b858c --- /dev/null +++ b/packages/grafana-data/src/types/flot.ts @@ -0,0 +1,8 @@ +export interface FlotDataPoint { + dataIndex: number; + datapoint: number[]; + pageX: number; + pageY: number; + series: any; + seriesIndex: number; +} diff --git a/packages/grafana-data/src/types/geometry.ts b/packages/grafana-data/src/types/geometry.ts new file mode 100644 index 0000000..9bb223e --- /dev/null +++ b/packages/grafana-data/src/types/geometry.ts @@ -0,0 +1,14 @@ +/** + * A coordinate on a two dimensional plane. + */ +export interface CartesianCoords2D { + x: number; + y: number; +} +/** + * 2d object dimensions. + */ +export interface Dimensions2D { + width: number; + height: number; +} diff --git a/packages/grafana-data/src/types/graph.ts b/packages/grafana-data/src/types/graph.ts new file mode 100644 index 0000000..91604f5 --- /dev/null +++ b/packages/grafana-data/src/types/graph.ts @@ -0,0 +1,31 @@ +import { DisplayValue } from './displayValue'; +import { Field } from './dataFrame'; + +export interface YAxis { + index: number; + min?: number; + tickDecimals?: number; +} + +export type GraphSeriesValue = number | null; + +/** View model projection of a series */ +export interface GraphSeriesXY { + color?: string; + data: GraphSeriesValue[][]; // [x,y][] + isVisible: boolean; + label: string; + yAxis: YAxis; + // Field with series' time values + timeField: Field; + // Field with series' values + valueField: Field; + seriesIndex: number; + timeStep: number; + + info?: DisplayValue[]; // Legend info +} + +export interface CreatePlotOverlay { + (element: JQuery, event: any, plot: { getOptions: () => { events: { manager: any } } }): any; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts new file mode 100644 index 0000000..3e88ca5 --- /dev/null +++ b/packages/grafana-data/src/types/index.ts @@ -0,0 +1,34 @@ +export * from './data'; +export * from './dataFrame'; +export * from './dataLink'; +export * from './annotations'; +export * from './logs'; +export * from './navModel'; +export * from './select'; +export * from './time'; +export * from './thresholds'; +export * from './valueMapping'; +export * from './displayValue'; +export * from './graph'; +export * from './ScopedVars'; +export * from './transformations'; +export * from './fieldOverrides'; +export * from './vector'; +export * from './app'; +export * from './datasource'; +export * from './panel'; +export * from './plugin'; +export * from './thresholds'; +export * from './templateVars'; +export * from './fieldColor'; +export * from './theme'; +export * from './orgs'; +export * from './flot'; +export * from './trace'; +export * from './explore'; +export * from './legacyEvents'; +export * from './live'; +export * from './variables'; +export * from './geometry'; + +export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config'; diff --git a/packages/grafana-data/src/types/legacyEvents.ts b/packages/grafana-data/src/types/legacyEvents.ts new file mode 100644 index 0000000..10c0092 --- /dev/null +++ b/packages/grafana-data/src/types/legacyEvents.ts @@ -0,0 +1,46 @@ +import { DataQueryError, DataQueryResponseData } from './datasource'; +import { AngularPanelMenuItem } from './panel'; +import { DataFrame } from './dataFrame'; +import { eventFactory } from '../events/eventFactory'; +import { BusEventBase, BusEventWithPayload } from '../events/types'; + +export type AlertPayload = [string, string?]; +export type AlertErrorPayload = [string, (string | Error)?]; + +export const AppEvents = { + alertSuccess: eventFactory('alert-success'), + alertWarning: eventFactory('alert-warning'), + alertError: eventFactory('alert-error'), +}; + +export const PanelEvents = { + refresh: eventFactory('refresh'), + componentDidMount: eventFactory('component-did-mount'), + dataReceived: eventFactory('data-received'), + dataError: eventFactory('data-error'), + dataFramesReceived: eventFactory('data-frames-received'), + dataSnapshotLoad: eventFactory('data-snapshot-load'), + editModeInitialized: eventFactory('init-edit-mode'), + initPanelActions: eventFactory('init-panel-actions'), + initialized: eventFactory('panel-initialized'), + panelTeardown: eventFactory('panel-teardown'), + render: eventFactory('render'), +}; + +/** @public */ +export interface LegacyGraphHoverEventPayload { + pos: any; + panel: { + id: number; + }; +} + +/** @alpha */ +export class LegacyGraphHoverEvent extends BusEventWithPayload { + static type = 'graph-hover'; +} + +/** @alpha */ +export class LegacyGraphHoverClearEvent extends BusEventBase { + static type = 'graph-hover-clear'; +} diff --git a/packages/grafana-data/src/types/live.ts b/packages/grafana-data/src/types/live.ts new file mode 100644 index 0000000..999be33 --- /dev/null +++ b/packages/grafana-data/src/types/live.ts @@ -0,0 +1,213 @@ +import { Observable } from 'rxjs'; + +/** + * The channel id is defined as: + * + * ${scope}/${namespace}/${path} + * + * The scope drives how the namespace is used and controlled + */ +export enum LiveChannelScope { + DataSource = 'ds', // namespace = data source ID + Plugin = 'plugin', // namespace = plugin name (singleton works for apps too) + Grafana = 'grafana', // namespace = feature +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelConfig { + /** + * The path definition. either static, or it may contain variables identifed with {varname} + */ + path: string; + + /** + * An optional description for the channel + */ + description?: string; + + /** + * The channel keeps track of who else is connected to the same channel + */ + hasPresence?: boolean; + + /** + * This method will be defined if it is possible to publish in this channel. + * The function will return true/false if the current user can publish + */ + canPublish?: () => boolean; + + /** convert the raw stream message into a message that should be broadcast */ + processMessage?: (msg: any) => TMessage; + + /** some channels are managed by an explicit interface */ + getController?: () => TController; +} + +export enum LiveChannelConnectionState { + /** The connection is not yet established */ + Pending = 'pending', + /** Connected to the channel */ + Connected = 'connected', + /** Disconnected from the channel. The channel will reconnect when possible */ + Disconnected = 'disconnected', + /** Was at some point connected, and will not try to reconnect */ + Shutdown = 'shutdown', + /** Channel configuraiton was invalid and will not connect */ + Invalid = 'invalid', +} + +export enum LiveChannelEventType { + Status = 'status', + Join = 'join', + Leave = 'leave', + Message = 'message', +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelStatusEvent { + type: LiveChannelEventType.Status; + + /** + * {scope}/{namespace}/{path} + */ + id: string; + + /** + * unix millies timestamp for the last status change + */ + timestamp: number; + + /** + * flag if the channel is actively connected to the channel. + * This may be false while the connections get established or if the network is lost + * As long as the `shutdown` flag is not set, the connection will try to reestablish + */ + state: LiveChannelConnectionState; + + /** + * The last error. + * + * This will remain in the status until a new message is successfully received from the channel + */ + error?: any; +} + +export interface LiveChannelJoinEvent { + type: LiveChannelEventType.Join; + user: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +export interface LiveChannelLeaveEvent { + type: LiveChannelEventType.Leave; + user: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +export interface LiveChannelMessageEvent { + type: LiveChannelEventType.Message; + message: T; +} + +export type LiveChannelEvent = + | LiveChannelStatusEvent + | LiveChannelJoinEvent + | LiveChannelLeaveEvent + | LiveChannelMessageEvent; + +export function isLiveChannelStatusEvent(evt: LiveChannelEvent): evt is LiveChannelStatusEvent { + return evt.type === LiveChannelEventType.Status; +} + +export function isLiveChannelJoinEvent(evt: LiveChannelEvent): evt is LiveChannelJoinEvent { + return evt.type === LiveChannelEventType.Join; +} + +export function isLiveChannelLeaveEvent(evt: LiveChannelEvent): evt is LiveChannelLeaveEvent { + return evt.type === LiveChannelEventType.Leave; +} + +export function isLiveChannelMessageEvent(evt: LiveChannelEvent): evt is LiveChannelMessageEvent { + return evt.type === LiveChannelEventType.Message; +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelPresenceStatus { + users: any; // @alpha -- experimental -- will be filled in when we improve the UI +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelAddress { + scope: LiveChannelScope; + namespace: string; // depends on the scope + path: string; +} + +/** + * Check if the address has a scope, namespace, and path + */ +export function isValidLiveChannelAddress(addr?: LiveChannelAddress): addr is LiveChannelAddress { + return !!(addr?.path && addr.namespace && addr.scope); +} + +/** + * @alpha -- experimental + */ +export interface LiveChannel { + /** The fully qualified channel id: ${scope}/${namespace}/${path} */ + id: string; + + /** The channel address */ + addr: LiveChannelAddress; + + /** Unix timestamp for when the channel connected */ + opened: number; + + /** Static definition of the channel definition. This may describe the channel usage */ + config?: LiveChannelConfig; + + /** + * Watch all events in this channel + */ + getStream: () => Observable>; + + /** + * For channels that support presence, this will request the current state from the server. + * + * Join and leave messages will be sent to the open stream + */ + getPresence?: () => Promise; + + /** + * Write a message into the channel + * + * NOTE: This feature is supported by a limited set of channels + */ + publish?: (msg: TPublish) => Promise; + + /** + * This will close and terminate this channel + */ + disconnect: () => void; +} + +/** + * @alpha -- experimental + */ +export interface LiveChannelSupport { + /** + * Get the channel handler for the path, or throw an error if invalid + */ + getChannelConfig(path: string): LiveChannelConfig | undefined; + + /** + * Return a list of supported channels + */ + getSupportedPaths(): LiveChannelConfig[]; +} diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts new file mode 100644 index 0000000..ac14171 --- /dev/null +++ b/packages/grafana-data/src/types/logs.ts @@ -0,0 +1,142 @@ +import { Labels } from './data'; +import { GraphSeriesXY } from './graph'; +import { DataFrame } from './dataFrame'; +import { AbsoluteTimeRange } from './time'; + +/** + * Mapping of log level abbreviation to canonical log level. + * Supported levels are reduce to limit color variation. + */ +export enum LogLevel { + emerg = 'critical', + fatal = 'critical', + alert = 'critical', + crit = 'critical', + critical = 'critical', + warn = 'warning', + warning = 'warning', + err = 'error', + eror = 'error', + error = 'error', + info = 'info', + information = 'info', + notice = 'info', + dbug = 'debug', + debug = 'debug', + trace = 'trace', + unknown = 'unknown', +} + +// Used for meta information such as common labels or returned log rows in logs view in Explore +export enum LogsMetaKind { + Number, + String, + LabelsMap, + Error, +} + +export enum LogsSortOrder { + Descending = 'Descending', + Ascending = 'Ascending', +} + +export interface LogsMetaItem { + label: string; + value: string | number | Labels; + kind: LogsMetaKind; +} + +export interface LogRowModel { + // Index of the field from which the entry has been created so that we do not show it later in log row details. + entryFieldIndex: number; + + // Index of the row in the dataframe. As log rows can be stitched from multiple dataFrames, this does not have to be + // the same as rows final index when rendered. + rowIndex: number; + + // Full DataFrame from which we parsed this log. + // TODO: refactor this so we do not need to pass whole dataframes in addition to also parsed data. + dataFrame: DataFrame; + duplicates?: number; + + // Actual log line + entry: string; + hasAnsi: boolean; + hasUnescapedContent: boolean; + labels: Labels; + logLevel: LogLevel; + raw: string; + searchWords?: string[]; + timeFromNow: string; + timeEpochMs: number; + // timeEpochNs stores time with nanosecond-level precision, + // as millisecond-level precision is usually not enough for proper sorting of logs + timeEpochNs: string; + timeLocal: string; + timeUtc: string; + uid: string; + uniqueLabels?: Labels; +} + +export interface LogsModel { + hasUniqueLabels: boolean; + meta?: LogsMetaItem[]; + rows: LogRowModel[]; + series?: GraphSeriesXY[]; + visibleRange?: AbsoluteTimeRange; +} + +export interface LogSearchMatch { + start: number; + length: number; + text: string; +} + +export interface LogLabelStatsModel { + active?: boolean; + count: number; + proportion: number; + value: string; +} + +export enum LogsDedupStrategy { + none = 'none', + exact = 'exact', + numbers = 'numbers', + signature = 'signature', +} + +export interface LogsParser { + /** + * Value-agnostic matcher for a field label. + * Used to filter rows, and first capture group contains the value. + */ + buildMatcher: (label: string) => RegExp; + + /** + * Returns all parsable substrings from a line, used for highlighting + */ + getFields: (line: string) => string[]; + + /** + * Gets the label name from a parsable substring of a line + */ + getLabelFromField: (field: string) => string; + + /** + * Gets the label value from a parsable substring of a line + */ + getValueFromField: (field: string) => string; + /** + * Function to verify if this is a valid parser for the given line. + * The parser accepts the line unless it returns undefined. + */ + test: (line: string) => any; +} + +export enum LogsDedupDescription { + none = 'No de-duplication', + exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.', + numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.', + signature = 'De-duplication of successive lines that have identical punctuation and whitespace.', +} diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts new file mode 100644 index 0000000..ff6ca48 --- /dev/null +++ b/packages/grafana-data/src/types/navModel.ts @@ -0,0 +1,42 @@ +export interface NavModelItem { + text: string; + url?: string; + subTitle?: string; + icon?: string; + img?: string; + id?: string; + active?: boolean; + hideFromTabs?: boolean; + hideFromMenu?: boolean; + divider?: boolean; + children?: NavModelItem[]; + breadcrumbs?: NavModelBreadcrumb[]; + target?: string; + parentItem?: NavModelItem; + showOrgSwitcher?: boolean; +} + +/** + * Interface used to describe different kinds of page titles and page navigation. Navmodels are usually generated in the backend and stored in Redux. + */ +export interface NavModel { + /** + * Main page. that wraps the navigation. Generate the `children` property generate tabs when used with the Page component. + */ + main: NavModelItem; + /** + * This is the current active tab/navigation. + */ + node: NavModelItem; + /** + * Describes breadcrumbs that are used in places such as data source settings., folder page and plugins page. + */ + breadcrumbs?: NavModelItem[]; +} + +export interface NavModelBreadcrumb { + title: string; + url?: string; +} + +export type NavIndex = { [s: string]: NavModelItem }; diff --git a/packages/grafana-data/src/types/options.ts b/packages/grafana-data/src/types/options.ts new file mode 100644 index 0000000..a3ea2bb --- /dev/null +++ b/packages/grafana-data/src/types/options.ts @@ -0,0 +1,55 @@ +import { DataFrame } from './dataFrame'; + +/** + * Base class for editor builders + * + * @beta + */ +export interface OptionEditorConfig { + /** + * Path of the option property to control. + * + * @example + * Given options object of a type: + * ```ts + * interface Options { + * a: { + * b: string; + * } + * } + * ``` + * + * path can be either 'a' or 'a.b'. + */ + path: (keyof TOptions & string) | string; + + /** + * Name of the option. Will be displayed in the UI as form element label. + */ + name: string; + + /** + * Description of the option. Will be displayed in the UI as form element description. + */ + description?: string; + + /** + * Custom settings of the editor. + */ + settings?: TSettings; + + /** + * Array of strings representing category of the option. First element in the array will make option render as collapsible section. + */ + category?: Array; + + /** + * Set this value if undefined + */ + defaultValue?: TValue; + + /** + * Function that enables configuration of when option editor should be shown based on current panel option properties. + */ + showIf?: (currentOptions: TOptions, data?: DataFrame[]) => boolean | undefined; +} diff --git a/packages/grafana-data/src/types/orgs.ts b/packages/grafana-data/src/types/orgs.ts new file mode 100644 index 0000000..e4e83a0 --- /dev/null +++ b/packages/grafana-data/src/types/orgs.ts @@ -0,0 +1,11 @@ +export interface UserOrgDTO { + orgId: number; + name: string; + role: OrgRole; +} + +export enum OrgRole { + Admin = 'Admin', + Editor = 'Editor', + Viewer = 'Viewer', +} diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts new file mode 100644 index 0000000..7de98b5 --- /dev/null +++ b/packages/grafana-data/src/types/panel.ts @@ -0,0 +1,178 @@ +import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; +import { PluginMeta } from './plugin'; +import { ScopedVars } from './ScopedVars'; +import { LoadingState } from './data'; +import { DataFrame } from './dataFrame'; +import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; +import { EventBus } from '../events'; +import { FieldConfigSource } from './fieldOverrides'; +import { Registry } from '../utils'; +import { StandardEditorProps } from '../field'; +import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; +import { OptionEditorConfig } from './options'; + +export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; + +export interface PanelPluginMeta extends PluginMeta { + /** Indicates that panel does not issue queries */ + skipDataQuery?: boolean; + /** Indicates that panel should not be available in visualisation picker */ + hideFromList?: boolean; + /** Sort order */ + sort: number; +} + +export interface PanelData { + /** State of the data (loading, done, error, streaming) */ + state: LoadingState; + + /** Contains data frames with field overrides applied */ + series: DataFrame[]; + + /** A list of annotation items */ + annotations?: DataFrame[]; + + /** Request contains the queries and properties sent to the datasource */ + request?: DataQueryRequest; + + /** Timing measurements */ + timings?: DataQueryTimings; + + /** Any query errors */ + error?: DataQueryError; + + /** Contains the range from the request or a shifted time range if a request uses relative time */ + timeRange: TimeRange; +} + +export interface PanelProps { + /** ID of the panel within the current dashboard */ + id: number; + + /** Result set of panel queries */ + data: PanelData; + + /** Time range of the current dashboard */ + timeRange: TimeRange; + + /** Time zone of the current dashboard */ + timeZone: TimeZone; + + /** Panel options */ + options: T; + + /** Indicates whether or not panel should be rendered transparent */ + transparent: boolean; + + /** Current width of the panel */ + width: number; + + /** Current height of the panel */ + height: number; + + /** Field options configuration */ + fieldConfig: FieldConfigSource; + + /** @internal */ + renderCounter: number; + + /** Panel title */ + title: string; + + /** EventBus */ + eventBus: EventBus; + + /** Panel options change handler */ + onOptionsChange: (options: T) => void; + + /** Field config change handler */ + onFieldConfigChange: (config: FieldConfigSource) => void; + + /** Template variables interpolation function */ + replaceVariables: InterpolateFunction; + + /** Time range change handler */ + onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void; +} + +export interface PanelEditorProps { + /** Panel options */ + options: T; + /** Panel options change handler */ + onOptionsChange: ( + options: T, + // callback can be used to run something right after update. + callback?: () => void + ) => void; + /** Result set of panel queries */ + data?: PanelData; +} + +export interface PanelModel { + /** ID of the panel within the current dashboard */ + id: number; + /** Panel options */ + options: TOptions; + /** Field options configuration */ + fieldConfig: FieldConfigSource; + /** Version of the panel plugin */ + pluginVersion?: string; + scopedVars?: ScopedVars; +} + +/** + * Called when a panel is first loaded with current panel model + */ +export type PanelMigrationHandler = (panel: PanelModel) => Partial; + +/** + * Called before a panel is initialized. Allows panel inspection for any updates before changing the panel type. + */ +export type PanelTypeChangedHandler = ( + panel: PanelModel, + prevPluginId: string, + prevOptions: Record, + prevFieldConfig: FieldConfigSource +) => Partial; + +export type PanelOptionEditorsRegistry = Registry; + +export interface PanelOptionsEditorProps extends StandardEditorProps {} + +export interface PanelOptionsEditorItem + extends OptionsEditorItem, TValue> {} + +export interface PanelOptionsEditorConfig + extends OptionEditorConfig {} + +/** + * @internal + */ +export interface PanelMenuItem { + type?: 'submenu' | 'divider'; + text: string; + iconClassName?: string; + onClick?: (event: React.MouseEvent) => void; + shortcut?: string; + href?: string; + subMenu?: PanelMenuItem[]; +} + +/** + * @internal + */ +export interface AngularPanelMenuItem { + click: Function; + icon: string; + href: string; + divider: boolean; + text: string; + shortcut: string; + submenu: any[]; +} + +export enum VizOrientation { + Auto = 'auto', + Vertical = 'vertical', + Horizontal = 'horizontal', +} diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts new file mode 100644 index 0000000..d7cbad6 --- /dev/null +++ b/packages/grafana-data/src/types/plugin.ts @@ -0,0 +1,194 @@ +import { ComponentClass } from 'react'; +import { KeyValue } from './data'; +import { LiveChannelSupport } from './live'; + +/** Describes plugins life cycle status */ +export enum PluginState { + alpha = 'alpha', // Only included if `enable_alpha` config option is true + beta = 'beta', // Will show a warning banner + stable = 'stable', // Will not show anything + deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add +} + +/** Describes {@link https://grafana.com/docs/grafana/latest/plugins | type of plugin} */ +export enum PluginType { + panel = 'panel', + datasource = 'datasource', + app = 'app', + renderer = 'renderer', +} + +/** Describes status of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/ | plugin signature} */ +export enum PluginSignatureStatus { + internal = 'internal', // core plugin, no signature + valid = 'valid', // signed and accurate MANIFEST + invalid = 'invalid', // invalid signature + modified = 'modified', // valid signature, but content mismatch + missing = 'missing', // missing signature file +} + +/** Describes error code returned from Grafana plugins API call */ +export enum PluginErrorCode { + missingSignature = 'signatureMissing', + invalidSignature = 'signatureInvalid', + modifiedSignature = 'signatureModified', +} + +/** Describes error returned from Grafana plugins API call */ +export interface PluginError { + errorCode: PluginErrorCode; + pluginId: string; +} + +export interface PluginMeta { + id: string; + name: string; + type: PluginType; + info: PluginMetaInfo; + includes?: PluginInclude[]; + state?: PluginState; + + // System.load & relative URLS + module: string; + baseUrl: string; + + // Define plugin requirements + dependencies?: PluginDependencies; + + // Filled in by the backend + jsonData?: T; + secureJsonData?: KeyValue; + enabled?: boolean; + defaultNavUrl?: string; + hasUpdate?: boolean; + enterprise?: boolean; + latestVersion?: string; + pinned?: boolean; + signature?: PluginSignatureStatus; + live?: boolean; +} + +interface PluginDependencyInfo { + id: string; + name: string; + version: string; + type: PluginType; +} + +export interface PluginDependencies { + grafanaVersion: string; + plugins: PluginDependencyInfo[]; +} + +export enum PluginIncludeType { + dashboard = 'dashboard', + page = 'page', + + // Only valid for apps + panel = 'panel', + datasource = 'datasource', +} + +export interface PluginInclude { + type: PluginIncludeType; + name: string; + path?: string; + icon?: string; + + role?: string; // "Viewer", Admin, editor??? + addToNav?: boolean; // Show in the sidebar... only if type=page? + + // Angular app pages + component?: string; +} + +interface PluginMetaInfoLink { + name: string; + url: string; +} + +export interface PluginBuildInfo { + time?: number; + repo?: string; + branch?: string; + hash?: string; + number?: number; + pr?: number; +} + +export interface ScreenshotInfo { + name: string; + path: string; +} + +export interface PluginMetaInfo { + author: { + name: string; + url?: string; + }; + description: string; + links: PluginMetaInfoLink[]; + logos: { + large: string; + small: string; + }; + build?: PluginBuildInfo; + screenshots: ScreenshotInfo[]; + updated: string; + version: string; +} + +export interface PluginConfigPageProps { + plugin: GrafanaPlugin; + query: KeyValue; // The URL query parameters +} + +export interface PluginConfigPage { + title: string; // Display + icon?: string; + id: string; // Unique, in URL + + body: ComponentClass>; +} + +export class GrafanaPlugin { + // Meta is filled in by the plugin loading system + meta: T; + + // This is set if the plugin system had errors loading the plugin + loadError?: boolean; + + /** + * Live streaming support + * + * Note: `plugin.json` must also define `live: true` + */ + channelSupport?: LiveChannelSupport; + + // Config control (app/datasource) + angularConfigCtrl?: any; + + // Show configuration tabs on the plugin page + configPages?: Array>; + + // Tabs on the plugin page + addConfigPage(tab: PluginConfigPage) { + if (!this.configPages) { + this.configPages = []; + } + this.configPages.push(tab); + return this; + } + + /** + * Specify how the plugin should support paths within the live streaming environment + */ + setChannelSupport(support: LiveChannelSupport) { + this.channelSupport = support; + return this; + } + + constructor() { + this.meta = {} as T; + } +} diff --git a/packages/grafana-data/src/types/select.ts b/packages/grafana-data/src/types/select.ts new file mode 100644 index 0000000..db65906 --- /dev/null +++ b/packages/grafana-data/src/types/select.ts @@ -0,0 +1,11 @@ +/** + * Used in select elements + */ +export interface SelectableValue { + label?: string; + value?: T; + imgUrl?: string; + icon?: string; + description?: string; + [key: string]: any; +} diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts new file mode 100644 index 0000000..9a11643 --- /dev/null +++ b/packages/grafana-data/src/types/templateVars.ts @@ -0,0 +1,7 @@ +export type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'; + +export interface VariableModel { + type: VariableType; + name: string; + label: string | null; +} diff --git a/packages/grafana-data/src/types/theme.ts b/packages/grafana-data/src/types/theme.ts new file mode 100644 index 0000000..5134952 --- /dev/null +++ b/packages/grafana-data/src/types/theme.ts @@ -0,0 +1,250 @@ +export enum GrafanaThemeType { + Light = 'light', + Dark = 'dark', +} + +export interface GrafanaThemeCommons { + name: string; + // TODO: not sure if should be a part of theme + breakpoints: { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + xxl: string; + }; + typography: { + fontFamily: { + sansSerif: string; + monospace: string; + }; + size: { + base: string; + xs: string; + sm: string; + md: string; + lg: string; + }; + weight: { + light: number; + regular: number; + semibold: number; + bold: number; + }; + lineHeight: { + xs: number; //1 + sm: number; //1.1 + md: number; // 4/3 + lg: number; // 1.5 + }; + // TODO: Refactor to use size instead of custom defs + heading: { + h1: string; + h2: string; + h3: string; + h4: string; + h5: string; + h6: string; + }; + link: { + decoration: string; + hoverDecoration: string; + }; + }; + spacing: { + base: number; + insetSquishMd: string; + d: string; + xxs: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + gutter: string; + + // Next-gen forms spacing variables + // TODO: Move variables definition to respective components when implementing + formSpacingBase: number; + formMargin: string; + formFieldsetMargin: string; + formInputHeight: number; + formButtonHeight: number; + formInputPaddingHorizontal: string; + // Used for icons do define spacing between icon and input field + // Applied on the right(prefix) or left(suffix) + formInputAffixPaddingHorizontal: string; + formInputMargin: string; + formLabelPadding: string; + formLabelMargin: string; + formValidationMessagePadding: string; + formValidationMessageMargin: string; + inlineFormMargin: string; + }; + border: { + radius: { + sm: string; + md: string; + lg: string; + }; + width: { + sm: string; + }; + }; + height: { + sm: number; + md: number; + lg: number; + }; + panelPadding: number; + panelHeaderHeight: number; + zIndex: { + dropdown: number; + navbarFixed: number; + sidemenu: number; + tooltip: number; + modalBackdrop: number; + modal: number; + typeahead: number; + }; +} + +export interface GrafanaTheme extends GrafanaThemeCommons { + type: GrafanaThemeType; + isDark: boolean; + isLight: boolean; + palette: { + black: string; + white: string; + dark1: string; + dark2: string; + dark3: string; + dark4: string; + dark5: string; + dark6: string; + dark7: string; + dark8: string; + dark9: string; + dark10: string; + gray1: string; + gray2: string; + gray3: string; + gray4: string; + gray5: string; + gray6: string; + gray7: string; + + // New greys palette used by next-gen form elements + gray98: string; + gray97: string; + gray95: string; + gray90: string; + gray85: string; + gray70: string; + gray60: string; + gray33: string; + gray25: string; + gray15: string; + gray10: string; + gray05: string; + + // New blues palette used by next-gen form elements + blue95: string; + blue85: string; + blue80: string; + blue77: string; + + // New reds palette used by next-gen form elements + red88: string; + + // Accent colors + redBase: string; + redShade: string; + greenBase: string; + greenShade: string; + red: string; + yellow: string; + purple: string; + orange: string; + orangeDark: string; + queryRed: string; + queryGreen: string; + queryPurple: string; + queryOrange: string; + brandPrimary: string; + brandSuccess: string; + brandWarning: string; + brandDanger: string; + + // Status colors + online: string; + warn: string; + critical: string; + }; + colors: { + bg1: string; + bg2: string; + bg3: string; + border1: string; + border2: string; + border3: string; + + bgBlue1: string; + bgBlue2: string; + + dashboardBg: string; + bodyBg: string; + panelBg: string; + panelBorder: string; + pageHeaderBg: string; + pageHeaderBorder: string; + + dropdownBg: string; + dropdownShadow: string; + dropdownOptionHoverBg: string; + + // Link colors + link: string; + linkDisabled: string; + linkHover: string; + linkExternal: string; + + // Text colors + textStrong: string; + textHeading: string; + text: string; + textSemiWeak: string; + textWeak: string; + textFaint: string; + textBlue: string; + + // Next-gen forms functional colors + formLabel: string; + formDescription: string; + formInputBg: string; + formInputBgDisabled: string; + formInputBorder: string; + formInputBorderHover: string; + formInputBorderActive: string; + formInputBorderInvalid: string; + formFocusOutline: string; + formInputText: string; + formInputDisabledText: string; + formInputPlaceholderText: string; + formValidationMessageText: string; + formValidationMessageBg: string; + formSwitchBg: string; + formSwitchBgActive: string; + formSwitchBgActiveHover: string; + formSwitchBgHover: string; + formSwitchBgDisabled: string; + formSwitchDot: string; + formCheckboxBgChecked: string; + formCheckboxBgCheckedHover: string; + formCheckboxCheckmark: string; + }; + shadows: { + listItem: string; + }; +} diff --git a/packages/grafana-data/src/types/thresholds.ts b/packages/grafana-data/src/types/thresholds.ts new file mode 100644 index 0000000..4b30798 --- /dev/null +++ b/packages/grafana-data/src/types/thresholds.ts @@ -0,0 +1,31 @@ +export interface Threshold { + value: number; + color: string; + /** + * Warning, Error, LowLow, Low, OK, High, HighHigh etc + */ + state?: string; +} + +/** + * Display mode + */ +export enum ThresholdsMode { + Absolute = 'absolute', + /** + * between 0 and 1 (based on min/max) + */ + Percentage = 'percentage', +} + +/** + * Config that is passed to the ThresholdsEditor + */ +export interface ThresholdsConfig { + mode: ThresholdsMode; + + /** + * Must be sorted by 'value', first value is always -Infinity + */ + steps: Threshold[]; +} diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts new file mode 100644 index 0000000..b417e7d --- /dev/null +++ b/packages/grafana-data/src/types/time.ts @@ -0,0 +1,53 @@ +import { dateTime, DateTime } from '../datetime/moment_wrapper'; + +export interface RawTimeRange { + from: DateTime | string; + to: DateTime | string; +} + +export interface TimeRange { + from: DateTime; + to: DateTime; + raw: RawTimeRange; +} + +export interface AbsoluteTimeRange { + from: number; + to: number; +} + +export interface IntervalValues { + interval: string; // 10s,5m + intervalMs: number; +} + +export type TimeZoneUtc = 'utc'; +export type TimeZoneBrowser = 'browser'; +export type TimeZone = TimeZoneBrowser | TimeZoneUtc | string; + +export const DefaultTimeZone: TimeZone = 'browser'; + +export interface TimeOption { + from: string; + to: string; + display: string; + section: number; +} + +export interface TimeOptions { + [key: string]: TimeOption[]; +} + +export type TimeFragment = string | DateTime; + +export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export function getDefaultTimeRange(): TimeRange { + const now = dateTime(); + + return { + from: dateTime(now).subtract(6, 'hour'), + to: now, + raw: { from: 'now-6h', to: 'now' }, + }; +} diff --git a/packages/grafana-data/src/types/trace.ts b/packages/grafana-data/src/types/trace.ts new file mode 100644 index 0000000..d5ab616 --- /dev/null +++ b/packages/grafana-data/src/types/trace.ts @@ -0,0 +1,42 @@ +/** + * Type representing a tag in a trace span or fields of a log. + */ +export type TraceKeyValuePair = { + key: string; + value: any; +}; + +/** + * Type representing a log in a span. + */ +export type TraceLog = { + // Millisecond epoch time + timestamp: number; + fields: TraceKeyValuePair[]; +}; + +/** + * This describes the structure of the dataframe that should be returned from a tracing data source to show trace + * in a TraceView component. + */ +export interface TraceSpanRow { + traceID: string; + spanID: string; + parentSpanID: string | undefined; + operationName: string; + serviceName: string; + serviceTags: TraceKeyValuePair[]; + // Millisecond epoch time + startTime: number; + // Milliseconds + duration: number; + logs?: TraceLog[]; + + // Note: To mark spen as having error add tag error: true + tags?: TraceKeyValuePair[]; + warnings?: string[]; + stackTraces?: string[]; + + // Specify custom color of the error icon + errorIconColor?: string; +} diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts new file mode 100644 index 0000000..eac4f44 --- /dev/null +++ b/packages/grafana-data/src/types/transformations.ts @@ -0,0 +1,58 @@ +import { MonoTypeOperatorFunction } from 'rxjs'; + +import { DataFrame, Field } from './dataFrame'; +import { RegistryItemWithOptions } from '../utils/Registry'; + +/** + * Function that transform data frames (AKA transformer) + */ +export interface DataTransformerInfo extends RegistryItemWithOptions { + /** + * Function that configures transformation and returns a transformer + * @param options + */ + operator: (options: TOptions) => MonoTypeOperatorFunction; +} + +export interface DataTransformerConfig { + /** + * Unique identifier of transformer + */ + id: string; + /** + * Options to be passed to the transformer + */ + options: TOptions; +} + +export type FrameMatcher = (frame: DataFrame) => boolean; +export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +/** + * Value matcher type to describe the matcher function + * @public + */ +export type ValueMatcher = (valueIndex: number, field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean; + +export interface FieldMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FieldMatcher; +} + +export interface FrameMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FrameMatcher; +} + +/** + * Registry item to represent all the different valu matchers supported + * in the Grafana platform. + * @public + */ +export interface ValueMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => ValueMatcher; + isApplicable: (field: Field) => boolean; + getDefaultOptions: (field: Field) => TOptions; +} +export interface MatcherConfig { + id: string; + options?: TOptions; +} diff --git a/packages/grafana-data/src/types/valueMapping.ts b/packages/grafana-data/src/types/valueMapping.ts new file mode 100644 index 0000000..3a2b61a --- /dev/null +++ b/packages/grafana-data/src/types/valueMapping.ts @@ -0,0 +1,21 @@ +export enum MappingType { + ValueToText = 1, + RangeToText = 2, +} + +interface BaseMap { + id: number; // this could/should just be the array index + text: string; // the final display value + type: MappingType; +} + +export type ValueMapping = ValueMap | RangeMap; + +export interface ValueMap extends BaseMap { + value: string; +} + +export interface RangeMap extends BaseMap { + from: string; + to: string; +} diff --git a/packages/grafana-data/src/types/variables.ts b/packages/grafana-data/src/types/variables.ts new file mode 100644 index 0000000..5c0aac2 --- /dev/null +++ b/packages/grafana-data/src/types/variables.ts @@ -0,0 +1,99 @@ +import { ComponentType } from 'react'; +import { Observable } from 'rxjs'; + +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceJsonData, + DataSourceOptionsType, + DataSourceQueryType, + QueryEditorProps, +} from './datasource'; + +/** + * Enum with the different variable support types + * + * @alpha -- experimental + */ +export enum VariableSupportType { + Legacy = 'legacy', + Standard = 'standard', + Custom = 'custom', + Datasource = 'datasource', +} + +/** + * Base class for VariableSupport classes + * + * @alpha -- experimental + */ +export abstract class VariableSupportBase< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> { + abstract getType(): VariableSupportType; +} + +/** + * Extend this class in a data source plugin to use the standard query editor for Query variables + * + * @alpha -- experimental + */ +export abstract class StandardVariableSupport< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Standard; + } + + abstract toDataQuery(query: StandardVariableQuery): TQuery; + query?(request: DataQueryRequest): Observable; +} + +/** + * Extend this class in a data source plugin to use a customized query editor for Query variables + * + * @alpha -- experimental + */ +export abstract class CustomVariableSupport< + DSType extends DataSourceApi, + VariableQuery extends DataQuery = any, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Custom; + } + + abstract editor: ComponentType>; + abstract query(request: DataQueryRequest): Observable; +} + +/** + * Extend this class in a data source plugin to use the query editor in the data source plugin for Query variables + * + * @alpha -- experimental + */ +export abstract class DataSourceVariableSupport< + DSType extends DataSourceApi, + TQuery extends DataQuery = DataSourceQueryType, + TOptions extends DataSourceJsonData = DataSourceOptionsType +> extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Datasource; + } +} + +/** + * Defines the standard DatQuery used by data source plugins that implement StandardVariableSupport + * + * @alpha -- experimental + */ +export interface StandardVariableQuery extends DataQuery { + query: string; +} diff --git a/packages/grafana-data/src/types/vector.ts b/packages/grafana-data/src/types/vector.ts new file mode 100644 index 0000000..76f2038 --- /dev/null +++ b/packages/grafana-data/src/types/vector.ts @@ -0,0 +1,35 @@ +export interface Vector { + length: number; + + /** + * Access the value by index (Like an array) + */ + get(index: number): T; + + /** + * Get the results as an array. + */ + toArray(): T[]; +} + +/** + * Apache arrow vectors are Read/Write + */ +export interface ReadWriteVector extends Vector { + set: (index: number, value: T) => void; +} + +/** + * Vector with standard manipulation functions + */ +export interface MutableVector extends ReadWriteVector { + /** + * Adds the value to the vector + */ + add: (value: T) => void; + + /** + * modifies the vector so it is now the opposite order + */ + reverse: () => void; +} diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts new file mode 100644 index 0000000..cd62939 --- /dev/null +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -0,0 +1,240 @@ +import { FieldConfigEditorProps, FieldConfigPropertyItem, FieldConfigEditorConfig } from '../types/fieldOverrides'; +import { OptionsUIRegistryBuilder } from '../types/OptionsUIRegistryBuilder'; +import { FieldType } from '../types/dataFrame'; +import { PanelOptionsEditorConfig, PanelOptionsEditorItem } from '../types/panel'; +import { + numberOverrideProcessor, + selectOverrideProcessor, + stringOverrideProcessor, + booleanOverrideProcessor, + standardEditorsRegistry, + SelectFieldConfigSettings, + StandardEditorProps, + StringFieldConfigSettings, + NumberFieldConfigSettings, + SliderFieldConfigSettings, + identityOverrideProcessor, + UnitFieldConfigSettings, + unitOverrideProcessor, +} from '../field'; + +/** + * Fluent API for declarative creation of field config option editors + */ +export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder< + TOptions, + FieldConfigEditorProps, + FieldConfigPropertyItem +> { + addNumberInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('number').editor as any, + editor: standardEditorsRegistry.get('number').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + + addSliderInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('slider').editor as any, + editor: standardEditorsRegistry.get('slider').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + + addTextInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('text').editor as any, + editor: standardEditorsRegistry.get('text').editor as any, + process: stringOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : (field) => field.type === FieldType.string, + settings: config.settings || {}, + }); + } + + addSelect>( + config: FieldConfigEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('select').editor as any, + editor: standardEditorsRegistry.get('select').editor as any, + process: selectOverrideProcessor, + // ??? + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || { options: [] }, + }); + } + + addRadio(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('radio').editor as any, + editor: standardEditorsRegistry.get('radio').editor as any, + process: selectOverrideProcessor, + // ??? + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || { options: [] }, + }); + } + + addBooleanSwitch(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('boolean').editor as any, + override: standardEditorsRegistry.get('boolean').editor as any, + process: booleanOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } + + addColorPicker(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('color').editor as any, + override: standardEditorsRegistry.get('color').editor as any, + process: identityOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } + + addUnitPicker( + config: FieldConfigEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('unit').editor as any, + override: standardEditorsRegistry.get('unit').editor as any, + process: unitOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } +} + +/** + * Fluent API for declarative creation of panel options + */ +export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilder< + TOptions, + StandardEditorProps, + PanelOptionsEditorItem +> { + addNumberInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('number').editor as any, + }); + } + + addSliderInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('slider').editor as any, + }); + } + + addTextInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('text').editor as any, + }); + } + + addStringArray( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('strings').editor as any, + }); + } + + addSelect>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('select').editor as any, + }); + } + + addMultiSelect>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + defaultValue: config.defaultValue ?? [], + id: config.path, + editor: standardEditorsRegistry.get('multi-select').editor as any, + }); + } + + addRadio>( + config: PanelOptionsEditorConfig + ) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('radio').editor as any, + }); + } + + addBooleanSwitch(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('boolean').editor as any, + }); + } + + addColorPicker(config: PanelOptionsEditorConfig): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('color').editor as any, + settings: config.settings || {}, + }); + } + + addTimeZonePicker(config: PanelOptionsEditorConfig): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('timezone').editor as any, + settings: config.settings || {}, + }); + } + + addUnitPicker( + config: PanelOptionsEditorConfig + ): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('unit').editor as any, + }); + } +} diff --git a/packages/grafana-data/src/utils/Registry.test.ts b/packages/grafana-data/src/utils/Registry.test.ts new file mode 100644 index 0000000..a979c2c --- /dev/null +++ b/packages/grafana-data/src/utils/Registry.test.ts @@ -0,0 +1,31 @@ +import { Registry } from './Registry'; +import { FieldReducerInfo, fieldReducers, ReducerID } from '../transformations'; + +describe('Registry', () => { + describe('selectOptions', () => { + describe('when called with current', () => { + it('then order in select.current should be same as current', () => { + const list = fieldReducers.list(); + const registry = new Registry(() => list); + const current = [ReducerID.step, ReducerID.mean, ReducerID.allIsZero, ReducerID.first, ReducerID.delta]; + const select = registry.selectOptions(current); + expect(select.current).toEqual([ + { description: 'Minimum interval between values', label: 'Step', value: 'step' }, + { description: 'Average Value', label: 'Mean', value: 'mean' }, + { description: 'All values are zero', label: 'All Zeros', value: 'allIsZero' }, + { description: 'First Value', label: 'First', value: 'first' }, + { description: 'Cumulative change in value', label: 'Delta', value: 'delta' }, + ]); + }); + + describe('when called without current', () => { + it('then it should return an empty array', () => { + const list = fieldReducers.list(); + const registry = new Registry(() => list); + const select = registry.selectOptions(); + expect(select.current).toEqual([]); + }); + }); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/Registry.ts b/packages/grafana-data/src/utils/Registry.ts new file mode 100644 index 0000000..060d4ae --- /dev/null +++ b/packages/grafana-data/src/utils/Registry.ts @@ -0,0 +1,176 @@ +import { SelectableValue } from '../types/select'; + +export interface RegistryItem { + id: string; // Unique Key -- saved in configs + name: string; // Display Name, can change without breaking configs + description?: string; + aliasIds?: string[]; // when the ID changes, we may want backwards compatibility ('current' => 'last') + + /** + * Some extensions should not be user selectable + * like: 'all' and 'any' matchers; + */ + excludeFromPicker?: boolean; +} + +export interface RegistryItemWithOptions extends RegistryItem { + /** + * Convert the options to a string + */ + getOptionsDisplayText?: (options: TOptions) => string; + + /** + * Default options used if nothing else is specified + */ + defaultOptions?: TOptions; +} + +interface RegistrySelectInfo { + options: Array>; + current: Array>; +} + +export class Registry { + private ordered: T[] = []; + private byId = new Map(); + private initialized = false; + + constructor(private init?: () => T[]) {} + + setInit = (init: () => T[]) => { + if (this.initialized) { + throw new Error('Registry already initialized'); + } + this.init = init; + }; + + getIfExists(id: string | undefined): T | undefined { + if (!this.initialized) { + this.initialize(); + } + + if (id) { + return this.byId.get(id); + } + + return undefined; + } + + private initialize() { + if (this.init) { + for (const ext of this.init()) { + this.register(ext); + } + } + this.sort(); + this.initialized = true; + } + + get(id: string): T { + const v = this.getIfExists(id); + if (!v) { + throw new Error(`"${id}" not found in: ${this.list().map((v) => v.id)}`); + } + return v; + } + + selectOptions(current?: string[], filter?: (ext: T) => boolean): RegistrySelectInfo { + if (!this.initialized) { + this.initialize(); + } + + const select = { + options: [], + current: [], + } as RegistrySelectInfo; + + const currentOptions: Record> = {}; + if (current) { + for (const id of current) { + currentOptions[id] = {}; + } + } + + for (const ext of this.ordered) { + if (ext.excludeFromPicker) { + continue; + } + if (filter && !filter(ext)) { + continue; + } + + const option = { + value: ext.id, + label: ext.name, + description: ext.description, + }; + + select.options.push(option); + if (currentOptions[ext.id]) { + currentOptions[ext.id] = option; + } + } + + if (current) { + // this makes sure we preserve the order of ids + select.current = Object.values(currentOptions); + } + + return select; + } + + /** + * Return a list of values by ID, or all values if not specified + */ + list(ids?: any[]): T[] { + if (!this.initialized) { + this.initialize(); + } + + if (ids) { + const found: T[] = []; + for (const id of ids) { + const v = this.getIfExists(id); + if (v) { + found.push(v); + } + } + return found; + } + + return this.ordered; + } + + isEmpty(): boolean { + if (!this.initialized) { + this.initialize(); + } + + return this.ordered.length === 0; + } + + register(ext: T) { + if (this.byId.has(ext.id)) { + throw new Error('Duplicate Key:' + ext.id); + } + + this.byId.set(ext.id, ext); + this.ordered.push(ext); + + if (ext.aliasIds) { + for (const alias of ext.aliasIds) { + if (!this.byId.has(alias)) { + this.byId.set(alias, ext); + } + } + } + + if (this.initialized) { + this.sort(); + } + } + + private sort() { + // TODO sort the list + } +} diff --git a/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap new file mode 100644 index 0000000..a505486 --- /dev/null +++ b/packages/grafana-data/src/utils/__snapshots__/csv.test.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`read csv should get X and y 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 1", + "type": "string", + "values": Array [ + "", + "2", + "5", + "", + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 2", + "type": "number", + "values": Array [ + 1, + 3, + 6, + NaN, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 3", + "type": "number", + "values": Array [ + undefined, + 4, + undefined, + NaN, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "Field 4", + "type": "number", + "values": Array [ + undefined, + undefined, + undefined, + 7, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`read csv should read csv from local file system 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "a", + "type": "number", + "values": Array [ + 10, + 40, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "b", + "type": "number", + "values": Array [ + 20, + 50, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "c", + "type": "number", + "values": Array [ + 30, + 60, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; + +exports[`read csv should read csv with headers 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object { + "unit": "ms", + }, + "labels": undefined, + "name": "a", + "type": "number", + "values": Array [ + 10, + 40, + 40, + 40, + ], + }, + Object { + "config": Object { + "unit": "lengthm", + }, + "labels": undefined, + "name": "b", + "type": "number", + "values": Array [ + 20, + 50, + 500, + 50, + ], + }, + Object { + "config": Object { + "unit": "s", + }, + "labels": undefined, + "name": "c", + "type": "boolean", + "values": Array [ + true, + false, + false, + true, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": undefined, +} +`; diff --git a/packages/grafana-data/src/utils/binaryOperators.ts b/packages/grafana-data/src/utils/binaryOperators.ts new file mode 100644 index 0000000..451b314 --- /dev/null +++ b/packages/grafana-data/src/utils/binaryOperators.ts @@ -0,0 +1,39 @@ +import { RegistryItem, Registry } from './Registry'; + +export enum BinaryOperationID { + Add = '+', + Subtract = '-', + Divide = '/', + Multiply = '*', +} + +export type BinaryOperation = (left: number, right: number) => number; + +interface BinaryOperatorInfo extends RegistryItem { + operation: BinaryOperation; +} + +export const binaryOperators = new Registry(() => { + return [ + { + id: BinaryOperationID.Add, + name: 'Add', + operation: (a: number, b: number) => a + b, + }, + { + id: BinaryOperationID.Subtract, + name: 'Subtract', + operation: (a: number, b: number) => a - b, + }, + { + id: BinaryOperationID.Multiply, + name: 'Multiply', + operation: (a: number, b: number) => a * b, + }, + { + id: BinaryOperationID.Divide, + name: 'Divide', + operation: (a: number, b: number) => a / b, + }, + ]; +}); diff --git a/packages/grafana-data/src/utils/csv.test.ts b/packages/grafana-data/src/utils/csv.test.ts new file mode 100644 index 0000000..0d69706 --- /dev/null +++ b/packages/grafana-data/src/utils/csv.test.ts @@ -0,0 +1,162 @@ +import { CSVHeaderStyle, readCSV, toCSV } from './csv'; +import { getDataFrameRow, toDataFrameDTO } from '../dataframe/processDataFrame'; + +// Test with local CSV files +import fs from 'fs'; +import { MutableDataFrame } from '../dataframe'; +import { getDisplayProcessor } from '../field'; + +describe('read csv', () => { + it('should get X and y', () => { + const text = ',1\n2,3,4\n5,6\n,,,7'; + const data = readCSV(text); + expect(data.length).toBe(1); + + const series = data[0]; + expect(series.fields.length).toBe(4); + + const rows = 4; + expect(series.length).toBe(rows); + + // Make sure everything is padded properly + for (const field of series.fields) { + expect(field.values.length).toBe(rows); + } + + const dto = toDataFrameDTO(series); + expect(dto).toMatchSnapshot(); + }); + + it('should read single string OK', () => { + const text = 'a,b,c'; + const data = readCSV(text); + expect(data.length).toBe(1); + + const series = data[0]; + expect(series.fields.length).toBe(3); + expect(series.length).toBe(0); + + expect(series.fields[0].name).toEqual('a'); + expect(series.fields[1].name).toEqual('b'); + expect(series.fields[2].name).toEqual('c'); + }); + + it('should read csv from local file system', () => { + const path = __dirname + '/testdata/simple.csv'; + expect(fs.existsSync(path)).toBeTruthy(); + + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + expect(data.length).toBe(1); + expect(toDataFrameDTO(data[0])).toMatchSnapshot(); + }); + + it('should read csv with headers', () => { + const path = __dirname + '/testdata/withHeaders.csv'; + expect(fs.existsSync(path)).toBeTruthy(); + + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + expect(data.length).toBe(1); + expect(toDataFrameDTO(data[0])).toMatchSnapshot(); + }); +}); + +function norm(csv: string): string { + return csv.trim().replace(/[\r]/g, ''); +} + +describe('write csv', () => { + it('should write the same CSV that we read', () => { + const firstRow = [10, 'this "has quotes" inside', true]; + const path = __dirname + '/testdata/roundtrip.csv'; + const csv = fs.readFileSync(path, 'utf8'); + const data = readCSV(csv); + const out = toCSV(data, { headerStyle: CSVHeaderStyle.full }); + expect(data.length).toBe(1); + expect(getDataFrameRow(data[0], 0)).toEqual(firstRow); + expect(data[0].fields.length).toBe(3); + expect(norm(out)).toBe(norm(csv)); + + // Keep the name even without special formatting + const again = readCSV(out); + const shorter = toCSV(again, { headerStyle: CSVHeaderStyle.name }); + + const f = readCSV(shorter); + const fields = f[0].fields; + expect(fields.length).toBe(3); + expect(getDataFrameRow(f[0], 0)).toEqual(firstRow); + expect(fields.map((f) => f.name).join(',')).toEqual('a,b,c'); // the names + }); + + it('should add Excel header given config', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1598784913123, 1598784914123] }, + { name: 'Value', values: ['1234', '5678'] }, + ], + }); + + const csv = toCSV([dataFrame], { useExcelHeader: true }); + expect(csv).toMatchInlineSnapshot(` + "sep=, + \\"Time\\",\\"Value\\" + 1598784913123,1234 + 1598784914123,5678 + + " + `); + }); +}); + +describe('DataFrame to CSV', () => { + it('should escape double quotes in the field names', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1589455688623] }, + // As we have traceId in message already this will shadow it. + { + name: 'Value', + values: ['1234'], + labels: { + label1: 'value1', + label2: 'value1', + }, + }, + ], + }); + + const csv = toCSV([dataFrame]); + expect(csv).toMatchInlineSnapshot(` + "\\"Time\\",\\"{label1=\\"\\"value1\\"\\", label2=\\"\\"value1\\"\\"}\\" + 1589455688623,1234 + + " + `); + }); + + it('should use field display processor if exists', () => { + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'Time', values: [1589455688623] }, + { + name: 'Value', + values: [1589455688623], + config: { + unit: 'dateTimeAsIso', + }, + }, + ], + }); + + dataFrame.fields[1].display = getDisplayProcessor({ field: dataFrame.fields[1], timeZone: 'utc' }); + + const csv = toCSV([dataFrame]); + expect(csv).toMatchInlineSnapshot(` + "\\"Time\\",\\"Value\\" + 1589455688623,2020-05-14 11:28:08 + + " + `); + }); +}); diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts new file mode 100644 index 0000000..17da168 --- /dev/null +++ b/packages/grafana-data/src/utils/csv.ts @@ -0,0 +1,320 @@ +// Libraries +import Papa, { ParseConfig, Parser, ParseResult } from 'papaparse'; +import defaults from 'lodash/defaults'; + +// Types +import { DataFrame, Field, FieldConfig, FieldType } from '../types'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { getFieldDisplayName } from '../field'; +import { formattedValueToString } from '../valueFormats'; + +export enum CSVHeaderStyle { + full, + name, + none, +} + +// Subset of all parse options +export interface CSVConfig { + delimiter?: string; // default: "," + newline?: string; // default: "\r\n" + quoteChar?: string; // default: '"' + encoding?: string; // default: "", + useExcelHeader?: boolean; // default: false + headerStyle?: CSVHeaderStyle; +} + +export interface CSVParseCallbacks { + /** + * Get a callback before any rows are processed + * This can return a modified table to force any + * Column configurations + */ + onHeader: (fields: Field[]) => void; + + // Called after each row is read + onRow: (row: any[]) => void; +} + +export interface CSVOptions { + config?: CSVConfig; + callback?: CSVParseCallbacks; +} + +export function readCSV(csv: string, options?: CSVOptions): DataFrame[] { + return new CSVReader(options).readCSV(csv); +} + +enum ParseState { + Starting, + InHeader, + ReadingRows, +} + +export class CSVReader { + config: CSVConfig; + callback?: CSVParseCallbacks; + + state: ParseState; + data: MutableDataFrame[]; + current: MutableDataFrame; + + constructor(options?: CSVOptions) { + if (!options) { + options = {}; + } + this.config = options.config || {}; + this.callback = options.callback; + + this.current = new MutableDataFrame({ fields: [] }); + this.state = ParseState.Starting; + this.data = []; + } + + // PapaParse callback on each line + private chunk = (results: ParseResult, parser: Parser): void => { + for (let i = 0; i < results.data.length; i++) { + const line: string[] = results.data[i]; + if (line.length < 1) { + continue; + } + const first = line[0]; // null or value, papaparse does not return '' + if (first) { + // Comment or header queue + if (first.startsWith('#')) { + // Look for special header column + // #{columkey}#a,b,c + const idx = first.indexOf('#', 2); + if (idx > 0) { + const k = first.substr(1, idx - 1); + const isName = 'name' === k; + + // Simple object used to check if headers match + const headerKeys: FieldConfig = { + unit: '#', + }; + + // Check if it is a known/supported column + if (isName || headerKeys.hasOwnProperty(k)) { + // Starting a new table after reading rows + if (this.state === ParseState.ReadingRows) { + this.current = new MutableDataFrame({ fields: [] }); + this.data.push(this.current); + } + + const v = first.substr(idx + 1); + if (isName) { + this.current.addFieldFor(undefined, v); + for (let j = 1; j < line.length; j++) { + this.current.addFieldFor(undefined, line[j]); + } + } else { + const { fields } = this.current; + for (let j = 0; j < fields.length; j++) { + if (!fields[j].config) { + fields[j].config = {}; + } + const disp = fields[j].config as any; // any lets name lookup + disp[k] = j === 0 ? v : line[j]; + } + } + + this.state = ParseState.InHeader; + continue; + } + } else if (this.state === ParseState.Starting) { + this.state = ParseState.InHeader; + continue; + } + // Ignore comment lines + continue; + } + + if (this.state === ParseState.Starting) { + const type = guessFieldTypeFromValue(first); + if (type === FieldType.string) { + for (const s of line) { + this.current.addFieldFor(undefined, s); + } + this.state = ParseState.InHeader; + continue; + } + this.state = ParseState.InHeader; // fall through to read rows + } + } + + // Add the current results to the data + if (this.state !== ParseState.ReadingRows) { + // anything??? + } + + this.state = ParseState.ReadingRows; + + // Make sure column structure is valid + if (line.length > this.current.fields.length) { + const { fields } = this.current; + for (let f = fields.length; f < line.length; f++) { + this.current.addFieldFor(line[f]); + } + if (this.callback) { + this.callback.onHeader(this.current.fields); + } + } + + this.current.appendRow(line); + if (this.callback) { + // // Send the header after we guess the type + // if (this.series.rows.length === 0) { + // this.callback.onHeader(this.series); + // } + this.callback.onRow(line); + } + } + }; + + readCSV(text: string): MutableDataFrame[] { + this.current = new MutableDataFrame({ fields: [] }); + this.data = [this.current]; + + const papacfg = { + ...this.config, + dynamicTyping: false, + skipEmptyLines: true, + comments: false, // Keep comment lines + chunk: this.chunk, + } as ParseConfig; + + Papa.parse(text, papacfg); + + return this.data; + } +} + +type FieldWriter = (value: any) => string; + +function writeValue(value: any, config: CSVConfig): string { + const str = value.toString(); + if (str.includes('"')) { + // Escape the double quote characters + return config.quoteChar + str.replace(/"/gi, '""') + config.quoteChar; + } + if (str.includes('\n') || str.includes(config.delimiter)) { + return config.quoteChar + str + config.quoteChar; + } + return str; +} + +function makeFieldWriter(field: Field, config: CSVConfig): FieldWriter { + if (field.display) { + return (value: any) => { + const displayValue = field.display!(value); + return writeValue(formattedValueToString(displayValue), config); + }; + } + + return (value: any) => writeValue(value, config); +} + +function getHeaderLine(key: string, fields: Field[], config: CSVConfig): string { + const isName = 'name' === key; + const isType = 'type' === key; + + for (const f of fields) { + const display = f.config; + if (isName || isType || (display && display.hasOwnProperty(key))) { + let line = '#' + key + '#'; + for (let i = 0; i < fields.length; i++) { + if (i > 0) { + line = line + config.delimiter; + } + + let v: any = fields[i].name; + if (isType) { + v = fields[i].type; + } else if (isName) { + // already name + } else { + v = (fields[i].config as any)[key]; + } + if (v) { + line = line + writeValue(v, config); + } + } + return line + config.newline; + } + } + return ''; +} + +function getLocaleDelimiter(): string { + const arr = ['x', 'y']; + if (arr.toLocaleString) { + return arr.toLocaleString().charAt(1); + } + return ','; +} + +export function toCSV(data: DataFrame[], config?: CSVConfig): string { + if (!data) { + return ''; + } + + config = defaults(config, { + delimiter: getLocaleDelimiter(), + newline: '\r\n', + quoteChar: '"', + encoding: '', + headerStyle: CSVHeaderStyle.name, + useExcelHeader: false, + }); + let csv = config.useExcelHeader ? `sep=${config.delimiter}${config.newline}` : ''; + + for (const series of data) { + const { fields } = series; + + // ignore frames with no fields + if (fields.length === 0) { + continue; + } + + if (config.headerStyle === CSVHeaderStyle.full) { + csv = + csv + + getHeaderLine('name', fields, config) + + getHeaderLine('type', fields, config) + + getHeaderLine('unit', fields, config) + + getHeaderLine('dateFormat', fields, config); + } else if (config.headerStyle === CSVHeaderStyle.name) { + for (let i = 0; i < fields.length; i++) { + if (i > 0) { + csv += config.delimiter; + } + csv += `"${getFieldDisplayName(fields[i], series).replace(/"/g, '""')}"`; + } + csv += config.newline; + } + + const length = fields[0].values.length; + + if (length > 0) { + const writers = fields.map((field) => makeFieldWriter(field, config!)); + for (let i = 0; i < length; i++) { + for (let j = 0; j < fields.length; j++) { + if (j > 0) { + csv = csv + config.delimiter; + } + + const v = fields[j].values.get(i); + if (v !== null) { + csv = csv + writers[j](v); + } + } + csv = csv + config.newline; + } + } + csv = csv + config.newline; + } + + return csv; +} diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts new file mode 100644 index 0000000..549a69d --- /dev/null +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -0,0 +1,39 @@ +import { mapInternalLinkToExplore } from './dataLinks'; +import { FieldType } from '../types'; +import { ArrayVector } from '../vector'; + +describe('mapInternalLinkToExplore', () => { + it('creates internal link', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: '12344' }, + }, + }; + + const link = mapInternalLinkToExplore({ + link: dataLink, + internalLink: dataLink.internal, + scopedVars: {}, + range: {} as any, + field: { + name: 'test', + type: FieldType.number, + config: {}, + values: new ArrayVector([2]), + }, + replaceVariables: (val) => val, + }); + + expect(link).toEqual( + expect.objectContaining({ + title: 'dsName', + href: '/explore?left={"datasource":"dsName","queries":[{"query":"12344"}]}', + onClick: undefined, + }) + ); + }); +}); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts new file mode 100644 index 0000000..bad07da --- /dev/null +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -0,0 +1,104 @@ +import { + DataLink, + DataQuery, + Field, + InternalDataLink, + InterpolateFunction, + LinkModel, + ScopedVars, + TimeRange, +} from '../types'; +import { locationUtil } from './location'; +import { serializeStateToUrlParam } from './url'; + +export const DataLinkBuiltInVars = { + keepTime: '__url_time_range', + timeRangeFrom: '__from', + timeRangeTo: '__to', + includeVars: '__all_variables', + seriesName: '__series.name', + fieldName: '__field.name', + valueTime: '__value.time', + valueNumeric: '__value.numeric', + valueText: '__value.text', + valueRaw: '__value.raw', + // name of the calculation represented by the value + valueCalc: '__value.calc', +}; + +// We inject these because we cannot import them directly as they reside inside grafana main package. +export type LinkToExploreOptions = { + link: DataLink; + scopedVars: ScopedVars; + range: TimeRange; + field: Field; + internalLink: InternalDataLink; + onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void; + replaceVariables: InterpolateFunction; +}; + +export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkModel { + const { onClickFn, replaceVariables, link, scopedVars, range, field, internalLink } = options; + + const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables); + const title = link.title ? link.title : internalLink.datasourceName; + + return { + title: replaceVariables(title, scopedVars), + // In this case this is meant to be internal link (opens split view by default) the href will also points + // to explore but this way you can open it in new tab. + href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range), + onClick: onClickFn + ? () => { + onClickFn({ + datasourceUid: internalLink.datasourceUid, + query: interpolatedQuery, + range, + }); + } + : undefined, + target: '_self', + origin: field, + }; +} + +/** + * Generates href for internal derived field link. + */ +function generateInternalHref(datasourceName: string, query: T, range: TimeRange): string { + return locationUtil.assureBaseUrl( + `/explore?left=${serializeStateToUrlParam({ + range: range.raw, + datasource: datasourceName, + queries: [query], + })}` + ); +} + +function interpolateQuery( + link: DataLink, + scopedVars: ScopedVars, + replaceVariables: InterpolateFunction +): T { + let stringifiedQuery = ''; + try { + stringifiedQuery = JSON.stringify(link.internal?.query || ''); + } catch (err) { + // should not happen and not much to do about this, possibly something non stringifiable in the query + console.error(err); + } + + // Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not + // actually work with every datasource query right now. + stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars); + + let replacedQuery = {} as T; + try { + replacedQuery = JSON.parse(stringifiedQuery); + } catch (err) { + // again should not happen and not much to do about this, probably some issue with how we replaced the variables. + console.error(stringifiedQuery, err); + } + + return replacedQuery; +} diff --git a/packages/grafana-data/src/utils/datasource.ts b/packages/grafana-data/src/utils/datasource.ts new file mode 100644 index 0000000..6fad3a5 --- /dev/null +++ b/packages/grafana-data/src/utils/datasource.ts @@ -0,0 +1,112 @@ +import { DataSourcePluginOptionsEditorProps, SelectableValue, KeyValue, DataSourceSettings } from '../types'; + +export const onUpdateDatasourceOption = (props: DataSourcePluginOptionsEditorProps, key: keyof DataSourceSettings) => ( + event: React.SyntheticEvent +) => { + updateDatasourcePluginOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceSecureJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginSecureJsonDataOption(props, key, event.currentTarget.value); +}; + +export const onUpdateDatasourceJsonDataOptionSelect = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (selected: SelectableValue) => { + updateDatasourcePluginJsonDataOption(props, key, selected.value); +}; + +export const onUpdateDatasourceJsonDataOptionChecked = ( + props: DataSourcePluginOptionsEditorProps, + key: K +) => (event: React.SyntheticEvent) => { + updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.checked); +}; + +export const onUpdateDatasourceSecureJsonDataOptionSelect = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => (selected: SelectableValue) => { + updateDatasourcePluginSecureJsonDataOption(props, key, selected.value); +}; + +export const onUpdateDatasourceResetOption = (props: DataSourcePluginOptionsEditorProps, key: string) => ( + event: React.MouseEvent +) => { + updateDatasourcePluginResetOption(props, key); +}; + +export function updateDatasourcePluginOption( + props: DataSourcePluginOptionsEditorProps, + key: keyof DataSourceSettings, + val: any +) { + const config = props.options; + + props.onOptionsChange({ + ...config, + [key]: val, + }); +} + +export const updateDatasourcePluginJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: K, + val: any +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + jsonData: { + ...config.jsonData, + [key]: val, + }, + }); +}; + +export const updateDatasourcePluginSecureJsonDataOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string, + val: any +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + secureJsonData: { + ...config.secureJsonData!, + [key]: val, + }, + }); +}; + +export const updateDatasourcePluginResetOption = ( + props: DataSourcePluginOptionsEditorProps, + key: string +) => { + const config = props.options; + + props.onOptionsChange({ + ...config, + secureJsonData: { + ...config.secureJsonData, + [key]: '', + }, + secureJsonFields: { + ...config.secureJsonFields, + [key]: false, + }, + }); +}; diff --git a/packages/grafana-data/src/utils/deprecationWarning.test.ts b/packages/grafana-data/src/utils/deprecationWarning.test.ts new file mode 100644 index 0000000..6da4f0d --- /dev/null +++ b/packages/grafana-data/src/utils/deprecationWarning.test.ts @@ -0,0 +1,34 @@ +import { deprecationWarning } from './deprecationWarning'; + +test('It should not output deprecation warnings too often', () => { + let dateNowValue = 10000000; + + const spyConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + const spyDateNow = jest.spyOn(global.Date, 'now').mockImplementation(() => dateNowValue); + // Make sure the mock works + expect(Date.now()).toEqual(dateNowValue); + expect(console.warn).toHaveBeenCalledTimes(0); + + // Call the deprecation many times + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(1); + + // Increment the time by 1min + dateNowValue += 60000; + deprecationWarning('file', 'oldName', 'newName'); + deprecationWarning('file', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(2); + + deprecationWarning('file2', 'oldName', 'newName'); + deprecationWarning('file2', 'oldName', 'newName'); + deprecationWarning('file2', 'oldName', 'newName'); + expect(console.warn).toHaveBeenCalledTimes(3); + + // or restoreMocks automatically? + spyConsoleWarn.mockRestore(); + spyDateNow.mockRestore(); +}); diff --git a/packages/grafana-data/src/utils/deprecationWarning.ts b/packages/grafana-data/src/utils/deprecationWarning.ts new file mode 100644 index 0000000..b959ccd --- /dev/null +++ b/packages/grafana-data/src/utils/deprecationWarning.ts @@ -0,0 +1,17 @@ +import { KeyValue } from '../types'; + +// Avoid writing the warning message more than once every 10s +const history: KeyValue = {}; + +export const deprecationWarning = (file: string, oldName: string, newName?: string) => { + let message = `[Deprecation warning] ${file}: ${oldName} is deprecated`; + if (newName) { + message += `. Use ${newName} instead`; + } + const now = Date.now(); + const last = history[message]; + if (!last || now - last > 10000) { + console.warn(message); + history[message] = now; + } +}; diff --git a/packages/grafana-data/src/utils/docs.ts b/packages/grafana-data/src/utils/docs.ts new file mode 100644 index 0000000..14e2247 --- /dev/null +++ b/packages/grafana-data/src/utils/docs.ts @@ -0,0 +1,9 @@ +/** + * Enumeration of documentation topics + * @internal + */ +export enum DocsId { + Transformations, + FieldConfig, + FieldConfigOverrides, +} diff --git a/packages/grafana-data/src/utils/fieldParser.ts b/packages/grafana-data/src/utils/fieldParser.ts new file mode 100644 index 0000000..939f4e4 --- /dev/null +++ b/packages/grafana-data/src/utils/fieldParser.ts @@ -0,0 +1,28 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; + +export function makeFieldParser(value: any, field: Field): (value: string) => any { + if (!field.type) { + if (field.name === 'time' || field.name === 'Time') { + field.type = FieldType.time; + } else { + field.type = guessFieldTypeFromValue(value); + } + } + + if (field.type === FieldType.number) { + return (value: string) => { + return parseFloat(value); + }; + } + + // Will convert anything that starts with "T" to true + if (field.type === FieldType.boolean) { + return (value: string) => { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + }; + } + + // Just pass the string back + return (value: string) => value; +} diff --git a/packages/grafana-data/src/utils/flotPairs.test.ts b/packages/grafana-data/src/utils/flotPairs.test.ts new file mode 100644 index 0000000..69dc3e1 --- /dev/null +++ b/packages/grafana-data/src/utils/flotPairs.test.ts @@ -0,0 +1,71 @@ +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { getFlotPairs, getFlotPairsConstant } from './flotPairs'; +import { TimeRange } from '../types/time'; +import { dateTime } from '../datetime/moment_wrapper'; + +describe('getFlotPairs', () => { + const series = new MutableDataFrame({ + fields: [ + { name: 'a', values: [1, 2, 3] }, + { name: 'b', values: [100, 200, 300] }, + { name: 'c', values: ['a', 'b', 'c'] }, + ], + }); + it('should get X and y', () => { + const pairs = getFlotPairs({ + xField: series.fields[0], + yField: series.fields[1], + }); + + expect(pairs.length).toEqual(3); + expect(pairs[0].length).toEqual(2); + expect(pairs[0][0]).toEqual(1); + expect(pairs[0][1]).toEqual(100); + }); + + it('should work with strings', () => { + const pairs = getFlotPairs({ + xField: series.fields[0], + yField: series.fields[2], + }); + + expect(pairs.length).toEqual(3); + expect(pairs[0].length).toEqual(2); + expect(pairs[0][0]).toEqual(1); + expect(pairs[0][1]).toEqual('a'); + }); +}); + +describe('getFlotPairsConstant', () => { + const makeRange = (from: number, to: number): TimeRange => ({ + from: dateTime(from), + to: dateTime(to), + raw: { from: `${from}`, to: `${to}` }, + }); + + it('should return an empty series on empty data', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant([], range); + expect(pairs).toMatchObject([]); + }); + + it('should return an empty series on missing range', () => { + const pairs = getFlotPairsConstant([], {} as TimeRange); + expect(pairs).toMatchObject([]); + }); + + it('should return an constant series for range', () => { + const range: TimeRange = makeRange(0, 1); + const pairs = getFlotPairsConstant( + [ + [2, 123], + [4, 456], + ], + range + ); + expect(pairs).toMatchObject([ + [0, 123], + [1, 123], + ]); + }); +}); diff --git a/packages/grafana-data/src/utils/flotPairs.ts b/packages/grafana-data/src/utils/flotPairs.ts new file mode 100644 index 0000000..7f99dd1 --- /dev/null +++ b/packages/grafana-data/src/utils/flotPairs.ts @@ -0,0 +1,67 @@ +import { Field } from '../types/dataFrame'; +import { NullValueMode } from '../types/data'; +import { GraphSeriesValue } from '../types/graph'; +import { TimeRange } from '../types/time'; + +// Types +// import { NullValueMode, GraphSeriesValue, Field, TimeRange } from '@grafana/data'; +export interface FlotPairsOptions { + xField: Field; + yField: Field; + nullValueMode?: NullValueMode; +} + +export function getFlotPairs({ xField, yField, nullValueMode }: FlotPairsOptions): GraphSeriesValue[][] { + const vX = xField.values; + const vY = yField.values; + const length = vX.length; + if (vY.length !== length) { + throw new Error('Unexpected field length'); + } + + const ignoreNulls = nullValueMode === NullValueMode.Ignore; + const nullAsZero = nullValueMode === NullValueMode.AsZero; + + const pairs: any[][] = []; + + for (let i = 0; i < length; i++) { + const x = vX.get(i); + let y = vY.get(i); + + if (y === null) { + if (ignoreNulls) { + continue; + } + if (nullAsZero) { + y = 0; + } + } + + // X must be a value + if (x === null) { + continue; + } + + pairs.push([x, y]); + } + return pairs; +} + +/** + * Returns a constant series based on the first value from the provide series. + * @param seriesData Series + * @param range Start and end time for the constant series + */ +export function getFlotPairsConstant(seriesData: GraphSeriesValue[][], range: TimeRange): GraphSeriesValue[][] { + if (!range.from || !range.to || !seriesData || seriesData.length === 0) { + return []; + } + + const from = range.from.valueOf(); + const to = range.to.valueOf(); + const value = seriesData[0][1]; + return [ + [from, value], + [to, value], + ]; +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts new file mode 100644 index 0000000..71059df --- /dev/null +++ b/packages/grafana-data/src/utils/index.ts @@ -0,0 +1,19 @@ +export * from './Registry'; +export * from './datasource'; +export * from './deprecationWarning'; +export * from './csv'; +export * from './logs'; +export * from './labels'; +export * from './labels'; +export * from './object'; +export * from './namedColorsPalette'; +export * from './series'; +export * from './binaryOperators'; +export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; + +export { getMappedValue } from './valueMappings'; +export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; +export { locationUtil } from './location'; +export { urlUtil, UrlQueryMap, UrlQueryValue, serializeStateToUrlParam } from './url'; +export { DataLinkBuiltInVars, mapInternalLinkToExplore } from './dataLinks'; +export { DocsId } from './docs'; diff --git a/packages/grafana-data/src/utils/labels.test.ts b/packages/grafana-data/src/utils/labels.test.ts new file mode 100644 index 0000000..1fb84f5 --- /dev/null +++ b/packages/grafana-data/src/utils/labels.test.ts @@ -0,0 +1,78 @@ +import { parseLabels, formatLabels, findCommonLabels, findUniqueLabels, matchAllLabels } from './labels'; +import { Labels } from '../types/data'; + +describe('parseLabels()', () => { + it('returns no labels on empty labels string', () => { + expect(parseLabels('')).toEqual({}); + expect(parseLabels('{}')).toEqual({}); + }); + + it('returns labels on labels string', () => { + expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: 'bar', baz: '42' }); + }); +}); + +describe('formatLabels()', () => { + it('returns no labels on empty label set', () => { + expect(formatLabels({})).toEqual(''); + expect(formatLabels({}, 'foo')).toEqual('foo'); + }); + + it('returns label string on label set', () => { + expect(formatLabels({ foo: 'bar', baz: '42' })).toEqual('{baz="42", foo="bar"}'); + }); +}); + +describe('findCommonLabels()', () => { + it('returns no common labels on empty sets', () => { + expect(findCommonLabels([{}])).toEqual({}); + expect(findCommonLabels([{}, {}])).toEqual({}); + }); + + it('returns no common labels on differing sets', () => { + expect(findCommonLabels([{ foo: 'bar' }, {}])).toEqual({}); + expect(findCommonLabels([{}, { foo: 'bar' }])).toEqual({}); + expect(findCommonLabels([{ baz: '42' }, { foo: 'bar' }])).toEqual({}); + expect(findCommonLabels([{ foo: '42', baz: 'bar' }, { foo: 'bar' }])).toEqual({}); + }); + + it('returns the single labels set as common labels', () => { + expect(findCommonLabels([{ foo: 'bar' }])).toEqual({ foo: 'bar' }); + }); +}); + +describe('findUniqueLabels()', () => { + it('returns no uncommon labels on empty sets', () => { + expect(findUniqueLabels({}, {})).toEqual({}); + }); + + it('returns all labels given no common labels', () => { + expect(findUniqueLabels({ foo: '"bar"' }, {})).toEqual({ foo: '"bar"' }); + }); + + it('returns all labels except the common labels', () => { + expect(findUniqueLabels({ foo: '"bar"', baz: '"42"' }, { foo: '"bar"' })).toEqual({ baz: '"42"' }); + }); +}); + +describe('matchAllLabels()', () => { + it('empty labels do math', () => { + expect(matchAllLabels({}, {})).toBeTruthy(); + }); + + it('missing labels', () => { + expect(matchAllLabels({ foo: 'bar' }, {})).toBeFalsy(); + }); + + it('extra labels should match', () => { + expect(matchAllLabels({ foo: 'bar' }, { foo: 'bar', baz: '22' })).toBeTruthy(); + }); + + it('be graceful with null values (match)', () => { + expect(matchAllLabels({ foo: 'bar' })).toBeFalsy(); + }); + + it('be graceful with null values (match)', () => { + expect(matchAllLabels((undefined as unknown) as Labels, { foo: 'bar' })).toBeTruthy(); + }); +}); diff --git a/packages/grafana-data/src/utils/labels.ts b/packages/grafana-data/src/utils/labels.ts new file mode 100644 index 0000000..10e1419 --- /dev/null +++ b/packages/grafana-data/src/utils/labels.ts @@ -0,0 +1,90 @@ +import { Labels } from '../types/data'; + +/** + * Regexp to extract Prometheus-style labels + */ +const labelRegexp = /\b(\w+)(!?=~?)"([^"\n]*?)"/g; + +/** + * Returns a map of label keys to value from an input selector string. + * + * Example: `parseLabels('{job="foo", instance="bar"}) // {job: "foo", instance: "bar"}` + */ +export function parseLabels(labels: string): Labels { + const labelsByKey: Labels = {}; + labels.replace(labelRegexp, (_, key, operator, value) => { + labelsByKey[key] = value; + return ''; + }); + return labelsByKey; +} + +/** + * Returns a map labels that are common to the given label sets. + */ +export function findCommonLabels(labelsSets: Labels[]): Labels { + return labelsSets.reduce((acc, labels) => { + if (!labels) { + throw new Error('Need parsed labels to find common labels.'); + } + if (!acc) { + // Initial set + acc = { ...labels }; + } else { + // Remove incoming labels that are missing or not matching in value + Object.keys(labels).forEach((key) => { + if (acc[key] === undefined || acc[key] !== labels[key]) { + delete acc[key]; + } + }); + // Remove common labels that are missing from incoming label set + Object.keys(acc).forEach((key) => { + if (labels[key] === undefined) { + delete acc[key]; + } + }); + } + return acc; + }, (undefined as unknown) as Labels); +} + +/** + * Returns a map of labels that are in `labels`, but not in `commonLabels`. + */ +export function findUniqueLabels(labels: Labels | undefined, commonLabels: Labels): Labels { + const uncommonLabels: Labels = { ...labels }; + Object.keys(commonLabels).forEach((key) => { + delete uncommonLabels[key]; + }); + return uncommonLabels; +} + +/** + * Check that all labels exist in another set of labels + */ +export function matchAllLabels(expect: Labels, against?: Labels): boolean { + if (!expect) { + return true; // nothing to match + } + for (const [key, value] of Object.entries(expect)) { + if (!against || against[key] !== value) { + return false; + } + } + return true; +} + +/** + * Serializes the given labels to a string. + */ +export function formatLabels(labels: Labels, defaultValue = '', withoutBraces?: boolean): string { + if (!labels || Object.keys(labels).length === 0) { + return defaultValue; + } + const labelKeys = Object.keys(labels).sort(); + const cleanSelector = labelKeys.map((key) => `${key}="${labels[key]}"`).join(', '); + if (withoutBraces) { + return cleanSelector; + } + return ['{', cleanSelector, '}'].join(''); +} diff --git a/packages/grafana-data/src/utils/location.test.ts b/packages/grafana-data/src/utils/location.test.ts new file mode 100644 index 0000000..dfedfa7 --- /dev/null +++ b/packages/grafana-data/src/utils/location.test.ts @@ -0,0 +1,21 @@ +import { locationUtil } from './location'; + +describe('locationUtil', () => { + beforeAll(() => { + locationUtil.initialize({ + getConfig: () => { + return { appSubUrl: '/subUrl' } as any; + }, + // @ts-ignore + buildParamsFromVariables: () => {}, + // @ts-ignore + getTimeRangeForUrl: () => {}, + }); + }); + describe('With /subUrl as appSubUrl', () => { + it('/subUrl should be stripped', () => { + const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/'); + expect(urlWithoutMaster).toBe('/grafana/'); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/location.ts b/packages/grafana-data/src/utils/location.ts new file mode 100644 index 0000000..af1ae51 --- /dev/null +++ b/packages/grafana-data/src/utils/location.ts @@ -0,0 +1,73 @@ +import { GrafanaConfig, RawTimeRange, ScopedVars } from '../types'; +import { urlUtil } from './url'; +import { textUtil } from '../text'; + +let grafanaConfig: () => GrafanaConfig; +let getTimeRangeUrlParams: () => RawTimeRange; +let getVariablesUrlParams: (params?: Record, scopedVars?: ScopedVars) => string; + +/** + * + * @param url + * @internal + */ +const stripBaseFromUrl = (url: string): string => { + const appSubUrl = grafanaConfig ? grafanaConfig().appSubUrl : ''; + const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0; + const urlWithoutBase = + url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url; + + return urlWithoutBase; +}; + +/** + * + * @param url + * @internal + */ +const assureBaseUrl = (url: string): string => { + if (url.startsWith('/')) { + return `${grafanaConfig ? grafanaConfig().appSubUrl : ''}${stripBaseFromUrl(url)}`; + } + return url; +}; + +interface LocationUtilDependencies { + getConfig: () => GrafanaConfig; + getTimeRangeForUrl: () => RawTimeRange; + buildParamsFromVariables: (params: any, scopedVars?: ScopedVars) => string; +} + +export const locationUtil = { + /** + * + * @param getConfig + * @param buildParamsFromVariables + * @param getTimeRangeForUrl + * @internal + */ + initialize: ({ getConfig, buildParamsFromVariables, getTimeRangeForUrl }: LocationUtilDependencies) => { + grafanaConfig = getConfig; + getTimeRangeUrlParams = getTimeRangeForUrl; + getVariablesUrlParams = buildParamsFromVariables; + }, + stripBaseFromUrl, + assureBaseUrl, + getTimeRangeUrlParams: () => { + if (!getTimeRangeUrlParams) { + return null; + } + return urlUtil.toUrlParams(getTimeRangeUrlParams()); + }, + getVariablesUrlParams: (scopedVars?: ScopedVars) => { + if (!getVariablesUrlParams) { + return null; + } + const params = {}; + getVariablesUrlParams(params, scopedVars); + return urlUtil.toUrlParams(params); + }, + processUrl: (url: string) => { + return grafanaConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); + }, +}; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts new file mode 100644 index 0000000..2a0b336 --- /dev/null +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -0,0 +1,369 @@ +import { LogLevel, LogsModel, LogRowModel, LogsSortOrder } from '../types/logs'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { + getLogLevel, + calculateLogsLabelStats, + calculateFieldStats, + getParser, + LogsParsers, + calculateStats, + getLogLevelFromKey, + sortLogsResult, + checkLogsError, +} from './logs'; + +describe('getLoglevel()', () => { + it('returns no log level on empty line', () => { + expect(getLogLevel('')).toBe(LogLevel.unknown); + }); + + it('returns no log level on when level is part of a word', () => { + expect(getLogLevel('who warns us')).toBe(LogLevel.unknown); + }); + + it('returns same log level for long and short version', () => { + expect(getLogLevel('[Warn]')).toBe(LogLevel.warning); + expect(getLogLevel('[Warning]')).toBe(LogLevel.warning); + expect(getLogLevel('[Warn]')).toBe('warning'); + }); + + it('returns correct log level when level is capitalized', () => { + expect(getLogLevel('WARN')).toBe(LogLevel.warn); + }); + + it('returns log level on line contains a log level', () => { + expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn); + expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn); + }); + + it('returns first log level found', () => { + expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn); + expect(getLogLevel('WARN this is a non-critical message')).toBe(LogLevel.warn); + }); +}); + +describe('getLogLevelFromKey()', () => { + it('returns correct log level', () => { + expect(getLogLevelFromKey('info')).toBe(LogLevel.info); + }); + it('returns correct log level when level is capitalized', () => { + expect(getLogLevelFromKey('INFO')).toBe(LogLevel.info); + }); + it('returns unknown log level when level is integer', () => { + expect(getLogLevelFromKey(1)).toBe(LogLevel.unknown); + }); +}); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should return detected fields', () => { + expect( + parser.getFields( + 'foo=bar baz="42 + 1" msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1" time(ms)=50 label{foo}=bar' + ) + ).toEqual([ + 'foo=bar', + 'baz="42 + 1"', + 'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"', + 'time(ms)=50', + 'label{foo}=bar', + ]); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('foo=bar')).toBe('foo'); + expect(parser.getLabelFromField('time(ms)=50')).toBe('time(ms)'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('foo=bar')).toBe('bar'); + expect(parser.getValueFromField('time(ms)=50')).toBe('50'); + expect( + parser.getValueFromField( + 'msg="[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"' + ) + ).toBe('"[resolver] received A record \\"127.0.0.1\\" for \\"localhost.\\" from udp:192.168.65.1"'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + + test('should build a valid complex value matcher', () => { + const matcher = parser.buildMatcher('time(ms)'); + const match = 'time(ms)=50'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('50'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should return detected fields', () => { + expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo":"bar"', '"baz":42']); + }); + + test('should return detected fields for nested quotes', () => { + expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo'); + expect(parser.getLabelFromField('"docker.memory.fail.count":0')).toBe('docker.memory.fail.count'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"'); + expect(parser.getValueFromField('"foo" : 42')).toBe('42'); + expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1'); + }); + + test('should build a valid value matcher for strings', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + + test('should build a valid value matcher for integers', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":42.1}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('42.1'); + }); + }); +}); + +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + +describe('calculateStats()', () => { + test('should return no stats for empty array', () => { + expect(calculateStats([])).toEqual([]); + }); + + test('should return correct stats', () => { + const values = ['one', 'one', null, undefined, 'two']; + expect(calculateStats(values)).toMatchObject([ + { + value: 'one', + count: 2, + proportion: 2 / 3, + }, + { + value: 'two', + count: 1, + proportion: 1 / 3, + }, + ]); + }); +}); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); + +describe('sortLogsResult', () => { + const firstRow: LogRowModel = { + rowIndex: 0, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeEpochNs: '0', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '1', + }; + const sameAsFirstRow = firstRow; + const secondRow: LogRowModel = { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '10000000', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }; + + describe('when called with LogsSortOrder.Descending', () => { + it('then it should sort descending', () => { + const logsResult: LogsModel = { + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Descending); + + expect(result).toEqual({ + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }); + }); + }); + + describe('when called with LogsSortOrder.Ascending', () => { + it('then it should sort ascending', () => { + const logsResult: LogsModel = { + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Ascending); + + expect(result).toEqual({ + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }); + }); + }); +}); + +describe('checkLogsError()', () => { + const log = ({ + labels: { + __error__: 'Error Message', + foo: 'boo', + }, + } as any) as LogRowModel; + test('should return correct error if error is present', () => { + expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' }); + }); +}); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts new file mode 100644 index 0000000..fb3ed84 --- /dev/null +++ b/packages/grafana-data/src/utils/logs.ts @@ -0,0 +1,228 @@ +import { countBy, chain, escapeRegExp } from 'lodash'; + +import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser, LogsModel, LogsSortOrder } from '../types/logs'; +import { DataFrame, FieldType } from '../types/index'; +import { ArrayVector } from '../vector/ArrayVector'; + +// This matches: +// first a label from start of the string or first white space, then any word chars until "=" +// second either an empty quotes, or anything that starts with quote and ends with unescaped quote, +// or any non whitespace chars that do not start with quote +const LOGFMT_REGEXP = /(?:^|\s)([\w\(\)\[\]\{\}]+)=(""|(?:".*?[^\\]"|[^"\s]\S*))/; + +/** + * Returns the log level of a log line. + * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`. + * + * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn` + */ +export function getLogLevel(line: string): LogLevel { + if (!line) { + return LogLevel.unknown; + } + let level = LogLevel.unknown; + let currentIndex: number | undefined = undefined; + + for (const key of Object.keys(LogLevel)) { + const regexp = new RegExp(`\\b${key}\\b`, 'i'); + const result = regexp.exec(line); + + if (result) { + if (currentIndex === undefined || result.index < currentIndex) { + level = (LogLevel as any)[key]; + currentIndex = result.index; + } + } + } + return level; +} + +export function getLogLevelFromKey(key: string | number): LogLevel { + const level = (LogLevel as any)[key.toString().toLowerCase()]; + if (level) { + return level; + } + + return LogLevel.unknown; +} + +export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataFrame { + const levels = new ArrayVector(); + const lines = series.fields[lineIndex]; + for (let i = 0; i < lines.values.length; i++) { + const line = lines.values.get(lineIndex); + levels.buffer.push(getLogLevel(line)); + } + + return { + ...series, // Keeps Tags, RefID etc + fields: [ + ...series.fields, + { + name: 'LogLevel', + type: FieldType.string, + values: levels, + config: {}, + }, + ], + }; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: (label) => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), + getFields: (line) => { + try { + const parsed = JSON.parse(line); + return Object.keys(parsed).map((key) => { + return `"${key}":${JSON.stringify(parsed[key])}`; + }); + } catch {} + return []; + }, + getLabelFromField: (field) => (field.match(/^"([^"]+)"\s*:/) || [])[1], + getValueFromField: (field) => (field.match(/:\s*(.*)$/) || [])[1], + test: (line) => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + + logfmt: { + buildMatcher: (label) => new RegExp(`(?:^|\\s)${escapeRegExp(label)}=("[^"]*"|\\S+)`), + getFields: (line) => { + const fields: string[] = []; + line.replace(new RegExp(LOGFMT_REGEXP, 'g'), (substring) => { + fields.push(substring.trim()); + return ''; + }); + return fields; + }, + getLabelFromField: (field) => (field.match(LOGFMT_REGEXP) || [])[1], + getValueFromField: (field) => (field.match(LOGFMT_REGEXP) || [])[2], + test: (line) => LOGFMT_REGEXP.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter((row) => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = countBy(rowsWithField, (r) => { + const row: LogRowModel = r; + const match = row.entry.match(extractor); + + return match ? match[1] : null; + }); + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter((row) => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = countBy(rowsWithLabel, (row) => (row as LogRowModel).labels[label]); + return getSortedCounts(countsByValue, rowCount); +} + +export function calculateStats(values: any[]): LogLabelStatsModel[] { + const nonEmptyValues = values.filter((value) => value !== undefined && value !== null); + const countsByValue = countBy(nonEmptyValues); + return getSortedCounts(countsByValue, nonEmptyValues.length); +} + +const getSortedCounts = (countsByValue: { [value: string]: number }, rowCount: number) => { + return chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); +}; + +export function getParser(line: string): LogsParser | undefined { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + + return parser; +} + +export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs < b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs > b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs < b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs > b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs > b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs < b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs > b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs < b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => { + const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : []; + return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; +}; + +export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) => + sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder); + +// Currently supports only error condition in Loki logs +export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorMessage?: string } => { + if (logRow.labels.__error__) { + return { + hasError: true, + errorMessage: logRow.labels.__error__, + }; + } + return { + hasError: false, + }; +}; + +export const escapeUnescapedString = (string: string) => + string.replace(/\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/packages/grafana-data/src/utils/namedColorsPalette.test.ts b/packages/grafana-data/src/utils/namedColorsPalette.test.ts new file mode 100644 index 0000000..3cfc68c --- /dev/null +++ b/packages/grafana-data/src/utils/namedColorsPalette.test.ts @@ -0,0 +1,33 @@ +import { getColorFromHexRgbOrName, getColorDefinitionByName } from './namedColorsPalette'; +import { GrafanaThemeType } from '../types/theme'; + +describe('colors', () => { + const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue'); + + describe('getColorFromHexRgbOrName', () => { + it('returns black for unknown color', () => { + expect(getColorFromHexRgbOrName('aruba-sunshine')).toBe('#000000'); + }); + + it('returns dark hex variant for known color if theme not specified', () => { + expect(getColorFromHexRgbOrName(SemiDarkBlue.name)).toBe(SemiDarkBlue.variants.dark); + }); + + it("returns correct variant's hex for known color if theme specified", () => { + expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaThemeType.Light)).toBe(SemiDarkBlue.variants.light); + }); + + it('returns color if specified as hex or rgb/a', () => { + expect(getColorFromHexRgbOrName('ff0000')).toBe('#ff0000'); + expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000'); + expect(getColorFromHexRgbOrName('#FF0000')).toBe('#FF0000'); + expect(getColorFromHexRgbOrName('#CCC')).toBe('#CCC'); + expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)'); + expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)'); + }); + + it('returns hex for named color that is not a part of named colors palette', () => { + expect(getColorFromHexRgbOrName('lime')).toBe('#00ff00'); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/namedColorsPalette.ts b/packages/grafana-data/src/utils/namedColorsPalette.ts new file mode 100644 index 0000000..c64a169 --- /dev/null +++ b/packages/grafana-data/src/utils/namedColorsPalette.ts @@ -0,0 +1,267 @@ +import flatten from 'lodash/flatten'; +import tinycolor from 'tinycolor2'; +import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; + +type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple'; + +export type Color = + | 'green' + | 'dark-green' + | 'semi-dark-green' + | 'light-green' + | 'super-light-green' + | 'yellow' + | 'dark-yellow' + | 'semi-dark-yellow' + | 'light-yellow' + | 'super-light-yellow' + | 'red' + | 'dark-red' + | 'semi-dark-red' + | 'light-red' + | 'super-light-red' + | 'blue' + | 'dark-blue' + | 'semi-dark-blue' + | 'light-blue' + | 'super-light-blue' + | 'orange' + | 'dark-orange' + | 'semi-dark-orange' + | 'light-orange' + | 'super-light-orange' + | 'purple' + | 'dark-purple' + | 'semi-dark-purple' + | 'light-purple' + | 'super-light-purple' + | 'panel-bg'; + +type ThemeVariants = { + dark: string; + light: string; +}; + +export type ColorDefinition = { + hue: Hue; + isPrimary?: boolean; + name: Color; + variants: ThemeVariants; +}; + +let colorsPaletteInstance: Map; +let colorsMap: Record | undefined; +let colorsMapTheme: GrafanaTheme | undefined; + +const buildColorDefinition = ( + hue: Hue, + name: Color, + [light, dark]: string[], + isPrimary?: boolean +): ColorDefinition => ({ + hue, + name, + variants: { + light, + dark, + }, + isPrimary: !!isPrimary, +}); + +export function getColorDefinitionByName(name: Color): ColorDefinition { + return flatten(Array.from(getNamedColorPalette().values())).filter((definition) => definition.name === name)[0]; +} + +export function buildColorsMapForTheme(theme: GrafanaTheme): Record { + theme = theme ?? GrafanaThemeType.Dark; + + colorsMap = {} as Record; + + for (const def of getNamedColorPalette().values()) { + for (const c of def) { + colorsMap[c.name] = c.variants[theme.type]; + } + } + + colorsMap['panel-bg'] = theme.colors.panelBg; + + return colorsMap; +} + +export function getColorForTheme(color: string, theme: GrafanaTheme): string { + if (!color) { + return 'gray'; + } + + // check if we need to rebuild cache + if (!colorsMap || colorsMapTheme !== theme) { + colorsMap = buildColorsMapForTheme(theme); + colorsMapTheme = theme; + } + + let realColor = colorsMap[color as Color]; + if (realColor) { + return realColor; + } + + if (color[0] === '#') { + return (colorsMap[color as Color] = color); + } + + if (color.indexOf('rgb') > -1) { + return (colorsMap[color as Color] = color); + } + + return (colorsMap[color as Color] = tinycolor(color).toHexString()); +} + +/** + * @deprecated use getColorForTheme + */ +export function getColorFromHexRgbOrName(color: string, type?: GrafanaThemeType): string { + const themeType = type ?? GrafanaThemeType.Dark; + + if (themeType === GrafanaThemeType.Dark) { + const darkTheme = ({ + type: themeType, + colors: { + panelBg: '#141619', + }, + } as unknown) as GrafanaTheme; + + return getColorForTheme(color, darkTheme); + } + + const lightTheme = ({ + type: themeType, + colors: { + panelBg: '#000000', + }, + } as unknown) as GrafanaTheme; + + return getColorForTheme(color, lightTheme); +} + +const buildNamedColorsPalette = () => { + const palette = new Map(); + + const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true); + const DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']); + const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']); + const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']); + const SuperLightGreen = buildColorDefinition('green', 'super-light-green', ['#96D98D', '#C8F2C2']); + + const BasicYellow = buildColorDefinition('yellow', 'yellow', ['#F2CC0C', '#FADE2A'], true); + const DarkYellow = buildColorDefinition('yellow', 'dark-yellow', ['#CC9D00', '#E0B400']); + const SemiDarkYellow = buildColorDefinition('yellow', 'semi-dark-yellow', ['#E0B400', '#F2CC0C']); + const LightYellow = buildColorDefinition('yellow', 'light-yellow', ['#FADE2A', '#FFEE52']); + const SuperLightYellow = buildColorDefinition('yellow', 'super-light-yellow', ['#FFEE52', '#FFF899']); + + const BasicRed = buildColorDefinition('red', 'red', ['#E02F44', '#F2495C'], true); + const DarkRed = buildColorDefinition('red', 'dark-red', ['#AD0317', '#C4162A']); + const SemiDarkRed = buildColorDefinition('red', 'semi-dark-red', ['#C4162A', '#E02F44']); + const LightRed = buildColorDefinition('red', 'light-red', ['#F2495C', '#FF7383']); + const SuperLightRed = buildColorDefinition('red', 'super-light-red', ['#FF7383', '#FFA6B0']); + + const BasicBlue = buildColorDefinition('blue', 'blue', ['#3274D9', '#5794F2'], true); + const DarkBlue = buildColorDefinition('blue', 'dark-blue', ['#1250B0', '#1F60C4']); + const SemiDarkBlue = buildColorDefinition('blue', 'semi-dark-blue', ['#1F60C4', '#3274D9']); + const LightBlue = buildColorDefinition('blue', 'light-blue', ['#5794F2', '#8AB8FF']); + const SuperLightBlue = buildColorDefinition('blue', 'super-light-blue', ['#8AB8FF', '#C0D8FF']); + + const BasicOrange = buildColorDefinition('orange', 'orange', ['#FF780A', '#FF9830'], true); + const DarkOrange = buildColorDefinition('orange', 'dark-orange', ['#E55400', '#FA6400']); + const SemiDarkOrange = buildColorDefinition('orange', 'semi-dark-orange', ['#FA6400', '#FF780A']); + const LightOrange = buildColorDefinition('orange', 'light-orange', ['#FF9830', '#FFB357']); + const SuperLightOrange = buildColorDefinition('orange', 'super-light-orange', ['#FFB357', '#FFCB7D']); + + const BasicPurple = buildColorDefinition('purple', 'purple', ['#A352CC', '#B877D9'], true); + const DarkPurple = buildColorDefinition('purple', 'dark-purple', ['#7C2EA3', '#8F3BB8']); + const SemiDarkPurple = buildColorDefinition('purple', 'semi-dark-purple', ['#8F3BB8', '#A352CC']); + const LightPurple = buildColorDefinition('purple', 'light-purple', ['#B877D9', '#CA95E5']); + const SuperLightPurple = buildColorDefinition('purple', 'super-light-purple', ['#CA95E5', '#DEB6F2']); + + const greens = [BasicGreen, DarkGreen, SemiDarkGreen, LightGreen, SuperLightGreen]; + const yellows = [BasicYellow, DarkYellow, SemiDarkYellow, LightYellow, SuperLightYellow]; + const reds = [BasicRed, DarkRed, SemiDarkRed, LightRed, SuperLightRed]; + const blues = [BasicBlue, DarkBlue, SemiDarkBlue, LightBlue, SuperLightBlue]; + const oranges = [BasicOrange, DarkOrange, SemiDarkOrange, LightOrange, SuperLightOrange]; + const purples = [BasicPurple, DarkPurple, SemiDarkPurple, LightPurple, SuperLightPurple]; + + palette.set('green', greens); + palette.set('yellow', yellows); + palette.set('red', reds); + palette.set('blue', blues); + palette.set('orange', oranges); + palette.set('purple', purples); + + return palette; +}; + +export const getNamedColorPalette = () => { + if (colorsPaletteInstance) { + return colorsPaletteInstance; + } + + colorsPaletteInstance = buildNamedColorsPalette(); + return colorsPaletteInstance; +}; + +export const classicColors = [ + '#7EB26D', // 0: pale green + '#EAB839', // 1: mustard + '#6ED0E0', // 2: light blue + '#EF843C', // 3: orange + '#E24D42', // 4: red + '#1F78C1', // 5: ocean + '#BA43A9', // 6: purple + '#705DA0', // 7: violet + '#508642', // 8: dark green + '#CCA300', // 9: dark sand + '#447EBC', + '#C15C17', + '#890F02', + '#0A437C', + '#6D1F62', + '#584477', + '#B7DBAB', + '#F4D598', + '#70DBED', + '#F9BA8F', + '#F29191', + '#82B5D8', + '#E5A8E2', + '#AEA2E0', + '#629E51', + '#E5AC0E', + '#64B0C8', + '#E0752D', + '#BF1B00', + '#0A50A1', + '#962D82', + '#614D93', + '#9AC48A', + '#F2C96D', + '#65C5DB', + '#F9934E', + '#EA6460', + '#5195CE', + '#D683CE', + '#806EB7', + '#3F6833', + '#967302', + '#2F575E', + '#99440A', + '#58140C', + '#052B51', + '#511749', + '#3F2B5B', + '#E0F9D7', + '#FCEACA', + '#CFFAFF', + '#F9E2D2', + '#FCE2DE', + '#BADFF4', + '#F9D9F9', + '#DEDAF7', +]; diff --git a/packages/grafana-data/src/utils/object.ts b/packages/grafana-data/src/utils/object.ts new file mode 100644 index 0000000..e5960b2 --- /dev/null +++ b/packages/grafana-data/src/utils/object.ts @@ -0,0 +1,8 @@ +export const objRemoveUndefined = (obj: any) => { + return Object.keys(obj).reduce((acc: any, key) => { + if (obj[key] !== undefined) { + acc[key] = obj[key]; + } + return acc; + }, {}); +}; diff --git a/packages/grafana-data/src/utils/series.test.ts b/packages/grafana-data/src/utils/series.test.ts new file mode 100644 index 0000000..4ef77d8 --- /dev/null +++ b/packages/grafana-data/src/utils/series.test.ts @@ -0,0 +1,47 @@ +import { getSeriesTimeStep, hasMsResolution } from './series'; +import { Field, FieldType } from '../types'; +import { ArrayVector } from '../vector'; + +const uniformTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 100, 200, 300]), + config: {}, +}; +const nonUniformTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 100, 300, 350]), + config: {}, +}; + +const msResolutionTimeField: Field = { + name: 'time', + type: FieldType.time, + values: new ArrayVector([0, 1572951685007, 300, 350]), + config: {}, +}; + +describe('getSeriesTimeStep', () => { + test('uniform series', () => { + const result = getSeriesTimeStep(uniformTimeField); + expect(result).toBe(100); + }); + + test('non-uniform series', () => { + const result = getSeriesTimeStep(nonUniformTimeField); + expect(result).toBe(50); + }); +}); + +describe('hasMsResolution', () => { + test('return false if none of the timestamps is in ms', () => { + const result = hasMsResolution(uniformTimeField); + expect(result).toBeFalsy(); + }); + + test('return true if any of the timestamps is in ms', () => { + const result = hasMsResolution(msResolutionTimeField); + expect(result).toBeTruthy(); + }); +}); diff --git a/packages/grafana-data/src/utils/series.ts b/packages/grafana-data/src/utils/series.ts new file mode 100644 index 0000000..38937b9 --- /dev/null +++ b/packages/grafana-data/src/utils/series.ts @@ -0,0 +1,46 @@ +import { Field } from '../types/dataFrame'; + +/** + * Returns minimal time step from series time field + * @param timeField + */ +export const getSeriesTimeStep = (timeField: Field): number => { + let previousTime: number | undefined; + let minTimeStep: number | undefined; + let returnTimeStep = Number.MAX_VALUE; + + for (let i = 0; i < timeField.values.length; i++) { + const currentTime = timeField.values.get(i); + + if (previousTime !== undefined) { + const timeStep = currentTime - previousTime; + + if (minTimeStep === undefined) { + returnTimeStep = timeStep; + } + + if (timeStep < returnTimeStep) { + returnTimeStep = timeStep; + } + } + previousTime = currentTime; + } + return returnTimeStep; +}; + +/** + * Checks if series time field has ms resolution + * @param timeField + */ +export const hasMsResolution = (timeField: Field) => { + for (let i = 0; i < timeField.values.length; i++) { + const value = timeField.values.get(i); + if (value !== null && value !== undefined) { + const timestamp = value.toString(); + if (timestamp.length === 13 && timestamp % 1000 !== 0) { + return true; + } + } + } + return false; +}; diff --git a/packages/grafana-data/src/utils/testdata/roundtrip.csv b/packages/grafana-data/src/utils/testdata/roundtrip.csv new file mode 100644 index 0000000..68b5986 --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/roundtrip.csv @@ -0,0 +1,12 @@ +#name#a,b,c +#type#number,string,boolean +#unit#ms,,s +10,"this ""has quotes"" inside",true +20,XX,false +30,YY,false +40,ZZ,true +50,"X,Y",true +60,"X +Y",true +70,BB,false + diff --git a/packages/grafana-data/src/utils/testdata/simple.csv b/packages/grafana-data/src/utils/testdata/simple.csv new file mode 100644 index 0000000..cd48f5d --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/simple.csv @@ -0,0 +1,3 @@ +a,b,c +10,20,30 +40,50,60 diff --git a/packages/grafana-data/src/utils/testdata/testTheme.ts b/packages/grafana-data/src/utils/testdata/testTheme.ts new file mode 100644 index 0000000..f706e04 --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/testTheme.ts @@ -0,0 +1,12 @@ +import { GrafanaTheme, GrafanaThemeType } from '../../types/theme'; + +export function getTestTheme(type: GrafanaThemeType = GrafanaThemeType.Dark): GrafanaTheme { + return ({ + type, + isDark: type === GrafanaThemeType.Dark, + isLight: type === GrafanaThemeType.Light, + colors: { + panelBg: 'white', + }, + } as unknown) as GrafanaTheme; +} diff --git a/packages/grafana-data/src/utils/testdata/withHeaders.csv b/packages/grafana-data/src/utils/testdata/withHeaders.csv new file mode 100644 index 0000000..d83ef4e --- /dev/null +++ b/packages/grafana-data/src/utils/testdata/withHeaders.csv @@ -0,0 +1,7 @@ +#name#a,b,c +#unit#ms,lengthm,s +#type#number,string,boolean +10,20,True +40,50,FALSE +"40","500",0 +40,50,1 diff --git a/packages/grafana-data/src/utils/tests/mockStandardProperties.ts b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts new file mode 100644 index 0000000..0f452c5 --- /dev/null +++ b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts @@ -0,0 +1,170 @@ +import { identityOverrideProcessor } from '../../field'; +import { ThresholdsMode } from '../../types'; + +export const mockStandardProperties = () => { + const title = { + id: 'displayName', + path: 'displayName', + name: 'Display name', + description: "Field's display name", + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: 'none', + expandTemplateVars: true, + }, + shouldApply: () => true, + }; + + const unit = { + id: 'unit', + path: 'unit', + name: 'Unit', + description: 'Value units', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'none', + }, + + shouldApply: () => true, + }; + + const min = { + id: 'min', + path: 'min', + name: 'Min', + description: 'Minimum expected value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + }, + shouldApply: () => true, + }; + + const max = { + id: 'max', + path: 'max', + name: 'Max', + description: 'Maximum expected value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + }, + + shouldApply: () => true, + }; + + const decimals = { + id: 'decimals', + path: 'decimals', + name: 'Decimals', + description: 'Number of decimal to be shown for a value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + min: 0, + max: 15, + integer: true, + }, + + shouldApply: () => true, + }; + + const thresholds = { + id: 'thresholds', + path: 'thresholds', + name: 'Thresholds', + description: 'Manage thresholds', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: {}, + defaultValue: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 80, color: 'red' }, + ], + }, + shouldApply: () => true, + }; + + const mappings = { + id: 'mappings', + path: 'mappings', + name: 'Value mappings', + description: 'Manage value mappings', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: {}, + defaultValue: [], + shouldApply: () => true, + }; + + const noValue = { + id: 'noValue', + path: 'noValue', + name: 'No Value', + description: 'What to show when there is no value', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: '-', + }, + // ??? any optionsUi with no value + shouldApply: () => true, + }; + + const links = { + id: 'links', + path: 'links', + name: 'DataLinks', + description: 'Manage date links', + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: '-', + }, + shouldApply: () => true, + }; + + const color = { + id: 'color', + path: 'color', + name: 'Color', + description: 'Customise color', + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + settings: { + placeholder: '-', + }, + shouldApply: () => true, + }; + + return [unit, min, max, decimals, title, noValue, thresholds, mappings, links, color]; +}; diff --git a/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts b/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts new file mode 100644 index 0000000..ac1dba0 --- /dev/null +++ b/packages/grafana-data/src/utils/tests/mockTransformationsRegistry.ts @@ -0,0 +1,16 @@ +import { standardTransformersRegistry } from '../../transformations'; +import { DataTransformerInfo } from '../../types'; + +export const mockTransformationsRegistry = (transformers: Array>) => { + standardTransformersRegistry.setInit(() => { + return transformers.map((t) => { + return { + id: t.id, + name: t.name, + transformation: t, + description: t.description, + editor: () => null, + }; + }); + }); +}; diff --git a/packages/grafana-data/src/utils/url.test.ts b/packages/grafana-data/src/utils/url.test.ts new file mode 100644 index 0000000..ca876d1 --- /dev/null +++ b/packages/grafana-data/src/utils/url.test.ts @@ -0,0 +1,25 @@ +import { urlUtil } from './url'; + +describe('toUrlParams', () => { + it('should encode object properties as url parameters', () => { + const url = urlUtil.toUrlParams({ + server: 'backend-01', + hasSpace: 'has space', + many: ['1', '2', '3'], + true: true, + number: 20, + isNull: null, + isUndefined: undefined, + }); + expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined='); + }); +}); + +describe('toUrlParams', () => { + it('should encode the same way as angularjs', () => { + const url = urlUtil.toUrlParams({ + server: ':@', + }); + expect(url).toBe('server=:@'); + }); +}); diff --git a/packages/grafana-data/src/utils/url.ts b/packages/grafana-data/src/utils/url.ts new file mode 100644 index 0000000..b8577a8 --- /dev/null +++ b/packages/grafana-data/src/utils/url.ts @@ -0,0 +1,140 @@ +/** + * @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT + */ + +import { ExploreUrlState } from '../types/explore'; + +/** + * Type to represent the value of a single query variable. + * + * @public + */ +export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[] | undefined | null; + +/** + * Type to represent the values parsed from the query string. + * + * @public + */ +export type UrlQueryMap = Record; + +function renderUrl(path: string, query: UrlQueryMap | undefined): string { + if (query && Object.keys(query).length > 0) { + path += '?' + toUrlParams(query); + } + return path; +} + +function encodeURIComponentAsAngularJS(val: string, pctEncodeSpaces?: boolean) { + return encodeURIComponent(val) + .replace(/%40/gi, '@') + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%3B/gi, ';') + .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); +} + +function toUrlParams(a: any) { + const s: any[] = []; + const rbracket = /\[\]$/; + + const isArray = (obj: any) => { + return Object.prototype.toString.call(obj) === '[object Array]'; + }; + + const add = (k: string, v: any) => { + v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v; + if (typeof v !== 'boolean') { + s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true); + } else { + s[s.length] = encodeURIComponentAsAngularJS(k, true); + } + }; + + const buildParams = (prefix: string, obj: any) => { + let i, len, key; + + if (prefix) { + if (isArray(obj)) { + for (i = 0, len = obj.length; i < len; i++) { + if (rbracket.test(prefix)) { + add(prefix, obj[i]); + } else { + buildParams(prefix, obj[i]); + } + } + } else if (obj && String(obj) === '[object Object]') { + for (key in obj) { + buildParams(prefix + '[' + key + ']', obj[key]); + } + } else { + add(prefix, obj); + } + } else if (isArray(obj)) { + for (i = 0, len = obj.length; i < len; i++) { + add(obj[i].name, obj[i].value); + } + } else { + for (key in obj) { + buildParams(key, obj[key]); + } + } + return s; + }; + + return buildParams('', a).join('&'); +} + +function appendQueryToUrl(url: string, stringToAppend: string) { + if (stringToAppend !== undefined && stringToAppend !== null && stringToAppend !== '') { + const pos = url.indexOf('?'); + if (pos !== -1) { + if (url.length - pos > 1) { + url += '&'; + } + } else { + url += '?'; + } + url += stringToAppend; + } + + return url; +} + +/** + * Return search part (as object) of current url + */ +function getUrlSearchParams() { + const search = window.location.search.substring(1); + const searchParamsSegments = search.split('&'); + const params: any = {}; + for (const p of searchParamsSegments) { + const keyValuePair = p.split('='); + if (keyValuePair.length > 1) { + // key-value param + const key = decodeURIComponent(keyValuePair[0]); + const value = decodeURIComponent(keyValuePair[1]); + params[key] = value; + } else if (keyValuePair.length === 1) { + // boolean param + const key = decodeURIComponent(keyValuePair[0]); + params[key] = true; + } + } + return params; +} + +export const urlUtil = { + renderUrl, + toUrlParams, + appendQueryToUrl, + getUrlSearchParams, +}; + +export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { + if (compact) { + return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); + } + return JSON.stringify(urlState); +} diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts new file mode 100644 index 0000000..e9d0495 --- /dev/null +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -0,0 +1,156 @@ +import { getMappedValue, isNumeric } from './valueMappings'; +import { ValueMapping, MappingType } from '../types'; + +describe('Format value with value mappings', () => { + it('should return undefined with no valuemappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '10'; + + expect(getMappedValue(valueMappings, value)).toBeUndefined(); + }); + + it('should return undefined with no matching valuemappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value)).toBeUndefined(); + }); + + it('should return first matching mapping with lowest id', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, text: 'tio', type: MappingType.ValueToText, value: '10' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); + }); + + it('should return if value is null and value to text mapping value is null', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, text: '', type: MappingType.ValueToText, value: 'null' }, + ]; + const value = null; + + expect(getMappedValue(valueMappings, value).text).toEqual(''); + }); + + it('should return if value is null and range to text mapping from and to is null', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '', type: MappingType.RangeToText, from: 'null', to: 'null' }, + { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = null; + + expect(getMappedValue(valueMappings, value).text).toEqual(''); + }); + + it('should return rangeToText mapping where value equals to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, + { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-10'); + }); + + it('should return rangeToText mapping where value equals from', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, + { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('10-20'); + }); + + it('should return rangeToText mapping where value is between from and to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); + }); + + describe('text mapping', () => { + it('should map value text to mapping', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, text: 'ELVA', type: MappingType.ValueToText, value: 'elva' }, + ]; + + const value = 'elva'; + + expect(getMappedValue(valueMappings, value).text).toEqual('ELVA'); + }); + + it.each` + value | expected + ${'2/0/12'} | ${{ id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' }} + ${'2/1/12'} | ${undefined} + ${'2:0'} | ${{ id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' }} + ${'2:1'} | ${undefined} + ${'20whatever'} | ${{ id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' }} + ${'20whateve'} | ${undefined} + ${'20'} | ${undefined} + ${'00020.4'} | ${undefined} + ${'192.168.1.1'} | ${{ id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' }} + ${'192'} | ${undefined} + ${'192.168'} | ${undefined} + ${'192.168.1'} | ${undefined} + ${'9.90'} | ${{ id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' }} + `('numeric-like text mapping, value:${value', ({ value, expected }) => { + const valueMappings: ValueMapping[] = [ + { id: 1, text: 'mapped value 1', type: MappingType.ValueToText, value: '2/0/12' }, + { id: 2, text: 'mapped value 2', type: MappingType.ValueToText, value: '20whatever' }, + { id: 3, text: 'mapped value 3', type: MappingType.ValueToText, value: '2:0' }, + { id: 4, text: 'mapped value ip', type: MappingType.ValueToText, value: '192.168.1.1' }, + { id: 5, text: 'OK', type: MappingType.ValueToText, value: '9.9' }, + ]; + expect(getMappedValue(valueMappings, value)).toEqual(expected); + }); + }); +}); + +describe('isNumeric', () => { + it.each` + value | expected + ${123} | ${true} + ${0} | ${true} + ${'123'} | ${true} + ${'0'} | ${true} + ${' 123'} | ${true} + ${' 123 '} | ${true} + ${' 0 '} | ${true} + ${-123.4} | ${true} + ${'-123.4'} | ${true} + ${0.41} | ${true} + ${'.41'} | ${true} + ${0x12} | ${true} + ${'0x12'} | ${true} + ${'000123.4'} | ${true} + ${2e64} | ${true} + ${'2e64'} | ${true} + ${1e10000} | ${true} + ${'1e10000'} | ${true} + ${Infinity} | ${true} + ${'abc'} | ${false} + ${' '} | ${false} + ${null} | ${false} + ${undefined} | ${false} + ${NaN} | ${false} + ${''} | ${false} + ${{}} | ${false} + ${true} | ${false} + ${[]} | ${false} + `('detects numeric values', ({ value, expected }) => { + expect(isNumeric(value)).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/utils/valueMappings.ts b/packages/grafana-data/src/utils/valueMappings.ts new file mode 100644 index 0000000..3839683 --- /dev/null +++ b/packages/grafana-data/src/utils/valueMappings.ts @@ -0,0 +1,104 @@ +import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types'; + +type TimeSeriesValue = string | number | null; + +const addValueToTextMappingText = ( + allValueMappings: ValueMapping[], + valueToTextMapping: ValueMap, + value: TimeSeriesValue +) => { + if (valueToTextMapping.value === undefined) { + return allValueMappings; + } + + if (value === null && isNullValueMap(valueToTextMapping)) { + return allValueMappings.concat(valueToTextMapping); + } + + let valueAsNumber, valueToTextMappingAsNumber; + + if (isNumeric(value as string) && isNumeric(valueToTextMapping.value)) { + valueAsNumber = parseFloat(value as string); + valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); + + if (valueAsNumber === valueToTextMappingAsNumber) { + return allValueMappings.concat(valueToTextMapping); + } + return allValueMappings; + } + + if (value === valueToTextMapping.value) { + return allValueMappings.concat(valueToTextMapping); + } + + return allValueMappings; +}; + +const addRangeToTextMappingText = ( + allValueMappings: ValueMapping[], + rangeToTextMapping: RangeMap, + value: TimeSeriesValue +) => { + if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) { + return allValueMappings; + } + + if ( + value === null && + rangeToTextMapping.from && + rangeToTextMapping.to && + rangeToTextMapping.from.toLowerCase() === 'null' && + rangeToTextMapping.to.toLowerCase() === 'null' + ) { + return allValueMappings.concat(rangeToTextMapping); + } + + 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; +}; + +const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => { + const allFormattedValueMappings = valueMappings.reduce((allValueMappings, valueMapping) => { + if (valueMapping.type === MappingType.ValueToText) { + allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); + } else if (valueMapping.type === MappingType.RangeToText) { + allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); + } + + return allValueMappings; + }, [] as ValueMapping[]); + + allFormattedValueMappings.sort((t1, t2) => { + return t1.id - t2.id; + }); + + return allFormattedValueMappings; +}; + +export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => { + return getAllFormattedValueMappings(valueMappings, value)[0]; +}; + +const isNullValueMap = (mapping: ValueMap): boolean => { + if (!mapping || !mapping.value) { + return false; + } + return mapping.value.toLowerCase() === 'null'; +}; + +// Ref https://stackoverflow.com/a/58550111 + +export function isNumeric(num: any) { + return (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && !isNaN(num as number); +} diff --git a/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts b/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts new file mode 100644 index 0000000..af23b49 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/arithmeticFormatters.test.ts @@ -0,0 +1,41 @@ +import { toHex, toHex0x } from './arithmeticFormatters'; +import { formattedValueToString } from './valueFormats'; + +describe('hex', () => { + it('positive integer', () => { + const str = toHex(100, 0); + expect(formattedValueToString(str)).toBe('64'); + }); + it('negative integer', () => { + const str = toHex(-100, 0); + expect(formattedValueToString(str)).toBe('-64'); + }); + it('positive float', () => { + const str = toHex(50.52, 1); + expect(formattedValueToString(str)).toBe('32.8'); + }); + it('negative float', () => { + const str = toHex(-50.333, 2); + expect(formattedValueToString(str)).toBe('-32.547AE147AE14'); + }); +}); + +describe('hex 0x', () => { + it('positive integer', () => { + const str = toHex0x(7999, 0); + expect(formattedValueToString(str)).toBe('0x1F3F'); + }); + it('negative integer', () => { + const str = toHex0x(-584, 0); + expect(formattedValueToString(str)).toBe('-0x248'); + }); + + it('positive float', () => { + const str = toHex0x(74.443, 3); + expect(formattedValueToString(str)).toBe('0x4A.716872B020C4'); + }); + it('negative float', () => { + const str = toHex0x(-65.458, 1); + expect(formattedValueToString(str)).toBe('-0x41.8'); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts b/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts new file mode 100644 index 0000000..6aca412 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/arithmeticFormatters.ts @@ -0,0 +1,45 @@ +import { toFixed, FormattedValue } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; + +export function toPercent(size: number, decimals: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + return { text: toFixed(size, decimals), suffix: '%' }; +} + +export function toPercentUnit(size: number, decimals: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + return { text: toFixed(100 * size, decimals), suffix: '%' }; +} + +export function toHex0x(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + const asHex = toHex(value, decimals); + if (asHex.text.substring(0, 1) === '-') { + asHex.text = '-0x' + asHex.text.substring(1); + } else { + asHex.text = '0x' + asHex.text; + } + return asHex; +} + +export function toHex(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { + text: parseFloat(toFixed(value, decimals)).toString(16).toUpperCase(), + }; +} + +export function sci(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { text: value.toExponential(decimals as number) }; +} diff --git a/packages/grafana-data/src/valueFormats/categories.ts b/packages/grafana-data/src/valueFormats/categories.ts new file mode 100644 index 0000000..d509aeb --- /dev/null +++ b/packages/grafana-data/src/valueFormats/categories.ts @@ -0,0 +1,400 @@ +import { locale, scaledUnits, simpleCountUnit, toFixedUnit, ValueFormatCategory, stringFormater } from './valueFormats'; +import { + dateTimeAsIso, + dateTimeAsIsoNoDateIfToday, + dateTimeAsUS, + dateTimeAsUSNoDateIfToday, + getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, + dateTimeFromNow, + toClockMilliseconds, + toClockSeconds, + toDays, + toDurationInDaysHoursMinutesSeconds, + toDurationInHoursMinutesSeconds, + toDurationInMilliseconds, + toDurationInSeconds, + toHours, + toMicroSeconds, + toMilliSeconds, + toMinutes, + toNanoSeconds, + toSeconds, + toTimeTicks, + dateTimeSystemFormatter, +} from './dateTimeFormatters'; +import { toHex, sci, toHex0x, toPercent, toPercentUnit } from './arithmeticFormatters'; +import { binaryPrefix, currency, SIPrefix } from './symbolFormatters'; + +export const getCategories = (): ValueFormatCategory[] => [ + { + name: 'Misc', + formats: [ + { name: 'none', id: 'none', fn: toFixedUnit('') }, + { name: 'String', id: 'string', fn: stringFormater }, + { + name: 'short', + id: 'short', + fn: scaledUnits(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']), + }, + { name: 'Percent (0-100)', id: 'percent', fn: toPercent }, + { name: 'Percent (0.0-1.0)', id: 'percentunit', fn: toPercentUnit }, + { name: 'Humidity (%H)', id: 'humidity', fn: toFixedUnit('%H') }, + { name: 'Decibel', id: 'dB', fn: toFixedUnit('dB') }, + { name: 'Hexadecimal (0x)', id: 'hex0x', fn: toHex0x }, + { name: 'Hexadecimal', id: 'hex', fn: toHex }, + { name: 'Scientific notation', id: 'sci', fn: sci }, + { name: 'Locale format', id: 'locale', fn: locale }, + { name: 'Pixels', id: 'pixel', fn: toFixedUnit('px') }, + ], + }, + { + name: 'Acceleration', + formats: [ + { name: 'Meters/sec²', id: 'accMS2', fn: toFixedUnit('m/sec²') }, + { name: 'Feet/sec²', id: 'accFS2', fn: toFixedUnit('f/sec²') }, + { name: 'G unit', id: 'accG', fn: toFixedUnit('g') }, + ], + }, + { + name: 'Angle', + formats: [ + { name: 'Degrees (°)', id: 'degree', fn: toFixedUnit('°') }, + { name: 'Radians', id: 'radian', fn: toFixedUnit('rad') }, + { name: 'Gradian', id: 'grad', fn: toFixedUnit('grad') }, + { name: 'Arc Minutes', id: 'arcmin', fn: toFixedUnit('arcmin') }, + { name: 'Arc Seconds', id: 'arcsec', fn: toFixedUnit('arcsec') }, + ], + }, + { + name: 'Area', + formats: [ + { name: 'Square Meters (m²)', id: 'areaM2', fn: toFixedUnit('m²') }, + { name: 'Square Feet (ft²)', id: 'areaF2', fn: toFixedUnit('ft²') }, + { name: 'Square Miles (mi²)', id: 'areaMI2', fn: toFixedUnit('mi²') }, + ], + }, + { + name: 'Computation', + formats: [ + { name: 'FLOP/s', id: 'flops', fn: SIPrefix('FLOPS') }, + { name: 'MFLOP/s', id: 'mflops', fn: SIPrefix('FLOPS', 2) }, + { name: 'GFLOP/s', id: 'gflops', fn: SIPrefix('FLOPS', 3) }, + { name: 'TFLOP/s', id: 'tflops', fn: SIPrefix('FLOPS', 4) }, + { name: 'PFLOP/s', id: 'pflops', fn: SIPrefix('FLOPS', 5) }, + { name: 'EFLOP/s', id: 'eflops', fn: SIPrefix('FLOPS', 6) }, + { name: 'ZFLOP/s', id: 'zflops', fn: SIPrefix('FLOPS', 7) }, + { name: 'YFLOP/s', id: 'yflops', fn: SIPrefix('FLOPS', 8) }, + ], + }, + { + name: 'Concentration', + formats: [ + { name: 'parts-per-million (ppm)', id: 'ppm', fn: toFixedUnit('ppm') }, + { name: 'parts-per-billion (ppb)', id: 'conppb', fn: toFixedUnit('ppb') }, + { name: 'nanogram per cubic meter (ng/m³)', id: 'conngm3', fn: toFixedUnit('ng/m³') }, + { name: 'nanogram per normal cubic meter (ng/Nm³)', id: 'conngNm3', fn: toFixedUnit('ng/Nm³') }, + { name: 'microgram per cubic meter (μg/m³)', id: 'conμgm3', fn: toFixedUnit('μg/m³') }, + { name: 'microgram per normal cubic meter (μg/Nm³)', id: 'conμgNm3', fn: toFixedUnit('μg/Nm³') }, + { name: 'milligram per cubic meter (mg/m³)', id: 'conmgm3', fn: toFixedUnit('mg/m³') }, + { name: 'milligram per normal cubic meter (mg/Nm³)', id: 'conmgNm3', fn: toFixedUnit('mg/Nm³') }, + { name: 'gram per cubic meter (g/m³)', id: 'congm3', fn: toFixedUnit('g/m³') }, + { name: 'gram per normal cubic meter (g/Nm³)', id: 'congNm3', fn: toFixedUnit('g/Nm³') }, + { name: 'milligrams per decilitre (mg/dL)', id: 'conmgdL', fn: toFixedUnit('mg/dL') }, + { name: 'millimoles per litre (mmol/L)', id: 'conmmolL', fn: toFixedUnit('mmol/L') }, + ], + }, + { + name: 'Currency', + formats: [ + { name: 'Dollars ($)', id: 'currencyUSD', fn: currency('$') }, + { name: 'Pounds (£)', id: 'currencyGBP', fn: currency('£') }, + { name: 'Euro (€)', id: 'currencyEUR', fn: currency('€') }, + { name: 'Yen (¥)', id: 'currencyJPY', fn: currency('¥') }, + { name: 'Rubles (₽)', id: 'currencyRUB', fn: currency('₽') }, + { name: 'Hryvnias (₴)', id: 'currencyUAH', fn: currency('₴') }, + { name: 'Real (R$)', id: 'currencyBRL', fn: currency('R$') }, + { name: 'Danish Krone (kr)', id: 'currencyDKK', fn: currency('kr', true) }, + { name: 'Icelandic Króna (kr)', id: 'currencyISK', fn: currency('kr', true) }, + { name: 'Norwegian Krone (kr)', id: 'currencyNOK', fn: currency('kr', true) }, + { name: 'Swedish Krona (kr)', id: 'currencySEK', fn: currency('kr', true) }, + { name: 'Czech koruna (czk)', id: 'currencyCZK', fn: currency('czk') }, + { name: 'Swiss franc (CHF)', id: 'currencyCHF', fn: currency('CHF') }, + { name: 'Polish Złoty (PLN)', id: 'currencyPLN', fn: currency('PLN') }, + { name: 'Bitcoin (฿)', id: 'currencyBTC', fn: currency('฿') }, + { name: 'Milli Bitcoin (฿)', id: 'currencymBTC', fn: currency('mBTC') }, + { name: 'Micro Bitcoin (฿)', id: 'currencyμBTC', fn: currency('μBTC') }, + { name: 'South African Rand (R)', id: 'currencyZAR', fn: currency('R') }, + { name: 'Indian Rupee (₹)', id: 'currencyINR', fn: currency('₹') }, + { name: 'South Korean Won (₩)', id: 'currencyKRW', fn: currency('₩') }, + { name: 'Indonesian Rupiah (Rp)', id: 'currencyIDR', fn: currency('Rp') }, + { name: 'Philippine Peso (PHP)', id: 'currencyPHP', fn: currency('PHP') }, + { name: 'Vietnamese Dong (VND)', id: 'currencyVND', fn: currency('đ', true) }, + ], + }, + { + name: 'Data', + formats: [ + { name: 'bytes(IEC)', id: 'bytes', fn: binaryPrefix('B') }, + { name: 'bytes(SI)', id: 'decbytes', fn: SIPrefix('B') }, + { name: 'bits(IEC)', id: 'bits', fn: binaryPrefix('b') }, + { name: 'bits(SI)', id: 'decbits', fn: SIPrefix('b') }, + { name: 'kibibytes', id: 'kbytes', fn: binaryPrefix('B', 1) }, + { name: 'kilobytes', id: 'deckbytes', fn: SIPrefix('B', 1) }, + { name: 'mebibytes', id: 'mbytes', fn: binaryPrefix('B', 2) }, + { name: 'megabytes', id: 'decmbytes', fn: SIPrefix('B', 2) }, + { name: 'gibibytes', id: 'gbytes', fn: binaryPrefix('B', 3) }, + { name: 'gigabytes', id: 'decgbytes', fn: SIPrefix('B', 3) }, + { name: 'tebibytes', id: 'tbytes', fn: binaryPrefix('B', 4) }, + { name: 'terabytes', id: 'dectbytes', fn: SIPrefix('B', 4) }, + { name: 'pebibytes', id: 'pbytes', fn: binaryPrefix('B', 5) }, + { name: 'petabytes', id: 'decpbytes', fn: SIPrefix('B', 5) }, + ], + }, + { + name: 'Data rate', + formats: [ + { name: 'packets/sec', id: 'pps', fn: SIPrefix('p/s') }, + { name: 'bytes/sec(IEC)', id: 'binBps', fn: binaryPrefix('B/s') }, + { name: 'bytes/sec(SI)', id: 'Bps', fn: SIPrefix('B/s') }, + { name: 'bits/sec(IEC)', id: 'binbps', fn: binaryPrefix('b/s') }, + { name: 'bits/sec(SI)', id: 'bps', fn: SIPrefix('b/s') }, + { name: 'kibibytes/sec', id: 'KiBs', fn: binaryPrefix('B/s', 1) }, + { name: 'kibibits/sec', id: 'Kibits', fn: binaryPrefix('b/s', 1) }, + { name: 'kilobytes/sec', id: 'KBs', fn: SIPrefix('B/s', 1) }, + { name: 'kilobits/sec', id: 'Kbits', fn: SIPrefix('b/s', 1) }, + { name: 'mibibytes/sec', id: 'MiBs', fn: binaryPrefix('B/s', 2) }, + { name: 'mibibits/sec', id: 'Mibits', fn: binaryPrefix('b/s', 2) }, + { name: 'megabytes/sec', id: 'MBs', fn: SIPrefix('B/s', 2) }, + { name: 'megabits/sec', id: 'Mbits', fn: SIPrefix('b/s', 2) }, + { name: 'gibibytes/sec', id: 'GiBs', fn: binaryPrefix('B/s', 3) }, + { name: 'gibibits/sec', id: 'Gibits', fn: binaryPrefix('b/s', 3) }, + { name: 'gigabytes/sec', id: 'GBs', fn: SIPrefix('B/s', 3) }, + { name: 'gigabits/sec', id: 'Gbits', fn: SIPrefix('b/s', 3) }, + { name: 'tebibytes/sec', id: 'TiBs', fn: binaryPrefix('B/s', 4) }, + { name: 'tebibits/sec', id: 'Tibits', fn: binaryPrefix('b/s', 4) }, + { name: 'terabytes/sec', id: 'TBs', fn: SIPrefix('B/s', 4) }, + { name: 'terabits/sec', id: 'Tbits', fn: SIPrefix('b/s', 4) }, + { name: 'petibytes/sec', id: 'PiBs', fn: binaryPrefix('B/s', 5) }, + { name: 'petibits/sec', id: 'Pibits', fn: binaryPrefix('b/s', 5) }, + { name: 'petabytes/sec', id: 'PBs', fn: SIPrefix('B/s', 5) }, + { name: 'petabits/sec', id: 'Pbits', fn: SIPrefix('b/s', 5) }, + ], + }, + { + name: 'Date & time', + formats: [ + { name: 'Datetime ISO', id: 'dateTimeAsIso', fn: dateTimeAsIso }, + { name: 'Datetime ISO (No date if today)', id: 'dateTimeAsIsoNoDateIfToday', fn: dateTimeAsIsoNoDateIfToday }, + { name: 'Datetime US', id: 'dateTimeAsUS', fn: dateTimeAsUS }, + { name: 'Datetime US (No date if today)', id: 'dateTimeAsUSNoDateIfToday', fn: dateTimeAsUSNoDateIfToday }, + { name: 'Datetime local', id: 'dateTimeAsLocal', fn: getDateTimeAsLocalFormat() }, + { + name: 'Datetime local (No date if today)', + id: 'dateTimeAsLocalNoDateIfToday', + fn: getDateTimeAsLocalFormatNoDateIfToday(), + }, + { name: 'Datetime default', id: 'dateTimeAsSystem', fn: dateTimeSystemFormatter }, + { name: 'From Now', id: 'dateTimeFromNow', fn: dateTimeFromNow }, + ], + }, + { + name: 'Energy', + formats: [ + { name: 'Watt (W)', id: 'watt', fn: SIPrefix('W') }, + { name: 'Kilowatt (kW)', id: 'kwatt', fn: SIPrefix('W', 1) }, + { name: 'Megawatt (MW)', id: 'megwatt', fn: SIPrefix('W', 2) }, + { name: 'Gigawatt (GW)', id: 'gwatt', fn: SIPrefix('W', 3) }, + { name: 'Milliwatt (mW)', id: 'mwatt', fn: SIPrefix('W', -1) }, + { name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') }, + { name: 'Volt-ampere (VA)', id: 'voltamp', fn: SIPrefix('VA') }, + { name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: SIPrefix('VA', 1) }, + { name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: SIPrefix('var') }, + { name: 'Kilovolt-ampere reactive (kvar)', id: 'kvoltampreact', fn: SIPrefix('var', 1) }, + { name: 'Watt-hour (Wh)', id: 'watth', fn: SIPrefix('Wh') }, + { name: 'Watt-hour per Kilogram (Wh/kg)', id: 'watthperkg', fn: SIPrefix('Wh/kg') }, + { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: SIPrefix('Wh', 1) }, + { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: SIPrefix('W-Min', 1) }, + { name: 'Ampere-hour (Ah)', id: 'amph', fn: SIPrefix('Ah') }, + { name: 'Kiloampere-hour (kAh)', id: 'kamph', fn: SIPrefix('Ah', 1) }, + { name: 'Milliampere-hour (mAh)', id: 'mamph', fn: SIPrefix('Ah', -1) }, + { name: 'Joule (J)', id: 'joule', fn: SIPrefix('J') }, + { name: 'Electron volt (eV)', id: 'ev', fn: SIPrefix('eV') }, + { name: 'Ampere (A)', id: 'amp', fn: SIPrefix('A') }, + { name: 'Kiloampere (kA)', id: 'kamp', fn: SIPrefix('A', 1) }, + { name: 'Milliampere (mA)', id: 'mamp', fn: SIPrefix('A', -1) }, + { name: 'Volt (V)', id: 'volt', fn: SIPrefix('V') }, + { name: 'Kilovolt (kV)', id: 'kvolt', fn: SIPrefix('V', 1) }, + { name: 'Millivolt (mV)', id: 'mvolt', fn: SIPrefix('V', -1) }, + { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: SIPrefix('dBm') }, + { name: 'Ohm (Ω)', id: 'ohm', fn: SIPrefix('Ω') }, + { name: 'Kiloohm (kΩ)', id: 'kohm', fn: SIPrefix('Ω', 1) }, + { name: 'Megaohm (MΩ)', id: 'Mohm', fn: SIPrefix('Ω', 2) }, + { name: 'Farad (F)', id: 'farad', fn: SIPrefix('F') }, + { name: 'Microfarad (µF)', id: 'µfarad', fn: SIPrefix('F', -2) }, + { name: 'Nanofarad (nF)', id: 'nfarad', fn: SIPrefix('F', -3) }, + { name: 'Picofarad (pF)', id: 'pfarad', fn: SIPrefix('F', -4) }, + { name: 'Femtofarad (fF)', id: 'ffarad', fn: SIPrefix('F', -5) }, + { name: 'Henry (H)', id: 'henry', fn: SIPrefix('H') }, + { name: 'Millihenry (mH)', id: 'mhenry', fn: SIPrefix('H', -1) }, + { name: 'Microhenry (µH)', id: 'µhenry', fn: SIPrefix('H', -2) }, + { name: 'Lumens (Lm)', id: 'lumens', fn: SIPrefix('Lm') }, + ], + }, + { + name: 'Flow', + formats: [ + { name: 'Gallons/min (gpm)', id: 'flowgpm', fn: toFixedUnit('gpm') }, + { name: 'Cubic meters/sec (cms)', id: 'flowcms', fn: toFixedUnit('cms') }, + { name: 'Cubic feet/sec (cfs)', id: 'flowcfs', fn: toFixedUnit('cfs') }, + { name: 'Cubic feet/min (cfm)', id: 'flowcfm', fn: toFixedUnit('cfm') }, + { name: 'Litre/hour', id: 'litreh', fn: toFixedUnit('L/h') }, + { name: 'Litre/min (L/min)', id: 'flowlpm', fn: toFixedUnit('L/min') }, + { name: 'milliLitre/min (mL/min)', id: 'flowmlpm', fn: toFixedUnit('mL/min') }, + { name: 'Lux (lx)', id: 'lux', fn: toFixedUnit('lux') }, + ], + }, + { + name: 'Force', + formats: [ + { name: 'Newton-meters (Nm)', id: 'forceNm', fn: SIPrefix('Nm') }, + { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: SIPrefix('Nm', 1) }, + { name: 'Newtons (N)', id: 'forceN', fn: SIPrefix('N') }, + { name: 'Kilonewtons (kN)', id: 'forcekN', fn: SIPrefix('N', 1) }, + ], + }, + { + name: 'Hash rate', + formats: [ + { name: 'hashes/sec', id: 'Hs', fn: SIPrefix('H/s') }, + { name: 'kilohashes/sec', id: 'KHs', fn: SIPrefix('H/s', 1) }, + { name: 'megahashes/sec', id: 'MHs', fn: SIPrefix('H/s', 2) }, + { name: 'gigahashes/sec', id: 'GHs', fn: SIPrefix('H/s', 3) }, + { name: 'terahashes/sec', id: 'THs', fn: SIPrefix('H/s', 4) }, + { name: 'petahashes/sec', id: 'PHs', fn: SIPrefix('H/s', 5) }, + { name: 'exahashes/sec', id: 'EHs', fn: SIPrefix('H/s', 6) }, + ], + }, + { + name: 'Mass', + formats: [ + { name: 'milligram (mg)', id: 'massmg', fn: SIPrefix('g', -1) }, + { name: 'gram (g)', id: 'massg', fn: SIPrefix('g') }, + { name: 'kilogram (kg)', id: 'masskg', fn: SIPrefix('g', 1) }, + { name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') }, + ], + }, + { + name: 'Length', + formats: [ + { name: 'millimeter (mm)', id: 'lengthmm', fn: SIPrefix('m', -1) }, + { name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') }, + { name: 'meter (m)', id: 'lengthm', fn: SIPrefix('m') }, + { name: 'kilometer (km)', id: 'lengthkm', fn: SIPrefix('m', 1) }, + { name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') }, + ], + }, + { + name: 'Pressure', + formats: [ + { name: 'Millibars', id: 'pressurembar', fn: SIPrefix('bar', -1) }, + { name: 'Bars', id: 'pressurebar', fn: SIPrefix('bar') }, + { name: 'Kilobars', id: 'pressurekbar', fn: SIPrefix('bar', 1) }, + { name: 'Pascals', id: 'pressurepa', fn: SIPrefix('Pa') }, + { name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') }, + { name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') }, + { name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') }, + { name: 'PSI', id: 'pressurepsi', fn: scaledUnits(1000, ['psi', 'ksi', 'Mpsi']) }, + ], + }, + { + name: 'Radiation', + formats: [ + { name: 'Becquerel (Bq)', id: 'radbq', fn: SIPrefix('Bq') }, + { name: 'curie (Ci)', id: 'radci', fn: SIPrefix('Ci') }, + { name: 'Gray (Gy)', id: 'radgy', fn: SIPrefix('Gy') }, + { name: 'rad', id: 'radrad', fn: SIPrefix('rad') }, + { name: 'Sievert (Sv)', id: 'radsv', fn: SIPrefix('Sv') }, + { name: 'milliSievert (mSv)', id: 'radmsv', fn: SIPrefix('Sv', -1) }, + { name: 'microSievert (µSv)', id: 'radusv', fn: SIPrefix('Sv', -2) }, + { name: 'rem', id: 'radrem', fn: SIPrefix('rem') }, + { name: 'Exposure (C/kg)', id: 'radexpckg', fn: SIPrefix('C/kg') }, + { name: 'roentgen (R)', id: 'radr', fn: SIPrefix('R') }, + { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: SIPrefix('Sv/h') }, + { name: 'milliSievert/hour (mSv/h)', id: 'radmsvh', fn: SIPrefix('Sv/h', -1) }, + { name: 'microSievert/hour (µSv/h)', id: 'radusvh', fn: SIPrefix('Sv/h', -2) }, + ], + }, + { + name: 'Rotational Speed', + formats: [ + { name: 'Revolutions per minute (rpm)', id: 'rotrpm', fn: toFixedUnit('rpm') }, + { name: 'Hertz (Hz)', id: 'rothz', fn: SIPrefix('Hz') }, + { name: 'Radians per second (rad/s)', id: 'rotrads', fn: toFixedUnit('rad/s') }, + { name: 'Degrees per second (°/s)', id: 'rotdegs', fn: toFixedUnit('°/s') }, + ], + }, + { + name: 'Temperature', + formats: [ + { name: 'Celsius (°C)', id: 'celsius', fn: toFixedUnit('°C') }, + { name: 'Fahrenheit (°F)', id: 'fahrenheit', fn: toFixedUnit('°F') }, + { name: 'Kelvin (K)', id: 'kelvin', fn: toFixedUnit('K') }, + ], + }, + { + name: 'Time', + formats: [ + { name: 'Hertz (1/s)', id: 'hertz', fn: SIPrefix('Hz') }, + { name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds }, + { name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds }, + { name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds }, + { name: 'seconds (s)', id: 's', fn: toSeconds }, + { name: 'minutes (m)', id: 'm', fn: toMinutes }, + { name: 'hours (h)', id: 'h', fn: toHours }, + { name: 'days (d)', id: 'd', fn: toDays }, + { name: 'duration (ms)', id: 'dtdurationms', fn: toDurationInMilliseconds }, + { name: 'duration (s)', id: 'dtdurations', fn: toDurationInSeconds }, + { name: 'duration (hh:mm:ss)', id: 'dthms', fn: toDurationInHoursMinutesSeconds }, + { name: 'duration (d hh:mm:ss)', id: 'dtdhms', fn: toDurationInDaysHoursMinutesSeconds }, + { name: 'Timeticks (s/100)', id: 'timeticks', fn: toTimeTicks }, + { name: 'clock (ms)', id: 'clockms', fn: toClockMilliseconds }, + { name: 'clock (s)', id: 'clocks', fn: toClockSeconds }, + ], + }, + { + name: 'Throughput', + formats: [ + { name: 'counts/sec (cps)', id: 'cps', fn: simpleCountUnit('c/s') }, + { name: 'ops/sec (ops)', id: 'ops', fn: simpleCountUnit('ops/s') }, + { name: 'requests/sec (rps)', id: 'reqps', fn: simpleCountUnit('req/s') }, + { name: 'reads/sec (rps)', id: 'rps', fn: simpleCountUnit('rd/s') }, + { name: 'writes/sec (wps)', id: 'wps', fn: simpleCountUnit('wr/s') }, + { name: 'I/O ops/sec (iops)', id: 'iops', fn: simpleCountUnit('io/s') }, + { name: 'counts/min (cpm)', id: 'cpm', fn: simpleCountUnit('c/m') }, + { name: 'ops/min (opm)', id: 'opm', fn: simpleCountUnit('ops/m') }, + { name: 'reads/min (rpm)', id: 'rpm', fn: simpleCountUnit('rd/m') }, + { name: 'writes/min (wpm)', id: 'wpm', fn: simpleCountUnit('wr/m') }, + ], + }, + { + name: 'Velocity', + formats: [ + { name: 'meters/second (m/s)', id: 'velocityms', fn: toFixedUnit('m/s') }, + { name: 'kilometers/hour (km/h)', id: 'velocitykmh', fn: toFixedUnit('km/h') }, + { name: 'miles/hour (mph)', id: 'velocitymph', fn: toFixedUnit('mph') }, + { name: 'knot (kn)', id: 'velocityknot', fn: toFixedUnit('kn') }, + ], + }, + { + name: 'Volume', + formats: [ + { name: 'millilitre (mL)', id: 'mlitre', fn: SIPrefix('L', -1) }, + { name: 'litre (L)', id: 'litre', fn: SIPrefix('L') }, + { name: 'cubic meter', id: 'm3', fn: toFixedUnit('m³') }, + { name: 'Normal cubic meter', id: 'Nm3', fn: toFixedUnit('Nm³') }, + { name: 'cubic decimeter', id: 'dm3', fn: toFixedUnit('dm³') }, + { name: 'gallons', id: 'gallons', fn: toFixedUnit('gal') }, + ], + }, +]; diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts new file mode 100644 index 0000000..c40d7dd --- /dev/null +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts @@ -0,0 +1,374 @@ +import { + dateTimeAsIso, + dateTimeAsIsoNoDateIfToday, + dateTimeAsUS, + dateTimeAsUSNoDateIfToday, + getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, + dateTimeFromNow, + Interval, + toClock, + toDuration, + toDurationInMilliseconds, + toDurationInSeconds, + toDurationInHoursMinutesSeconds, + toDurationInDaysHoursMinutesSeconds, + toNanoSeconds, + toSeconds, +} from './dateTimeFormatters'; +import { formattedValueToString } from './valueFormats'; +import { toUtc, dateTime } from '../datetime/moment_wrapper'; + +describe('date time formats', () => { + const epoch = 1505634997920; + const utcTime = toUtc(epoch); + const browserTime = dateTime(epoch); + + it('should format as iso date', () => { + const expected = browserTime.format('YYYY-MM-DD HH:mm:ss'); + const actual = dateTimeAsIso(epoch, 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date (in UTC)', () => { + const expected = utcTime.format('YYYY-MM-DD HH:mm:ss'); + const actual = dateTimeAsIso(epoch, 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date and skip date when today', () => { + const now = dateTime(); + const expected = now.format('HH:mm:ss'); + const actual = dateTimeAsIsoNoDateIfToday(now.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as iso date (in UTC) and skip date when today', () => { + const now = toUtc(); + const expected = now.format('HH:mm:ss'); + const actual = dateTimeAsIsoNoDateIfToday(now.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as US date', () => { + const expected = browserTime.format('MM/DD/YYYY h:mm:ss a'); + const actual = dateTimeAsUS(epoch, 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as US date (in UTC)', () => { + const expected = utcTime.format('MM/DD/YYYY h:mm:ss a'); + const actual = dateTimeAsUS(epoch, 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as US date and skip date when today', () => { + const now = dateTime(); + const expected = now.format('h:mm:ss a'); + const actual = dateTimeAsUSNoDateIfToday(now.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as US date (in UTC) and skip date when today', () => { + const now = toUtc(); + const expected = now.format('h:mm:ss a'); + const actual = dateTimeAsUSNoDateIfToday(now.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as local date', () => { + const dateTimeObject = browserTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0).text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date and skip date when today', () => { + const now = dateTime(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0).text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC)', () => { + const dateTimeObject = utcTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0, 'utc').text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC) and skip date when today', () => { + const now = toUtc(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0, 'utc').text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as from now with days', () => { + const daysAgo = dateTime().add(-7, 'd'); + const expected = '7 days ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with days (in UTC)', () => { + const daysAgo = toUtc().add(-7, 'd'); + const expected = '7 days ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with minutes', () => { + const daysAgo = dateTime().add(-2, 'm'); + const expected = '2 minutes ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0); + expect(actual.text).toBe(expected); + }); + + it('should format as from now with minutes (in UTC)', () => { + const daysAgo = toUtc().add(-2, 'm'); + const expected = '2 minutes ago'; + const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, 'utc'); + expect(actual.text).toBe(expected); + }); +}); + +describe('duration', () => { + it('0 milliseconds', () => { + const str = toDurationInMilliseconds(0, 0); + expect(formattedValueToString(str)).toBe('0 milliseconds'); + }); + it('1 millisecond', () => { + const str = toDurationInMilliseconds(1, 0); + expect(formattedValueToString(str)).toBe('1 millisecond'); + }); + it('-1 millisecond', () => { + const str = toDurationInMilliseconds(-1, 0); + expect(formattedValueToString(str)).toBe('1 millisecond ago'); + }); + it('seconds', () => { + const str = toDurationInSeconds(1, 0); + expect(formattedValueToString(str)).toBe('1 second'); + }); + it('minutes', () => { + const str = toDuration(1, 0, Interval.Minute); + expect(formattedValueToString(str)).toBe('1 minute'); + }); + it('hours', () => { + const str = toDuration(1, 0, Interval.Hour); + expect(formattedValueToString(str)).toBe('1 hour'); + }); + it('days', () => { + const str = toDuration(1, 0, Interval.Day); + expect(formattedValueToString(str)).toBe('1 day'); + }); + it('weeks', () => { + const str = toDuration(1, 0, Interval.Week); + expect(formattedValueToString(str)).toBe('1 week'); + }); + it('months', () => { + const str = toDuration(1, 0, Interval.Month); + expect(formattedValueToString(str)).toBe('1 month'); + }); + it('years', () => { + const str = toDuration(1, 0, Interval.Year); + expect(formattedValueToString(str)).toBe('1 year'); + }); + it('decimal days', () => { + const str = toDuration(1.5, 2, Interval.Day); + expect(formattedValueToString(str)).toBe('1 day, 12 hours, 0 minutes'); + }); + it('decimal months', () => { + const str = toDuration(1.5, 3, Interval.Month); + expect(formattedValueToString(str)).toBe('1 month, 2 weeks, 1 day, 0 hours'); + }); + it('no decimals', () => { + const str = toDuration(38898367008, 0, Interval.Millisecond); + expect(formattedValueToString(str)).toBe('1 year'); + }); + it('1 decimal', () => { + const str = toDuration(38898367008, 1, Interval.Millisecond); + expect(formattedValueToString(str)).toBe('1 year, 2 months'); + }); + it('too many decimals', () => { + const str = toDuration(38898367008, 20, Interval.Millisecond); + expect(formattedValueToString(str)).toBe( + '1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds' + ); + }); + it('floating point error', () => { + const str = toDuration(36993906007, 8, Interval.Millisecond); + expect(formattedValueToString(str)).toBe( + '1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds' + ); + }); + it('1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(1); + expect(formattedValueToString(str)).toBe('00:00:01'); + }); + it('-1 dthms', () => { + const str = toDurationInHoursMinutesSeconds(-1); + expect(formattedValueToString(str)).toBe('00:00:01 ago'); + }); + it('0 dthms', () => { + const str = toDurationInHoursMinutesSeconds(0); + expect(formattedValueToString(str)).toBe('00:00:00'); + }); + it('1 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(1); + expect(formattedValueToString(str)).toBe('00:00:01'); + }); + it('-1 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(-1); + expect(formattedValueToString(str)).toBe('00:00:01 ago'); + }); + it('0 dtdhms', () => { + const str = toDurationInHoursMinutesSeconds(0); + expect(formattedValueToString(str)).toBe('00:00:00'); + }); + it('86399 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(86399); + expect(formattedValueToString(str)).toBe('23:59:59'); + }); + it('86400 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(86400); + expect(formattedValueToString(str)).toBe('1 d 00:00:00'); + }); + it('360000 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(360000); + expect(formattedValueToString(str)).toBe('4 d 04:00:00'); + }); + it('1179811 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(1179811); + expect(formattedValueToString(str)).toBe('13 d 15:43:31'); + }); + it('-1179811 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(-1179811); + expect(formattedValueToString(str)).toBe('13 d 15:43:31 ago'); + }); + it('116876364 dtdhms', () => { + const str = toDurationInDaysHoursMinutesSeconds(116876364); + expect(formattedValueToString(str)).toBe('1352 d 17:39:24'); + }); +}); + +describe('clock', () => { + it('size less than 1 second', () => { + const str = toClock(999, 0); + expect(formattedValueToString(str)).toBe('999ms'); + }); + describe('size less than 1 minute', () => { + it('default', () => { + const str = toClock(59999); + expect(formattedValueToString(str)).toBe('59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(59999, 0); + expect(formattedValueToString(str)).toBe('59s'); + }); + }); + describe('size less than 1 hour', () => { + it('default', () => { + const str = toClock(3599999); + expect(formattedValueToString(str)).toBe('59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(3599999, 0); + expect(formattedValueToString(str)).toBe('59m'); + }); + it('decimals equals 1', () => { + const str = toClock(3599999, 1); + expect(formattedValueToString(str)).toBe('59m:59s'); + }); + }); + describe('size greater than or equal 1 hour', () => { + it('default', () => { + const str = toClock(7199999); + expect(formattedValueToString(str)).toBe('01h:59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(7199999, 0); + expect(formattedValueToString(str)).toBe('01h'); + }); + it('decimals equals 1', () => { + const str = toClock(7199999, 1); + expect(formattedValueToString(str)).toBe('01h:59m'); + }); + it('decimals equals 2', () => { + const str = toClock(7199999, 2); + expect(formattedValueToString(str)).toBe('01h:59m:59s'); + }); + }); + describe('size greater than or equal 1 day', () => { + it('default', () => { + const str = toClock(89999999); + expect(formattedValueToString(str)).toBe('24h:59m:59s:999ms'); + }); + it('decimals equals 0', () => { + const str = toClock(89999999, 0); + expect(formattedValueToString(str)).toBe('24h'); + }); + it('decimals equals 1', () => { + const str = toClock(89999999, 1); + expect(formattedValueToString(str)).toBe('24h:59m'); + }); + it('decimals equals 2', () => { + const str = toClock(89999999, 2); + expect(formattedValueToString(str)).toBe('24h:59m:59s'); + }); + }); +}); + +describe('to nanoseconds', () => { + it('should correctly display as ns', () => { + const tenNanoseconds = toNanoSeconds(10); + expect(tenNanoseconds.text).toBe('10'); + expect(tenNanoseconds.suffix).toBe(' ns'); + }); + + it('should correctly display as µs', () => { + const threeMicroseconds = toNanoSeconds(3000); + expect(threeMicroseconds.text).toBe('3'); + expect(threeMicroseconds.suffix).toBe(' µs'); + }); + + it('should correctly display as ms', () => { + const fourMilliseconds = toNanoSeconds(4000000); + expect(fourMilliseconds.text).toBe('4'); + expect(fourMilliseconds.suffix).toBe(' ms'); + }); + + it('should correctly display as s', () => { + const fiveSeconds = toNanoSeconds(5000000000); + expect(fiveSeconds.text).toBe('5'); + expect(fiveSeconds.suffix).toBe(' s'); + }); + + it('should correctly display as minutes', () => { + const eightMinutes = toNanoSeconds(480000000000); + expect(eightMinutes.text).toBe('8'); + expect(eightMinutes.suffix).toBe(' min'); + }); + + it('should correctly display as hours', () => { + const nineHours = toNanoSeconds(32400000000000); + expect(nineHours.text).toBe('9'); + expect(nineHours.suffix).toBe(' hour'); + }); + + it('should correctly display as days', () => { + const tenDays = toNanoSeconds(864000000000000); + expect(tenDays.text).toBe('10'); + expect(tenDays.suffix).toBe(' day'); + }); +}); + +describe('seconds', () => { + it('should show 0 as 0', () => { + const zeroSeconds = toSeconds(0); + expect(zeroSeconds.text).toBe('0'); + expect(zeroSeconds.suffix).toBe(' s'); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts new file mode 100644 index 0000000..93f9a81 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts @@ -0,0 +1,420 @@ +import { toDuration as duration, toUtc, dateTime } from '../datetime/moment_wrapper'; + +import { toFixed, toFixedScaled, FormattedValue, ValueFormatter } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; +import { TimeZone } from '../types'; +import { dateTimeFormat, dateTimeFormatTimeAgo, localTimeFormat, systemDateFormats } from '../datetime'; + +interface IntervalsInSeconds { + [interval: string]: number; +} + +export enum Interval { + Year = 'year', + Month = 'month', + Week = 'week', + Day = 'day', + Hour = 'hour', + Minute = 'minute', + Second = 'second', + Millisecond = 'millisecond', +} + +const INTERVALS_IN_SECONDS: IntervalsInSeconds = { + [Interval.Year]: 31536000, + [Interval.Month]: 2592000, + [Interval.Week]: 604800, + [Interval.Day]: 86400, + [Interval.Hour]: 3600, + [Interval.Minute]: 60, + [Interval.Second]: 1, + [Interval.Millisecond]: 0.001, +}; + +export function toNanoSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' ns' }; + } else if (Math.abs(size) < 1000000) { + return toFixedScaled(size / 1000, decimals, ' µs'); + } else if (Math.abs(size) < 1000000000) { + return toFixedScaled(size / 1000000, decimals, ' ms'); + } else if (Math.abs(size) < 60000000000) { + return toFixedScaled(size / 1000000000, decimals, ' s'); + } else if (Math.abs(size) < 3600000000000) { + return toFixedScaled(size / 60000000000, decimals, ' min'); + } else if (Math.abs(size) < 86400000000000) { + return toFixedScaled(size / 3600000000000, decimals, ' hour'); + } else { + return toFixedScaled(size / 86400000000000, decimals, ' day'); + } +} + +export function toMicroSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' µs' }; + } else if (Math.abs(size) < 1000000) { + return toFixedScaled(size / 1000, decimals, ' ms'); + } else { + return toFixedScaled(size / 1000000, decimals, ' s'); + } +} + +export function toMilliSeconds(size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 1000) { + return { text: toFixed(size, decimals), suffix: ' ms' }; + } else if (Math.abs(size) < 60000) { + // Less than 1 min + return toFixedScaled(size / 1000, decimals, ' s'); + } else if (Math.abs(size) < 3600000) { + // Less than 1 hour, divide in minutes + return toFixedScaled(size / 60000, decimals, ' min'); + } else if (Math.abs(size) < 86400000) { + // Less than one day, divide in hours + return toFixedScaled(size / 3600000, decimals, ' hour'); + } else if (Math.abs(size) < 31536000000) { + // Less than one year, divide in days + return toFixedScaled(size / 86400000, decimals, ' day'); + } + + return toFixedScaled(size / 31536000000, decimals, ' year'); +} + +export function trySubstract(value1: DecimalCount, value2: DecimalCount): DecimalCount { + if (value1 !== null && value1 !== undefined && value2 !== null && value2 !== undefined) { + return value1 - value2; + } + return undefined; +} + +export function toSeconds(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + // If 0, use s unit instead of ns + if (size === 0) { + return { text: '0', suffix: ' s' }; + } + + // Less than 1 µs, divide in ns + if (Math.abs(size) < 0.000001) { + return toFixedScaled(size * 1e9, decimals, ' ns'); + } + // Less than 1 ms, divide in µs + if (Math.abs(size) < 0.001) { + return toFixedScaled(size * 1e6, decimals, ' µs'); + } + // Less than 1 second, divide in ms + if (Math.abs(size) < 1) { + return toFixedScaled(size * 1e3, decimals, ' ms'); + } + + if (Math.abs(size) < 60) { + return { text: toFixed(size, decimals), suffix: ' s' }; + } else if (Math.abs(size) < 3600) { + // Less than 1 hour, divide in minutes + return toFixedScaled(size / 60, decimals, ' min'); + } else if (Math.abs(size) < 86400) { + // Less than one day, divide in hours + return toFixedScaled(size / 3600, decimals, ' hour'); + } else if (Math.abs(size) < 604800) { + // Less than one week, divide in days + return toFixedScaled(size / 86400, decimals, ' day'); + } else if (Math.abs(size) < 31536000) { + // Less than one year, divide in week + return toFixedScaled(size / 604800, decimals, ' week'); + } + + return toFixedScaled(size / 3.15569e7, decimals, ' year'); +} + +export function toMinutes(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 60) { + return { text: toFixed(size, decimals), suffix: ' min' }; + } else if (Math.abs(size) < 1440) { + return toFixedScaled(size / 60, decimals, ' hour'); + } else if (Math.abs(size) < 10080) { + return toFixedScaled(size / 1440, decimals, ' day'); + } else if (Math.abs(size) < 604800) { + return toFixedScaled(size / 10080, decimals, ' week'); + } else { + return toFixedScaled(size / 5.25948e5, decimals, ' year'); + } +} + +export function toHours(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 24) { + return { text: toFixed(size, decimals), suffix: ' hour' }; + } else if (Math.abs(size) < 168) { + return toFixedScaled(size / 24, decimals, ' day'); + } else if (Math.abs(size) < 8760) { + return toFixedScaled(size / 168, decimals, ' week'); + } else { + return toFixedScaled(size / 8760, decimals, ' year'); + } +} + +export function toDays(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (Math.abs(size) < 7) { + return { text: toFixed(size, decimals), suffix: ' day' }; + } else if (Math.abs(size) < 365) { + return toFixedScaled(size / 7, decimals, ' week'); + } else { + return toFixedScaled(size / 365, decimals, ' year'); + } +} + +export function toDuration(size: number, decimals: DecimalCount, timeScale: Interval): FormattedValue { + if (size === null) { + return { text: '' }; + } + + if (size === 0) { + return { text: '0', suffix: ' ' + timeScale + 's' }; + } + + if (size < 0) { + const v = toDuration(-size, decimals, timeScale); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + + const units = [ + { long: Interval.Year }, + { long: Interval.Month }, + { long: Interval.Week }, + { long: Interval.Day }, + { long: Interval.Hour }, + { long: Interval.Minute }, + { long: Interval.Second }, + { long: Interval.Millisecond }, + ]; + + // convert $size to milliseconds + // intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors + size *= INTERVALS_IN_SECONDS[timeScale] * 1000; + + const strings = []; + + // after first value >= 1 print only $decimals more + let decrementDecimals = false; + let decimalsCount = 0; + + if (decimals !== null && decimals !== undefined) { + decimalsCount = decimals as number; + } + + for (let i = 0; i < units.length && decimalsCount >= 0; i++) { + const interval = INTERVALS_IN_SECONDS[units[i].long] * 1000; + const value = size / interval; + if (value >= 1 || decrementDecimals) { + decrementDecimals = true; + const floor = Math.floor(value); + const unit = units[i].long + (floor !== 1 ? 's' : ''); + strings.push(floor + ' ' + unit); + size = size % interval; + decimalsCount--; + } + } + + return { text: strings.join(', ') }; +} + +export function toClock(size: number, decimals?: DecimalCount): FormattedValue { + if (size === null) { + return { text: '' }; + } + + // < 1 second + if (size < 1000) { + return { + text: toUtc(size).format('SSS\\m\\s'), + }; + } + + // < 1 minute + if (size < 60000) { + let format = 'ss\\s:SSS\\m\\s'; + if (decimals === 0) { + format = 'ss\\s'; + } + return { text: toUtc(size).format(format) }; + } + + // < 1 hour + if (size < 3600000) { + let format = 'mm\\m:ss\\s:SSS\\m\\s'; + if (decimals === 0) { + format = 'mm\\m'; + } else if (decimals === 1) { + format = 'mm\\m:ss\\s'; + } + return { text: toUtc(size).format(format) }; + } + + let format = 'mm\\m:ss\\s:SSS\\m\\s'; + + const hours = `${('0' + Math.floor(duration(size, 'milliseconds').asHours())).slice(-2)}h`; + + if (decimals === 0) { + format = ''; + } else if (decimals === 1) { + format = 'mm\\m'; + } else if (decimals === 2) { + format = 'mm\\m:ss\\s'; + } + + const text = format ? `${hours}:${toUtc(size).format(format)}` : hours; + return { text }; +} + +export function toDurationInMilliseconds(size: number, decimals: DecimalCount): FormattedValue { + return toDuration(size, decimals, Interval.Millisecond); +} + +export function toDurationInSeconds(size: number, decimals: DecimalCount): FormattedValue { + return toDuration(size, decimals, Interval.Second); +} + +export function toDurationInHoursMinutesSeconds(size: number): FormattedValue { + if (size < 0) { + const v = toDurationInHoursMinutesSeconds(-size); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + const strings = []; + const numHours = Math.floor(size / 3600); + const numMinutes = Math.floor((size % 3600) / 60); + const numSeconds = Math.floor((size % 3600) % 60); + numHours > 9 ? strings.push('' + numHours) : strings.push('0' + numHours); + numMinutes > 9 ? strings.push('' + numMinutes) : strings.push('0' + numMinutes); + numSeconds > 9 ? strings.push('' + numSeconds) : strings.push('0' + numSeconds); + return { text: strings.join(':') }; +} + +export function toDurationInDaysHoursMinutesSeconds(size: number): FormattedValue { + if (size < 0) { + const v = toDurationInDaysHoursMinutesSeconds(-size); + if (!v.suffix) { + v.suffix = ''; + } + v.suffix += ' ago'; + return v; + } + let dayString = ''; + const numDays = Math.floor(size / (24 * 3600)); + if (numDays > 0) { + dayString = numDays + ' d '; + } + const hmsString = toDurationInHoursMinutesSeconds(size - numDays * 24 * 3600); + return { text: dayString + hmsString.text }; +} + +export function toTimeTicks(size: number, decimals: DecimalCount): FormattedValue { + return toSeconds(size / 100, decimals); +} + +export function toClockMilliseconds(size: number, decimals: DecimalCount): FormattedValue { + return toClock(size, decimals); +} + +export function toClockSeconds(size: number, decimals: DecimalCount): FormattedValue { + return toClock(size * 1000, decimals); +} + +export function toDateTimeValueFormatter(pattern: string, todayPattern?: string): ValueFormatter { + return (value: number, decimals: DecimalCount, scaledDecimals: DecimalCount, timeZone?: TimeZone): FormattedValue => { + if (todayPattern) { + if (dateTime().isSame(value, 'day')) { + return { + text: dateTimeFormat(value, { format: todayPattern, timeZone }), + }; + } + } + return { text: dateTimeFormat(value, { format: pattern, timeZone }) }; + }; +} + +export const dateTimeAsIso = toDateTimeValueFormatter('YYYY-MM-DD HH:mm:ss'); +export const dateTimeAsIsoNoDateIfToday = toDateTimeValueFormatter('YYYY-MM-DD HH:mm:ss', 'HH:mm:ss'); +export const dateTimeAsUS = toDateTimeValueFormatter('MM/DD/YYYY h:mm:ss a'); +export const dateTimeAsUSNoDateIfToday = toDateTimeValueFormatter('MM/DD/YYYY h:mm:ss a', 'h:mm:ss a'); + +export function getDateTimeAsLocalFormat() { + return toDateTimeValueFormatter( + localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); +} + +export function getDateTimeAsLocalFormatNoDateIfToday() { + return toDateTimeValueFormatter( + localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + localTimeFormat({ + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); +} + +export function dateTimeSystemFormatter( + value: number, + decimals: DecimalCount, + scaledDecimals: DecimalCount, + timeZone?: TimeZone +): FormattedValue { + return { text: dateTimeFormat(value, { format: systemDateFormats.fullDate, timeZone }) }; +} + +export function dateTimeFromNow( + value: number, + decimals: DecimalCount, + scaledDecimals: DecimalCount, + timeZone?: TimeZone +): FormattedValue { + return { text: dateTimeFormatTimeAgo(value, { timeZone }) }; +} diff --git a/packages/grafana-data/src/valueFormats/index.ts b/packages/grafana-data/src/valueFormats/index.ts new file mode 100644 index 0000000..c76a443 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/index.ts @@ -0,0 +1 @@ +export * from './valueFormats'; diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts new file mode 100644 index 0000000..63f0e46 --- /dev/null +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.test.ts @@ -0,0 +1,55 @@ +import { currency } from './symbolFormatters'; + +describe('currency', () => { + const symbol = '@'; + + describe('when called without asSuffix', () => { + const fmtFunc = currency(symbol); + + it.each` + value | expectedSuffix | expectedText + ${999} | ${''} | ${'999'} + ${1000} | ${'K'} | ${'1'} + ${1000000} | ${'M'} | ${'1'} + ${1000000000} | ${'B'} | ${'1'} + ${1000000000000} | ${'T'} | ${'1'} + ${1000000000000000} | ${undefined} | ${'NA'} + ${-1000000000000} | ${'T'} | ${'-1'} + ${-1000000000} | ${'B'} | ${'-1'} + ${-1000000} | ${'M'} | ${'-1'} + ${-1000} | ${'K'} | ${'-1'} + ${-999} | ${''} | ${'-999'} + `('when called with value:{$value}', ({ value, expectedSuffix, expectedText }) => { + const { prefix, suffix, text } = fmtFunc(value); + + expect(prefix).toEqual(symbol); + expect(suffix).toEqual(expectedSuffix); + expect(text).toEqual(expectedText); + }); + }); + + describe('when called with asSuffix', () => { + const fmtFunc = currency(symbol, true); + + it.each` + value | expectedSuffix | expectedText + ${999} | ${'@'} | ${'999'} + ${1000} | ${'K@'} | ${'1'} + ${1000000} | ${'M@'} | ${'1'} + ${1000000000} | ${'B@'} | ${'1'} + ${1000000000000} | ${'T@'} | ${'1'} + ${1000000000000000} | ${undefined} | ${'NA'} + ${-1000000000000} | ${'T@'} | ${'-1'} + ${-1000000000} | ${'B@'} | ${'-1'} + ${-1000000} | ${'M@'} | ${'-1'} + ${-1000} | ${'K@'} | ${'-1'} + ${-999} | ${'@'} | ${'-999'} + `('when called with value:{$value}', ({ value, expectedSuffix, expectedText }) => { + const { prefix, suffix, text } = fmtFunc(value); + + expect(prefix).toEqual(undefined); + expect(suffix).toEqual(expectedSuffix); + expect(text).toEqual(expectedText); + }); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/symbolFormatters.ts b/packages/grafana-data/src/valueFormats/symbolFormatters.ts new file mode 100644 index 0000000..54c96bf --- /dev/null +++ b/packages/grafana-data/src/valueFormats/symbolFormatters.ts @@ -0,0 +1,71 @@ +import { scaledUnits, ValueFormatter } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; + +export function currency(symbol: string, asSuffix?: boolean): ValueFormatter { + const units = ['', 'K', 'M', 'B', 'T']; + const scaler = scaledUnits(1000, units); + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const scaled = scaler(size, decimals, scaledDecimals); + if (asSuffix) { + scaled.suffix = scaled.suffix !== undefined ? `${scaled.suffix}${symbol}` : undefined; + } else { + scaled.prefix = symbol; + } + return scaled; + }; +} + +export function getOffsetFromSIPrefix(c: string): number { + switch (c) { + case 'f': + return -5; + case 'p': + return -4; + case 'n': + return -3; + case 'μ': // Two different unicode chars for µ + case 'µ': + return -2; + case 'm': + return -1; + case '': + return 0; + case 'k': + return 1; + case 'M': + return 2; + case 'G': + return 3; + case 'T': + return 4; + case 'P': + return 5; + case 'E': + return 6; + case 'Z': + return 7; + case 'Y': + return 8; + } + return 0; +} + +export function binaryPrefix(unit: string, offset = 0): ValueFormatter { + const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset); + const units = prefixes.map((p) => { + return ' ' + p + unit; + }); + return scaledUnits(1024, units); +} + +export function SIPrefix(unit: string, offset = 0): ValueFormatter { + let prefixes = ['f', 'p', 'n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + prefixes = prefixes.slice(5 + (offset || 0)); + const units = prefixes.map((p) => { + return ' ' + p + unit; + }); + return scaledUnits(1000, units); +} diff --git a/packages/grafana-data/src/valueFormats/valueFormats.test.ts b/packages/grafana-data/src/valueFormats/valueFormats.test.ts new file mode 100644 index 0000000..3aedf0f --- /dev/null +++ b/packages/grafana-data/src/valueFormats/valueFormats.test.ts @@ -0,0 +1,140 @@ +import { toFixed, getValueFormat, scaledUnits, formattedValueToString } from './valueFormats'; +import { DecimalCount } from '../types/displayValue'; +import { TimeZone } from '../types'; +import { dateTime } from '../datetime'; + +interface ValueFormatTest { + id: string; + decimals?: DecimalCount; + scaledDecimals?: DecimalCount; + timeZone?: TimeZone; + value: number; + result: string; +} + +describe('valueFormats', () => { + it.each` + format | decimals | value | expected + ${'currencyUSD'} | ${2} | ${1532.82} | ${'$1.53K'} + ${'currencyKRW'} | ${2} | ${1532.82} | ${'₩1.53K'} + ${'currencyIDR'} | ${2} | ${1532.82} | ${'Rp1.53K'} + ${'none'} | ${undefined} | ${3.23} | ${'3.23'} + ${'none'} | ${undefined} | ${0.0245} | ${'0.0245'} + ${'none'} | ${undefined} | ${1 / 3} | ${'0.333'} + ${'ms'} | ${4} | ${0.0024} | ${'0.0024 ms'} + ${'ms'} | ${0} | ${100} | ${'100 ms'} + ${'ms'} | ${2} | ${1250} | ${'1.25 s'} + ${'ms'} | ${1} | ${10000086.123} | ${'2.8 hour'} + ${'ms'} | ${undefined} | ${1000} | ${'1 s'} + ${'ms'} | ${0} | ${1200} | ${'1 s'} + ${'short'} | ${undefined} | ${1000} | ${'1 K'} + ${'short'} | ${undefined} | ${1200} | ${'1.20 K'} + ${'short'} | ${undefined} | ${1250} | ${'1.25 K'} + ${'short'} | ${undefined} | ${1000000} | ${'1 Mil'} + ${'short'} | ${undefined} | ${1500000} | ${'1.50 Mil'} + ${'short'} | ${undefined} | ${1000120} | ${'1.00 Mil'} + ${'short'} | ${undefined} | ${98765} | ${'98.8 K'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'kbytes'} | ${undefined} | ${10000000} | ${'9.54 GiB'} + ${'deckbytes'} | ${undefined} | ${10000000} | ${'10 GB'} + ${'megwatt'} | ${3} | ${1000} | ${'1.000 GW'} + ${'kohm'} | ${3} | ${1000} | ${'1.000 MΩ'} + ${'Mohm'} | ${3} | ${1000} | ${'1.000 GΩ'} + ${'farad'} | ${3} | ${1000} | ${'1.000 kF'} + ${'µfarad'} | ${3} | ${1000} | ${'1.000 mF'} + ${'nfarad'} | ${3} | ${1000} | ${'1.000 µF'} + ${'pfarad'} | ${3} | ${1000} | ${'1.000 nF'} + ${'ffarad'} | ${3} | ${1000} | ${'1.000 pF'} + ${'henry'} | ${3} | ${1000} | ${'1.000 kH'} + ${'mhenry'} | ${3} | ${1000} | ${'1.000 H'} + ${'µhenry'} | ${3} | ${1000} | ${'1.000 mH'} + ${'a'} | ${0} | ${1532.82} | ${'1533 a'} + ${'b'} | ${0} | ${1532.82} | ${'1533 b'} + ${'prefix:b'} | ${undefined} | ${1532.82} | ${'b1533'} + ${'suffix:d'} | ${undefined} | ${1532.82} | ${'1533 d'} + ${'si:µF'} | ${2} | ${1234} | ${'1.23 mF'} + ${'si:µF'} | ${2} | ${1234000000} | ${'1.23 kF'} + ${'si:µF'} | ${2} | ${1234000000000000} | ${'1.23 GF'} + ${'count:xpm'} | ${2} | ${1234567} | ${'1.23M xpm'} + ${'count:x/min'} | ${2} | ${1234} | ${'1.23K x/min'} + ${'currency:@'} | ${2} | ${1234567} | ${'@1.23M'} + ${'currency:@'} | ${2} | ${1234} | ${'@1.23K'} + ${'time:YYYY'} | ${0} | ${dateTime(new Date(1999, 6, 2)).valueOf()} | ${'1999'} + ${'time:YYYY.MM'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010.07'} + ${'dateTimeAsIso'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dateTimeAsUS'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'07/02/2010 12:00:00 am'} + ${'dateTimeAsSystem'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'} + `( + 'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected', + async ({ format, value, decimals, expected }) => { + const result = getValueFormat(format)(value, decimals, undefined, undefined); + const full = formattedValueToString(result); + expect(full).toBe(expected); + } + ); + + it('Manually check a format', () => { + // helpful for adding tests one at a time with the debugger + const tests: ValueFormatTest[] = [ + { id: 'time:YYYY.MM', decimals: 0, value: dateTime(new Date(2010, 6, 2)).valueOf(), result: '2010.07' }, + ]; + const test = tests[0]; + const result = getValueFormat(test.id)(test.value, test.decimals, test.scaledDecimals); + const full = formattedValueToString(result); + expect(full).toBe(test.result); + }); + + describe('normal cases', () => { + it('toFixed should handle number correctly if decimal is null', () => { + expect(toFixed(100)).toBe('100'); + + expect(toFixed(100.4)).toBe('100'); + expect(toFixed(100.5)).toBe('101'); + }); + + it('toFixed should handle number correctly if decimal is not null', () => { + expect(toFixed(100, 1)).toBe('100.0'); + + expect(toFixed(100.37, 1)).toBe('100.4'); + expect(toFixed(100.63, 1)).toBe('100.6'); + + expect(toFixed(100.4, 2)).toBe('100.40'); + expect(toFixed(100.5, 2)).toBe('100.50'); + }); + }); + + describe('format edge cases', () => { + const negInf = Number.NEGATIVE_INFINITY.toLocaleString(); + const posInf = Number.POSITIVE_INFINITY.toLocaleString(); + + it('toFixed should handle non number input gracefully', () => { + expect(toFixed(NaN)).toBe('NaN'); + expect(toFixed(Number.NEGATIVE_INFINITY)).toBe(negInf); + expect(toFixed(Number.POSITIVE_INFINITY)).toBe(posInf); + }); + + it('scaledUnits should handle non number input gracefully', () => { + const disp = scaledUnits(5, ['a', 'b', 'c']); + expect(disp(NaN).text).toBe('NaN'); + expect(disp(Number.NEGATIVE_INFINITY).text).toBe(negInf); + expect(disp(Number.POSITIVE_INFINITY).text).toBe(posInf); + }); + }); + + describe('toFixed and negative decimals', () => { + it('should treat as zero decimals', () => { + const str = toFixed(186.123, -2); + expect(str).toBe('186'); + }); + }); + + describe('Resolve old units', () => { + it('resolve farenheit', () => { + const fmt0 = getValueFormat('farenheit'); + const fmt1 = getValueFormat('fahrenheit'); + expect(fmt0).toEqual(fmt1); + }); + }); +}); diff --git a/packages/grafana-data/src/valueFormats/valueFormats.ts b/packages/grafana-data/src/valueFormats/valueFormats.ts new file mode 100644 index 0000000..3d2174f --- /dev/null +++ b/packages/grafana-data/src/valueFormats/valueFormats.ts @@ -0,0 +1,265 @@ +import { getCategories } from './categories'; +import { DecimalCount } from '../types/displayValue'; +import { toDateTimeValueFormatter } from './dateTimeFormatters'; +import { getOffsetFromSIPrefix, SIPrefix, currency } from './symbolFormatters'; +import { TimeZone } from '../types'; + +export interface FormattedValue { + text: string; + prefix?: string; + suffix?: string; +} + +export function formattedValueToString(val: FormattedValue): string { + return `${val.prefix ?? ''}${val.text}${val.suffix ?? ''}`; +} + +export type ValueFormatter = ( + value: number, + decimals?: DecimalCount, + scaledDecimals?: DecimalCount, + timeZone?: TimeZone +) => FormattedValue; + +export interface ValueFormat { + name: string; + id: string; + fn: ValueFormatter; +} + +export interface ValueFormatCategory { + name: string; + formats: ValueFormat[]; +} + +export interface ValueFormatterIndex { + [id: string]: ValueFormatter; +} + +// Globals & formats cache +let categories: ValueFormatCategory[] = []; +const index: ValueFormatterIndex = {}; +let hasBuiltIndex = false; + +export function toFixed(value: number, decimals?: DecimalCount): string { + if (value === null) { + return ''; + } + + if (value === Number.NEGATIVE_INFINITY || value === Number.POSITIVE_INFINITY) { + return value.toLocaleString(); + } + + if (decimals === null || decimals === undefined) { + decimals = getDecimalsForValue(value); + } + + const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1; + const formatted = String(Math.round(value * factor) / factor); + + // if exponent return directly + if (formatted.indexOf('e') !== -1 || value === 0) { + return formatted; + } + + const decimalPos = formatted.indexOf('.'); + const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1; + if (precision < decimals) { + return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision); + } + + return formatted; +} + +function getDecimalsForValue(value: number): number { + const log10 = Math.floor(Math.log(Math.abs(value)) / Math.LN10); + let dec = -log10 + 1; + const magn = Math.pow(10, -dec); + const norm = value / magn; // norm is between 1.0 and 10.0 + + // special case for 2.5, requires an extra decimal + if (norm > 2.25) { + ++dec; + } + + if (value % 1 === 0) { + dec = 0; + } + + const decimals = Math.max(0, dec); + return decimals; +} + +export function toFixedScaled(value: number, decimals: DecimalCount, ext?: string): FormattedValue { + return { + text: toFixed(value, decimals), + suffix: ext, + }; +} + +export function toFixedUnit(unit: string, asPrefix?: boolean): ValueFormatter { + return (size: number, decimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const text = toFixed(size, decimals); + if (unit) { + if (asPrefix) { + return { text, prefix: unit }; + } + return { text, suffix: ' ' + unit }; + } + return { text }; + }; +} + +// Formatter which scales the unit string geometrically according to the given +// numeric factor. Repeatedly scales the value down by the factor until it is +// less than the factor in magnitude, or the end of the array is reached. +export function scaledUnits(factor: number, extArray: string[]): ValueFormatter { + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + if (size === Number.NEGATIVE_INFINITY || size === Number.POSITIVE_INFINITY || isNaN(size)) { + return { text: size.toLocaleString() }; + } + + let steps = 0; + const limit = extArray.length; + + while (Math.abs(size) >= factor) { + steps++; + size /= factor; + + if (steps >= limit) { + return { text: 'NA' }; + } + } + + return { text: toFixed(size, decimals), suffix: extArray[steps] }; + }; +} + +export function locale(value: number, decimals: DecimalCount): FormattedValue { + if (value == null) { + return { text: '' }; + } + return { + text: value.toLocaleString(undefined, { maximumFractionDigits: decimals as number }), + }; +} + +export function simpleCountUnit(symbol: string): ValueFormatter { + const units = ['', 'K', 'M', 'B', 'T']; + const scaler = scaledUnits(1000, units); + return (size: number, decimals?: DecimalCount, scaledDecimals?: DecimalCount) => { + if (size === null) { + return { text: '' }; + } + const v = scaler(size, decimals, scaledDecimals); + v.suffix += ' ' + symbol; + return v; + }; +} + +export function stringFormater(value: number): FormattedValue { + return { text: `${value}` }; +} + +function buildFormats() { + categories = getCategories(); + + for (const cat of categories) { + for (const format of cat.formats) { + index[format.id] = format.fn; + } + } + + // Resolve units pointing to old IDs + [{ from: 'farenheit', to: 'fahrenheit' }].forEach((alias) => { + const f = index[alias.to]; + if (f) { + index[alias.from] = f; + } + }); + + hasBuiltIndex = true; +} + +export function getValueFormat(id?: string | null): ValueFormatter { + if (!id) { + return toFixedUnit(''); + } + + if (!hasBuiltIndex) { + buildFormats(); + } + + const fmt = index[id]; + + if (!fmt && id) { + const idx = id.indexOf(':'); + + if (idx > 0) { + const key = id.substring(0, idx); + const sub = id.substring(idx + 1); + + if (key === 'prefix') { + return toFixedUnit(sub, true); + } + + if (key === 'suffix') { + return toFixedUnit(sub, false); + } + + if (key === 'time') { + return toDateTimeValueFormatter(sub); + } + + if (key === 'si') { + const offset = getOffsetFromSIPrefix(sub.charAt(0)); + const unit = offset === 0 ? sub : sub.substring(1); + return SIPrefix(unit, offset); + } + + if (key === 'count') { + return simpleCountUnit(sub); + } + + if (key === 'currency') { + return currency(sub); + } + } + + return toFixedUnit(id); + } + + return fmt; +} + +export function getValueFormatterIndex(): ValueFormatterIndex { + if (!hasBuiltIndex) { + buildFormats(); + } + + return index; +} + +export function getValueFormats() { + if (!hasBuiltIndex) { + buildFormats(); + } + + return categories.map((cat) => { + return { + text: cat.name, + submenu: cat.formats.map((format) => { + return { + text: format.name, + value: format.id, + }; + }), + }; + }); +} diff --git a/packages/grafana-data/src/vector/AppendedVectors.test.ts b/packages/grafana-data/src/vector/AppendedVectors.test.ts new file mode 100644 index 0000000..9d39b47 --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.test.ts @@ -0,0 +1,23 @@ +import { ArrayVector } from './ArrayVector'; +import { AppendedVectors } from './AppendedVectors'; + +describe('Check Appending Vector', () => { + it('should transparently join them', () => { + const appended = new AppendedVectors(); + appended.append(new ArrayVector([1, 2, 3])); + appended.append(new ArrayVector([4, 5, 6])); + appended.append(new ArrayVector([7, 8, 9])); + expect(appended.length).toEqual(9); + + appended.setLength(5); + expect(appended.length).toEqual(5); + appended.append(new ArrayVector(['a', 'b', 'c'])); + expect(appended.length).toEqual(8); + expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); + + appended.setLength(2); + appended.setLength(6); + appended.append(new ArrayVector(['x', 'y', 'z'])); + expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); + }); +}); diff --git a/packages/grafana-data/src/vector/AppendedVectors.ts b/packages/grafana-data/src/vector/AppendedVectors.ts new file mode 100644 index 0000000..a10568a --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.ts @@ -0,0 +1,73 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface AppendedVectorInfo { + start: number; + end: number; + values: Vector; +} + +/** + * This may be more trouble than it is worth. This trades some computation time for + * RAM -- rather than allocate a new array the size of all previous arrays, this just + * points the correct index to their original array values + */ +export class AppendedVectors implements Vector { + length = 0; + source: Array> = []; + + constructor(startAt = 0) { + this.length = startAt; + } + + /** + * Make the vector look like it is this long + */ + setLength(length: number) { + if (length > this.length) { + // make the vector longer (filling with undefined) + this.length = length; + } else if (length < this.length) { + // make the array shorter + const sources: Array> = []; + for (const src of this.source) { + sources.push(src); + if (src.end > length) { + src.end = length; + break; + } + } + this.source = sources; + this.length = length; + } + } + + append(v: Vector): AppendedVectorInfo { + const info = { + start: this.length, + end: this.length + v.length, + values: v, + }; + this.length = info.end; + this.source.push(info); + return info; + } + + get(index: number): T { + for (let i = 0; i < this.source.length; i++) { + const src = this.source[i]; + if (index >= src.start && index < src.end) { + return src.values.get(index - src.start); + } + } + return (undefined as unknown) as T; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ArrayVector.ts b/packages/grafana-data/src/vector/ArrayVector.ts new file mode 100644 index 0000000..3df650e --- /dev/null +++ b/packages/grafana-data/src/vector/ArrayVector.ts @@ -0,0 +1,42 @@ +import { MutableVector } from '../types/vector'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * @public + */ +export class ArrayVector extends FunctionalVector implements MutableVector { + buffer: T[]; + + constructor(buffer?: T[]) { + super(); + this.buffer = buffer ? buffer : []; + } + + get length() { + return this.buffer.length; + } + + add(value: T) { + this.buffer.push(value); + } + + get(index: number): T { + return this.buffer[index]; + } + + set(index: number, value: T) { + this.buffer[index] = value; + } + + reverse() { + this.buffer.reverse(); + } + + toArray(): T[] { + return this.buffer; + } + + toJSON(): T[] { + return this.buffer; + } +} diff --git a/packages/grafana-data/src/vector/AsNumberVector.ts b/packages/grafana-data/src/vector/AsNumberVector.ts new file mode 100644 index 0000000..c8a6591 --- /dev/null +++ b/packages/grafana-data/src/vector/AsNumberVector.ts @@ -0,0 +1,21 @@ +import { Vector } from '../types'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * This will force all values to be numbers + * + * @public + */ +export class AsNumberVector extends FunctionalVector { + constructor(private field: Vector) { + super(); + } + + get length() { + return this.field.length; + } + + get(index: number) { + return +this.field.get(index); + } +} diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.test.ts b/packages/grafana-data/src/vector/BinaryOperationVector.test.ts new file mode 100644 index 0000000..c6d9955 --- /dev/null +++ b/packages/grafana-data/src/vector/BinaryOperationVector.test.ts @@ -0,0 +1,18 @@ +import { ArrayVector } from './ArrayVector'; +import { BinaryOperationVector } from './BinaryOperationVector'; +import { ConstantVector } from './ConstantVector'; +import { binaryOperators, BinaryOperationID } from '../utils/binaryOperators'; + +describe('ScaledVector', () => { + it('should support multiply operations', () => { + const source = new ArrayVector([1, 2, 3, 4]); + const scale = 2.456; + const operation = binaryOperators.get(BinaryOperationID.Multiply).operation; + const v = new BinaryOperationVector(source, new ConstantVector(scale, source.length), operation); + expect(v.length).toEqual(source.length); + // expect(v.push(10)).toEqual(source.length); // not implemented + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(source.get(i) * scale); + } + }); +}); diff --git a/packages/grafana-data/src/vector/BinaryOperationVector.ts b/packages/grafana-data/src/vector/BinaryOperationVector.ts new file mode 100644 index 0000000..1740f99 --- /dev/null +++ b/packages/grafana-data/src/vector/BinaryOperationVector.ts @@ -0,0 +1,26 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; +import { BinaryOperation } from '../utils/binaryOperators'; + +/** + * @public + */ +export class BinaryOperationVector implements Vector { + constructor(private left: Vector, private right: Vector, private operation: BinaryOperation) {} + + get length(): number { + return this.left.length; + } + + get(index: number): number { + return this.operation(this.left.get(index), this.right.get(index)); + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/CircularVector.test.ts b/packages/grafana-data/src/vector/CircularVector.test.ts new file mode 100644 index 0000000..abffd15 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.test.ts @@ -0,0 +1,131 @@ +import { CircularVector } from './CircularVector'; + +describe('Check Circular Vector', () => { + it('should append values', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); // tail is default option + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + + v.add(8); + expect(v.toArray()).toEqual([6, 7, 8]); + }); + + it('should grow buffer until it hits capacity (append)', () => { + const v = new CircularVector({ capacity: 3 }); // tail is default option + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([1, 2]); + + v.add(3); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); + + it('should prepend values', () => { + const buffer = [3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + + v.add(8); + expect(v.toArray()).toEqual([8, 7, 6]); + }); + + it('should expand buffer and then prepend', () => { + const v = new CircularVector({ capacity: 3, append: 'head' }); + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([2, 1]); + + v.add(3); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + }); + + it('should reduce size and keep working (tail)', () => { + const buffer = [1, 2, 3, 4, 5]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3, 4, 5]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + }); + + it('should reduce size and keep working (head)', () => { + const buffer = [5, 4, 3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([5, 4, 3, 2, 1]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + }); + + it('change buffer direction', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.setAppendMode('head'); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.setAppendMode('tail'); + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); +}); diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts new file mode 100644 index 0000000..3f3b1f6 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -0,0 +1,143 @@ +import { MutableVector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; +import { FunctionalVector } from './FunctionalVector'; + +interface CircularOptions { + buffer?: T[]; + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * Circular vector uses a single buffer to capture a stream of values + * overwriting the oldest value on add. + * + * This supports adding to the 'head' or 'tail' and will grow the buffer + * to match a configured capacity. + * + * @public + */ +export class CircularVector extends FunctionalVector implements MutableVector { + private buffer: T[]; + private index: number; + private capacity: number; + private tail: boolean; + + constructor(options: CircularOptions) { + super(); + + this.buffer = options.buffer || []; + this.capacity = this.buffer.length; + this.tail = 'head' !== options.append; + this.index = 0; + + this.add = this.getAddFunction(); + if (options.capacity) { + this.setCapacity(options.capacity); + } + } + + /** + * This gets the appropriate add function depending on the buffer state: + * * head vs tail + * * growing buffer vs overwriting values + */ + private getAddFunction() { + // When we are not at capacity, it should actually modify the buffer + if (this.capacity > this.buffer.length) { + if (this.tail) { + return (value: T) => { + this.buffer.push(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } else { + return (value: T) => { + this.buffer.unshift(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } + } + + if (this.tail) { + return (value: T) => { + this.buffer[this.index] = value; + this.index = (this.index + 1) % this.buffer.length; + }; + } + + // Append values to the head + return (value: T) => { + let idx = this.index - 1; + if (idx < 0) { + idx = this.buffer.length - 1; + } + this.buffer[idx] = value; + this.index = idx; + }; + } + + setCapacity(v: number) { + if (this.capacity === v) { + return; + } + // Make a copy so it is in order and new additions can be at the head or tail + const copy = this.toArray(); + if (v > this.length) { + this.buffer = copy; + } else if (v < this.capacity) { + // Shrink the buffer + const delta = this.length - v; + if (this.tail) { + this.buffer = copy.slice(delta, copy.length); // Keep last items + } else { + this.buffer = copy.slice(0, copy.length - delta); // Keep first items + } + } + this.capacity = v; + this.index = 0; + this.add = this.getAddFunction(); + } + + setAppendMode(mode: 'head' | 'tail') { + const tail = 'head' !== mode; + if (tail !== this.tail) { + this.buffer = this.toArray().reverse(); + this.index = 0; + this.tail = tail; + this.add = this.getAddFunction(); + } + } + + reverse() { + this.buffer.reverse(); + } + + /** + * Add the value to the buffer + */ + add: (value: T) => void; + + get(index: number) { + return this.buffer[(index + this.index) % this.buffer.length]; + } + + set(index: number, value: T) { + this.buffer[(index + this.index) % this.buffer.length] = value; + } + + get length() { + return this.buffer.length; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ConstantVector.test.ts b/packages/grafana-data/src/vector/ConstantVector.test.ts new file mode 100644 index 0000000..b1b9422 --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.test.ts @@ -0,0 +1,17 @@ +import { ConstantVector } from './ConstantVector'; + +describe('ConstantVector', () => { + it('should support constant values', () => { + const value = 3.5; + const v = new ConstantVector(value, 7); + expect(v.length).toEqual(7); + + expect(v.get(0)).toEqual(value); + expect(v.get(1)).toEqual(value); + + // Now check all of them + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(value); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ConstantVector.ts b/packages/grafana-data/src/vector/ConstantVector.ts new file mode 100644 index 0000000..2024514 --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; + +/** + * @public + */ +export class ConstantVector implements Vector { + constructor(private value: T, private len: number) {} + + get length() { + return this.len; + } + + get(index: number): T { + return this.value; + } + + toArray(): T[] { + const arr = new Array(this.length); + return arr.fill(this.value); + } + + toJSON(): T[] { + return this.toArray(); + } +} diff --git a/packages/grafana-data/src/vector/FormattedVector.ts b/packages/grafana-data/src/vector/FormattedVector.ts new file mode 100644 index 0000000..f30e958 --- /dev/null +++ b/packages/grafana-data/src/vector/FormattedVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; +import { DisplayProcessor } from '../types'; +import { formattedValueToString } from '../valueFormats'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * @public + */ +export class FormattedVector extends FunctionalVector { + constructor(private source: Vector, private formatter: DisplayProcessor) { + super(); + } + + get length() { + return this.source.length; + } + + get(index: number): string { + const v = this.source.get(index); + return formattedValueToString(this.formatter(v)); + } +} diff --git a/packages/grafana-data/src/vector/FunctionalVector.ts b/packages/grafana-data/src/vector/FunctionalVector.ts new file mode 100644 index 0000000..6569881 --- /dev/null +++ b/packages/grafana-data/src/vector/FunctionalVector.ts @@ -0,0 +1,77 @@ +import { vectorToArray } from './vectorToArray'; +import { Vector } from '../types'; + +export abstract class FunctionalVector implements Vector, Iterable { + abstract get length(): number; + + abstract get(index: number): T; + + // Implement "iterator protocol" + *iterator() { + for (let i = 0; i < this.length; i++) { + yield this.get(i); + } + } + + // Implement "iterable protocol" + [Symbol.iterator]() { + return this.iterator(); + } + + forEach(iterator: (row: T) => void) { + return vectorator(this).forEach(iterator); + } + + map(transform: (item: T, index: number) => V) { + return vectorator(this).map(transform); + } + + filter(predicate: (item: T) => V) { + return vectorator(this).filter(predicate); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): any { + return this.toArray(); + } +} + +/** + * Use functional programming with your vector + */ +export function vectorator(vector: Vector) { + return { + *[Symbol.iterator]() { + for (let i = 0; i < vector.length; i++) { + yield vector.get(i); + } + }, + + forEach(iterator: (row: T) => void) { + for (let i = 0; i < vector.length; i++) { + iterator(vector.get(i)); + } + }, + + map(transform: (item: T, index: number) => V) { + const result: V[] = []; + for (let i = 0; i < vector.length; i++) { + result.push(transform(vector.get(i), i)); + } + return result; + }, + + filter(predicate: (item: T) => V) { + const result: T[] = []; + for (const val of this) { + if (predicate(val)) { + result.push(val); + } + } + return result; + }, + }; +} diff --git a/packages/grafana-data/src/vector/IndexVector.ts b/packages/grafana-data/src/vector/IndexVector.ts new file mode 100644 index 0000000..df695fc --- /dev/null +++ b/packages/grafana-data/src/vector/IndexVector.ts @@ -0,0 +1,36 @@ +import { Field, FieldType } from '../types'; +import { FunctionalVector } from './FunctionalVector'; + +/** + * IndexVector is a simple vector implementation that returns the index value + * for each element in the vector. It is functionally equivolant a vector backed + * by an array with values: `[0,1,2,...,length-1]` + */ +export class IndexVector extends FunctionalVector { + constructor(private len: number) { + super(); + } + + get length() { + return this.len; + } + + get(index: number): number { + return index; + } + + /** + * Returns a field representing the range [0 ... length-1] + */ + static newField(len: number): Field { + return { + name: '', + values: new IndexVector(len), + type: FieldType.number, + config: { + min: 0, + max: len - 1, + }, + }; + } +} diff --git a/packages/grafana-data/src/vector/RowVector.ts b/packages/grafana-data/src/vector/RowVector.ts new file mode 100644 index 0000000..d05904c --- /dev/null +++ b/packages/grafana-data/src/vector/RowVector.ts @@ -0,0 +1,28 @@ +import { Vector } from '../types'; +import { vectorToArray } from './vectorToArray'; + +/** + * RowVector makes the row values look like a vector + * @internal + */ +export class RowVector implements Vector { + constructor(private columns: Vector[]) {} + + rowIndex = 0; + + get length(): number { + return this.columns.length; + } + + get(index: number): number { + return this.columns[index].get(this.rowIndex); + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts new file mode 100644 index 0000000..33b97f6 --- /dev/null +++ b/packages/grafana-data/src/vector/SortedVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +/** + * Values are returned in the order defined by the input parameter + */ +export class SortedVector implements Vector { + constructor(private source: Vector, private order: number[]) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.source.get(this.order[index]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/index.ts b/packages/grafana-data/src/vector/index.ts new file mode 100644 index 0000000..5401189 --- /dev/null +++ b/packages/grafana-data/src/vector/index.ts @@ -0,0 +1,11 @@ +export * from './AppendedVectors'; +export * from './ArrayVector'; +export * from './CircularVector'; +export * from './ConstantVector'; +export * from './BinaryOperationVector'; +export * from './SortedVector'; +export * from './FormattedVector'; +export * from './IndexVector'; +export * from './AsNumberVector'; + +export { vectorator } from './FunctionalVector'; diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts new file mode 100644 index 0000000..a0a17d7 --- /dev/null +++ b/packages/grafana-data/src/vector/vectorToArray.ts @@ -0,0 +1,9 @@ +import { Vector } from '../types/vector'; + +export function vectorToArray(v: Vector): T[] { + const arr: T[] = Array(v.length); + for (let i = 0; i < v.length; i++) { + arr[i] = v.get(i); + } + return arr; +} diff --git a/packages/grafana-data/tsconfig.build.json b/packages/grafana-data/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-data/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json new file mode 100644 index 0000000..b2ebe33 --- /dev/null +++ b/packages/grafana-data/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types", "types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": [ + "src/**/*.ts*", + "typings/jest", + "../../public/app/types/jquery/*.ts", + "../../public/app/types/sanitize-url.d.ts" + ] +} diff --git a/packages/grafana-data/typings/jest/index.d.ts b/packages/grafana-data/typings/jest/index.d.ts new file mode 100644 index 0000000..a2ba02f --- /dev/null +++ b/packages/grafana-data/typings/jest/index.d.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; + +type ObservableType = T extends Observable ? V : never; + +declare global { + namespace jest { + interface Matchers { + toEmitValues>(expected: E[]): Promise; + toEmitValuesWith>(expectations: (received: E[]) => void): Promise; + } + } +} diff --git a/packages/grafana-e2e-selectors/CHANGELOG.md b/packages/grafana-e2e-selectors/CHANGELOG.md new file mode 100644 index 0000000..139597f --- /dev/null +++ b/packages/grafana-e2e-selectors/CHANGELOG.md @@ -0,0 +1,2 @@ + + diff --git a/packages/grafana-e2e-selectors/README.md b/packages/grafana-e2e-selectors/README.md new file mode 100644 index 0000000..60c35d7 --- /dev/null +++ b/packages/grafana-e2e-selectors/README.md @@ -0,0 +1,3 @@ +# Grafana End-to-End Test Selectors library + +> **@grafana/e2e-selectors is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! diff --git a/packages/grafana-e2e-selectors/api-extractor.json b/packages/grafana-e2e-selectors/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-e2e-selectors/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-e2e-selectors/index.js b/packages/grafana-e2e-selectors/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-e2e-selectors/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json new file mode 100644 index 0000000..1313eff --- /dev/null +++ b/packages/grafana-e2e-selectors/package.json @@ -0,0 +1,49 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/e2e-selectors", + "version": "7.5.5", + "description": "Grafana End-to-End Test Selectors Library", + "keywords": [ + "cli", + "grafana", + "e2e", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-e2e-selectors" + }, + "main": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=e2e-selectors", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/node": "13.7.7", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/systemjs": "^0.20.6", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "ts-loader": "6.2.1", + "ts-node": "9.0.0" + }, + "types": "src/index.ts", + "dependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "commander": "5.0.0", + "execa": "4.0.0", + "typescript": "4.1.2", + "yaml": "^1.8.3" + } +} diff --git a/packages/grafana-e2e-selectors/rollup.config.ts b/packages/grafana-e2e-selectors/rollup.config.ts new file mode 100644 index 0000000..41c1a50 --- /dev/null +++ b/packages/grafana-e2e-selectors/rollup.config.ts @@ -0,0 +1,25 @@ +import resolve from '@rollup/plugin-node-resolve'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + plugins: [resolve(), sourceMaps(), env === 'production' && terser()], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-e2e-selectors/src/index.ts b/packages/grafana-e2e-selectors/src/index.ts new file mode 100644 index 0000000..8c17fa0 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/index.ts @@ -0,0 +1,7 @@ +/** + * A library containing the different design components of the Grafana ecosystem. + * + * @packageDocumentation + */ +export * from './selectors'; +export * from './types'; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts new file mode 100644 index 0000000..53da452 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -0,0 +1,187 @@ +export const Components = { + DataSource: { + TestData: { + QueryTab: { + scenarioSelectContainer: 'Test Data Query scenario select container', + scenarioSelect: 'Test Data Query scenario select', + max: 'TestData max', + min: 'TestData min', + noise: 'TestData noise', + seriesCount: 'TestData series count', + spread: 'TestData spread', + startValue: 'TestData start value', + }, + }, + }, + Panels: { + Panel: { + title: (title: string) => `Panel header title item ${title}`, + headerItems: (item: string) => `Panel header item ${item}`, + containerByTitle: (title: string) => `Panel container title ${title}`, + headerCornerInfo: (mode: string) => `Panel header ${mode}`, + }, + Visualization: { + Graph: { + VisualizationTab: { + legendSection: 'Legend section', + }, + Legend: { + legendItemAlias: (name: string) => `gpl alias ${name}`, + showLegendSwitch: 'gpl show legend', + }, + xAxis: { + labels: () => 'div.flot-x-axis > div.flot-tick-label', + }, + }, + BarGauge: { + value: 'Bar gauge value', + }, + Text: { + container: () => '.markdown-html', + }, + }, + }, + Drawer: { + General: { + title: (title: string) => `Drawer title ${title}`, + expand: 'Drawer expand', + contract: 'Drawer contract', + close: 'Drawer close', + rcContentWrapper: () => '.drawer-content-wrapper', + }, + }, + PanelEditor: { + General: { + content: 'Panel editor content', + }, + OptionsPane: { + content: 'Panel editor option pane content', + close: 'Page toolbar button Close options pane', + open: 'Page toolbar button Open options pane', + select: 'Panel editor option pane select', + tab: (title: string) => `Panel editor option pane tab ${title}`, + }, + // not sure about the naming *DataPane* + DataPane: { + content: 'Panel editor data pane content', + }, + FieldOptions: { + propertyEditor: (type: string) => `${type} field property editor`, + }, + }, + PanelInspector: { + Data: { + content: 'Panel inspector Data content', + }, + Stats: { + content: 'Panel inspector Stats content', + }, + Json: { + content: 'Panel inspector Json content', + }, + Query: { + content: 'Panel inspector Query content', + refreshButton: 'Panel inspector Query refresh button', + jsonObjectKeys: () => '.json-formatter-key', + }, + }, + Tab: { + title: (title: string) => `Tab ${title}`, + active: () => '[class*="-activeTabStyle"]', + }, + RefreshPicker: { + runButton: 'RefreshPicker run button', + }, + QueryTab: { + content: 'Query editor tab content', + queryInspectorButton: 'Query inspector button', + addQuery: 'Query editor add query button', + }, + QueryEditorRows: { + rows: 'Query editor row', + }, + QueryEditorRow: { + actionButton: (title: string) => `${title} query operation action`, + title: (refId: string) => `Query editor row title ${refId}`, + }, + AlertTab: { + content: 'Alert editor tab content', + }, + Alert: { + alert: (severity: string) => `Alert ${severity}`, + }, + TransformTab: { + content: 'Transform editor tab content', + newTransform: (name: string) => `New transform ${name}`, + transformationEditor: (name: string) => `Transformation editor ${name}`, + transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`, + }, + Transforms: { + card: (name: string) => `New transform ${name}`, + Reduce: { + modeLabel: 'Transform mode label', + calculationsLabel: 'Transform calculations label', + }, + searchInput: 'search transformations', + }, + PageToolbar: { + container: () => '.page-toolbar', + item: (tooltip: string) => `Page toolbar button ${tooltip}`, + }, + QueryEditorToolbarItem: { + button: (title: string) => `QueryEditor toolbar item button ${title}`, + }, + BackButton: { + backArrow: 'Go Back button', + }, + OptionsGroup: { + toggle: (title?: string) => (title ? `Options group ${title}` : 'Options group'), + }, + PluginVisualization: { + item: (title: string) => `Plugin visualization item ${title}`, + current: () => '[class*="-currentVisualizationItem"]', + }, + Select: { + option: 'Select option', + input: () => 'input[id*="react-select-"]', + singleValue: () => 'div[class*="-singleValue"]', + }, + FieldConfigEditor: { + content: 'Field config editor content', + }, + OverridesConfigEditor: { + content: 'Field overrides editor content', + }, + FolderPicker: { + container: 'Folder picker select container', + }, + DataSourcePicker: { + container: 'Data source picker select container', + }, + TimeZonePicker: { + container: 'Time zone picker select container', + }, + TraceViewer: { + spanBar: () => '[data-test-id="SpanBar--wrapper"]', + }, + QueryField: { container: 'Query field' }, + ValuePicker: { + button: 'Value picker add button', + select: (name: string) => `Value picker select ${name}`, + }, + Search: { + section: 'Search section', + items: 'Search items', + }, + DashboardLinks: { + container: 'Dashboard link container', + dropDown: 'Dashboard link dropdown', + link: 'Dashboard link', + }, + CallToActionCard: { + button: (name: string) => `Call to action button ${name}`, + }, + DataLinksContextMenu: { + singleLink: 'Data link', + }, +}; diff --git a/packages/grafana-e2e-selectors/src/selectors/index.ts b/packages/grafana-e2e-selectors/src/selectors/index.ts new file mode 100644 index 0000000..6404693 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/index.ts @@ -0,0 +1,8 @@ +import { Pages } from './pages'; +import { Components } from './components'; +import { E2ESelectors } from '../types'; + +export const selectors: { pages: E2ESelectors; components: E2ESelectors } = { + pages: Pages, + components: Components, +}; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts new file mode 100644 index 0000000..ee74bd4 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -0,0 +1,148 @@ +import { Components } from './components'; + +export const Pages = { + Login: { + url: '/login', + username: 'Username input field', + password: 'Password input field', + submit: 'Login button', + skip: 'Skip change password button', + }, + Home: { + url: '/', + }, + DataSource: { + name: 'Data source settings page name input field', + delete: 'Data source settings page Delete button', + saveAndTest: 'Data source settings page Save and Test button', + alert: 'Data source settings page Alert', + }, + DataSources: { + url: '/datasources', + dataSources: (dataSourceName: string) => `Data source list item ${dataSourceName}`, + }, + AddDataSource: { + url: '/datasources/new', + dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`, + }, + ConfirmModal: { + delete: 'Confirm Modal Danger Button', + }, + AddDashboard: { + url: '/dashboard/new', + addNewPanel: 'Add new panel', + }, + Dashboard: { + url: (uid: string) => `/d/${uid}`, + SubMenu: { + submenuItem: 'Dashboard template variables submenu item', + submenuItemLabels: (item: string) => `Dashboard template variables submenu Label ${item}`, + submenuItemValueDropDownValueLinkTexts: (item: string) => + `Dashboard template variables Variable Value DropDown value link text ${item}`, + submenuItemValueDropDownDropDown: 'Dashboard template variables Variable Value DropDown DropDown', + submenuItemValueDropDownOptionTexts: (item: string) => + `Dashboard template variables Variable Value DropDown option text ${item}`, + }, + Settings: { + General: { + deleteDashBoard: 'Dashboard settings page delete dashboard button', + sectionItems: (item: string) => `Dashboard settings section item ${item}`, + saveDashBoard: 'Dashboard settings aside actions Save button', + saveAsDashBoard: 'Dashboard settings aside actions Save As button', + timezone: 'Time zone picker select container', + title: 'Dashboard settings page title', + }, + Annotations: { + List: { + addAnnotationCTA: Components.CallToActionCard.button('Add Annotation Query'), + }, + }, + Variables: { + List: { + addVariableCTA: Components.CallToActionCard.button('Add variable'), + newButton: 'Variable editor New variable button', + table: 'Variable editor Table', + tableRowNameFields: (variableName: string) => `Variable editor Table Name field ${variableName}`, + tableRowDefinitionFields: (variableName: string) => `Variable editor Table Definition field ${variableName}`, + tableRowArrowUpButtons: (variableName: string) => `Variable editor Table ArrowUp button ${variableName}`, + tableRowArrowDownButtons: (variableName: string) => `Variable editor Table ArrowDown button ${variableName}`, + tableRowDuplicateButtons: (variableName: string) => `Variable editor Table Duplicate button ${variableName}`, + tableRowRemoveButtons: (variableName: string) => `Variable editor Table Remove button ${variableName}`, + }, + Edit: { + General: { + headerLink: 'Variable editor Header link', + modeLabelNew: 'Variable editor Header mode New', + modeLabelEdit: 'Variable editor Header mode Edit', + generalNameInput: 'Variable editor Form Name field', + generalTypeSelect: 'Variable editor Form Type select', + generalLabelInput: 'Variable editor Form Label field', + generalHideSelect: 'Variable editor Form Hide select', + selectionOptionsMultiSwitch: 'Variable editor Form Multi switch', + selectionOptionsIncludeAllSwitch: 'Variable editor Form IncludeAll switch', + selectionOptionsCustomAllInput: 'Variable editor Form IncludeAll field', + previewOfValuesOption: 'Variable editor Preview of Values option', + submitButton: 'Variable editor Submit button', + }, + QueryVariable: { + queryOptionsDataSourceSelect: Components.DataSourcePicker.container, + queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select', + queryOptionsRegExInput: 'Variable editor Form Query RegEx field', + queryOptionsSortSelect: 'Variable editor Form Query Sort select', + queryOptionsQueryInput: 'Variable editor Form Default Variable Query Editor textarea', + valueGroupsTagsEnabledSwitch: 'Variable editor Form Query UseTags switch', + valueGroupsTagsTagsQueryInput: 'Variable editor Form Query TagsQuery field', + valueGroupsTagsTagsValuesQueryInput: 'Variable editor Form Query TagsValuesQuery field', + }, + ConstantVariable: { + constantOptionsQueryInput: 'Variable editor Form Constant Query field', + }, + TextBoxVariable: { + textBoxOptionsQueryInput: 'Variable editor Form TextBox Query field', + }, + }, + }, + }, + }, + Dashboards: { + url: '/dashboards', + dashboards: (title: string) => `Dashboard search item ${title}`, + }, + SaveDashboardAsModal: { + newName: 'Save dashboard title field', + save: 'Save dashboard button', + }, + SaveDashboardModal: { + save: 'Dashboard settings Save Dashboard Modal Save button', + saveVariables: 'Dashboard settings Save Dashboard Modal Save variables checkbox', + saveTimerange: 'Dashboard settings Save Dashboard Modal Save timerange checkbox', + }, + SharePanelModal: { + linkToRenderedImage: 'Link to rendered image', + }, + Explore: { + url: '/explore', + General: { + container: 'Explore', + graph: 'Explore Graph', + table: 'Explore Table', + scrollBar: () => '.scrollbar-view', + }, + Toolbar: { + navBar: () => '.explore-toolbar', + }, + }, + SoloPanel: { + url: (page: string) => `/d-solo/${page}`, + }, + PluginsList: { + page: 'Plugins list page', + list: 'Plugins list', + listItem: 'Plugins list item', + signatureErrorNotice: 'Unsigned plugins notice', + }, + PluginPage: { + page: 'Plugin page', + signatureInfo: 'Plugin signature info', + }, +}; diff --git a/packages/grafana-e2e-selectors/src/types/index.ts b/packages/grafana-e2e-selectors/src/types/index.ts new file mode 100644 index 0000000..8c9698f --- /dev/null +++ b/packages/grafana-e2e-selectors/src/types/index.ts @@ -0,0 +1 @@ +export * from './selectors'; diff --git a/packages/grafana-e2e-selectors/src/types/selectors.ts b/packages/grafana-e2e-selectors/src/types/selectors.ts new file mode 100644 index 0000000..e58a925 --- /dev/null +++ b/packages/grafana-e2e-selectors/src/types/selectors.ts @@ -0,0 +1,15 @@ +export type StringSelector = string; +export type FunctionSelector = (id: string) => string; +export type CssSelector = () => string; + +export interface Selectors { + [key: string]: StringSelector | FunctionSelector | CssSelector | UrlSelector | Selectors; +} + +export type E2ESelectors = { + [P in keyof S]: S[P]; +}; + +export interface UrlSelector extends Selectors { + url: string | FunctionSelector; +} diff --git a/packages/grafana-e2e-selectors/tsconfig.build.json b/packages/grafana-e2e-selectors/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-e2e-selectors/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-e2e-selectors/tsconfig.json b/packages/grafana-e2e-selectors/tsconfig.json new file mode 100644 index 0000000..a0cb12d --- /dev/null +++ b/packages/grafana-e2e-selectors/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts"] +} diff --git a/packages/grafana-e2e/.gitignore b/packages/grafana-e2e/.gitignore new file mode 100644 index 0000000..9770e5a --- /dev/null +++ b/packages/grafana-e2e/.gitignore @@ -0,0 +1,3 @@ +test/cypress/report.json +test/cypress/screenshots/actual +test/cypress/videos/ diff --git a/packages/grafana-e2e/CHANGELOG.md b/packages/grafana-e2e/CHANGELOG.md new file mode 100644 index 0000000..139597f --- /dev/null +++ b/packages/grafana-e2e/CHANGELOG.md @@ -0,0 +1,2 @@ + + diff --git a/packages/grafana-e2e/README.md b/packages/grafana-e2e/README.md new file mode 100644 index 0000000..73c105e --- /dev/null +++ b/packages/grafana-e2e/README.md @@ -0,0 +1,5 @@ +# Grafana End-to-End Test library + +> **@grafana/e2e is currently in BETA**. + +This package contains an API wrapper built on top of [Cypress](https://www.cypress.io) that simplifies creating end-to-end tests for Grafana. More information can be found [here](https://github.com/grafana/grafana/blob/master/contribute/style-guides/e2e.md). diff --git a/packages/grafana-e2e/api-extractor.json b/packages/grafana-e2e/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-e2e/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-e2e/bin/grafana-e2e.js b/packages/grafana-e2e/bin/grafana-e2e.js new file mode 100755 index 0000000..e2aefb1 --- /dev/null +++ b/packages/grafana-e2e/bin/grafana-e2e.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../cli')(); diff --git a/packages/grafana-e2e/cli.js b/packages/grafana-e2e/cli.js new file mode 100644 index 0000000..32f3a69 --- /dev/null +++ b/packages/grafana-e2e/cli.js @@ -0,0 +1,49 @@ +const execa = require('execa'); +const program = require('commander'); +const resolveBin = require('resolve-as-bin'); +const { resolve, sep } = require('path'); + +const cypress = (commandName, { updateScreenshots }) => { + // Support running an unpublished dev build + const dirname = __dirname.split(sep).pop(); + const projectPath = resolve(`${__dirname}${dirname === 'dist' ? '/..' : ''}`); + + // For plugins/extendConfig + const CWD = `CWD=${process.cwd()}`; + + // For plugins/compareSnapshots + const UPDATE_SCREENSHOTS = `UPDATE_SCREENSHOTS=${updateScreenshots ? 1 : 0}`; + + const cypressOptions = [commandName, '--env', `${CWD},${UPDATE_SCREENSHOTS}`, `--project=${projectPath}`]; + + const execaOptions = { + cwd: __dirname, + stdio: 'inherit', + }; + + return execa(resolveBin('cypress'), cypressOptions, execaOptions) + .then(() => {}) // no return value + .catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +}; + +module.exports = () => { + const updateOption = '-u, --update-screenshots'; + const updateDescription = 'update expected screenshots'; + + program + .command('open') + .description('runs tests within the interactive GUI') + .option(updateOption, updateDescription) + .action((options) => cypress('open', options)); + + program + .command('run') + .description('runs tests from the CLI without the GUI') + .option(updateOption, updateDescription) + .action((options) => cypress('run', options)); + + program.parse(process.argv); +}; diff --git a/packages/grafana-e2e/cypress.json b/packages/grafana-e2e/cypress.json new file mode 100644 index 0000000..3183556 --- /dev/null +++ b/packages/grafana-e2e/cypress.json @@ -0,0 +1,4 @@ +{ + "projectId": "zb7k1c", + "supportFile": "cypress/support/index.ts" +} diff --git a/packages/grafana-e2e/cypress/fixtures/example.json b/packages/grafana-e2e/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/packages/grafana-e2e/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/packages/grafana-e2e/cypress/fixtures/long-trace-response.json b/packages/grafana-e2e/cypress/fixtures/long-trace-response.json new file mode 100644 index 0000000..0d04aa1 --- /dev/null +++ b/packages/grafana-e2e/cypress/fixtures/long-trace-response.json @@ -0,0 +1,7592 @@ +{ + "data": [ + { + "traceID": "3fa414edcef6ad90", + "spans": [ + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b26effbab24e95a", + "operationName": "FindTraceByID", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3e2c6d65ea266fe3" }], + "startTime": 1605873894680581, + "duration": 1820, + "tags": [ + { "key": "component", "type": "string", "value": "gRPC" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0f5c1808567e4403", + "operationName": "FindTraceByID", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3e2c6d65ea266fe3" }], + "startTime": 1605873894680587, + "duration": 1847, + "tags": [ + { "key": "component", "type": "string", "value": "gRPC" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "59f093577238d61e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1cc731490b1da4c5" }], + "startTime": 1605873894683862, + "duration": 10204, + "tags": [], + "logs": [ + { "timestamp": 1605873894683872, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894694063, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1cc731490b1da4c5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "779ac3811ce65e40" }], + "startTime": 1605873894683858, + "duration": 10257, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "602204dc8b8fbc6d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "586e5e4c0400de11" }], + "startTime": 1605873894683201, + "duration": 11185, + "tags": [], + "logs": [ + { "timestamp": 1605873894683207, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894694385, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "586e5e4c0400de11", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "25223420e121413a" }], + "startTime": 1605873894683196, + "duration": 11200, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "779ac3811ce65e40", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683844, + "duration": 10983, + "tags": [ + { "key": "blockID", "type": "string", "value": "20a16df1-a312-4b1a-a2e2-33b55e9f3c8b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894694822, + "fields": [ + { "key": "bytes", "type": "int64", "value": 315664 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "24203526fe09b1e2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0afe9ad5f5b01be7" }], + "startTime": 1605873894682997, + "duration": 12453, + "tags": [], + "logs": [ + { "timestamp": 1605873894683002, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894695448, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0afe9ad5f5b01be7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "51413d67348a4624" }], + "startTime": 1605873894682993, + "duration": 12466, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "51413d67348a4624", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682986, + "duration": 13059, + "tags": [ + { "key": "blockID", "type": "string", "value": "08b90b09-c56e-4b4a-b95f-3f0409dc9ce9" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894695963, + "fields": [ + { "key": "bytes", "type": "int64", "value": 239824 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "60007a76ffde4644", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "09d7a8c1faef5a84" }], + "startTime": 1605873894682866, + "duration": 13279, + "tags": [], + "logs": [ + { "timestamp": 1605873894682872, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894696144, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "09d7a8c1faef5a84", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2755efbbfb1b537b" }], + "startTime": 1605873894682861, + "duration": 13291, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2755efbbfb1b537b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682846, + "duration": 14054, + "tags": [ + { "key": "blockID", "type": "string", "value": "f78b0397-d3ad-4514-9bf4-87b6ea7e920e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894696898, + "fields": [ + { "key": "bytes", "type": "int64", "value": 218440 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25223420e121413a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683188, + "duration": 14278, + "tags": [ + { "key": "blockID", "type": "string", "value": "3ae22086-9266-481a-9725-c921471e4a94" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894697462, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397880 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "17a3baf85848a727", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "46ebfa6c443776c4" }], + "startTime": 1605873894683030, + "duration": 14724, + "tags": [], + "logs": [ + { "timestamp": 1605873894683033, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894697752, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46ebfa6c443776c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1e283fe0dd8cc773" }], + "startTime": 1605873894683027, + "duration": 14734, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19b1afe02cf639cf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6e5a7dd55283f907" }], + "startTime": 1605873894683883, + "duration": 14279, + "tags": [], + "logs": [ + { "timestamp": 1605873894683889, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698160, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e5a7dd55283f907", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "650c7f5ec8cc53a5" }], + "startTime": 1605873894683879, + "duration": 14289, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5085badf0c1dc842", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "71a0e94722b662ed" }], + "startTime": 1605873894683657, + "duration": 14886, + "tags": [], + "logs": [ + { "timestamp": 1605873894683663, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698542, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71a0e94722b662ed", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1bae5c35dd7187ba" }], + "startTime": 1605873894683653, + "duration": 14897, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57e69d8f17b39563", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6fe636103f47e1fc" }], + "startTime": 1605873894683388, + "duration": 15548, + "tags": [], + "logs": [ + { "timestamp": 1605873894683394, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698936, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6fe636103f47e1fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "04652166eaec115c" }], + "startTime": 1605873894683384, + "duration": 15558, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "52146a5c1b2c0030", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "160fb4c8329a2ea0" }], + "startTime": 1605873894683284, + "duration": 15701, + "tags": [], + "logs": [ + { "timestamp": 1605873894683290, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894698984, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "160fb4c8329a2ea0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "690fcd8c8dc87ae8" }], + "startTime": 1605873894683280, + "duration": 15712, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e283fe0dd8cc773", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683024, + "duration": 16029, + "tags": [ + { "key": "blockID", "type": "string", "value": "9e102b4e-115a-4bda-abd6-aa6221f9e4b7" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699050, + "fields": [ + { "key": "bytes", "type": "int64", "value": 395808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bae5c35dd7187ba", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683644, + "duration": 15612, + "tags": [ + { "key": "blockID", "type": "string", "value": "b2f5a951-19a0-473d-8830-e1120ab7bf25" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699255, + "fields": [ + { "key": "bytes", "type": "int64", "value": 345992 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5af2c497b60703d9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6a64d382dd0239a7" }], + "startTime": 1605873894683842, + "duration": 15628, + "tags": [], + "logs": [ + { "timestamp": 1605873894683848, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894699469, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a64d382dd0239a7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "195ed27075e44238" }], + "startTime": 1605873894683837, + "duration": 15639, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04652166eaec115c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683378, + "duration": 16179, + "tags": [ + { "key": "blockID", "type": "string", "value": "30903640-5e8c-4cf6-9dc8-f84e0e2541c8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699555, + "fields": [ + { "key": "bytes", "type": "int64", "value": 291056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "650c7f5ec8cc53a5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683871, + "duration": 15807, + "tags": [ + { "key": "blockID", "type": "string", "value": "19b49abb-e17a-4632-a4b9-3ce95208e3cf" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894699675, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424248 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b30323ce39314b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "288816ad36c9020c" }], + "startTime": 1605873894684553, + "duration": 15144, + "tags": [], + "logs": [ + { "timestamp": 1605873894684559, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894699696, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "288816ad36c9020c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2e905b9eda5ef611" }], + "startTime": 1605873894684549, + "duration": 15154, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26e83a54365218ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5e6a2e62081720fd" }], + "startTime": 1605873894683881, + "duration": 16602, + "tags": [], + "logs": [ + { "timestamp": 1605873894683888, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700482, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e6a2e62081720fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "277870fa55872b13" }], + "startTime": 1605873894683878, + "duration": 16613, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "63332243ceed106c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7dbbbda52a6d32ce" }], + "startTime": 1605873894683893, + "duration": 16666, + "tags": [], + "logs": [ + { "timestamp": 1605873894683900, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700557, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7dbbbda52a6d32ce", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "022b6c95374f166d" }], + "startTime": 1605873894683888, + "duration": 16678, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "195ed27075e44238", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683828, + "duration": 16766, + "tags": [ + { "key": "blockID", "type": "string", "value": "6c5d1290-2b4b-4f33-9798-63b6654e16b4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894700591, + "fields": [ + { "key": "bytes", "type": "int64", "value": 367848 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "35e5a12a53c6088a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "113befce4abfecb2" }], + "startTime": 1605873894682748, + "duration": 17901, + "tags": [], + "logs": [ + { "timestamp": 1605873894682751, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894700647, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "690fcd8c8dc87ae8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683273, + "duration": 17376, + "tags": [ + { "key": "blockID", "type": "string", "value": "f1db0c64-befe-4790-af19-7b48e57a9558" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894700646, + "fields": [ + { "key": "bytes", "type": "int64", "value": 386448 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "113befce4abfecb2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7d9927e5c258d511" }], + "startTime": 1605873894682745, + "duration": 17911, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "277870fa55872b13", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683865, + "duration": 17440, + "tags": [ + { "key": "blockID", "type": "string", "value": "f05f1d13-0250-492a-abc8-bca24ccf3a15" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701291, + "fields": [ + { "key": "bytes", "type": "int64", "value": 211672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "022b6c95374f166d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683879, + "duration": 17471, + "tags": [ + { "key": "blockID", "type": "string", "value": "0cba7eaf-2546-41ac-99d7-673ef23d6e98" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701347, + "fields": [ + { "key": "bytes", "type": "int64", "value": 406456 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6cee3530fc730d34", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "674b435291a256c4" }], + "startTime": 1605873894684631, + "duration": 16733, + "tags": [], + "logs": [ + { "timestamp": 1605873894684639, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701363, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "674b435291a256c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "021f72c9979124b5" }], + "startTime": 1605873894684627, + "duration": 16745, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1de85b574e5d906c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4c5ac8757f9888b7" }], + "startTime": 1605873894683373, + "duration": 18328, + "tags": [], + "logs": [ + { "timestamp": 1605873894683380, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701701, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4c5ac8757f9888b7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "19021bbbe6310785" }], + "startTime": 1605873894683369, + "duration": 18338, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3e5ab83b57207c74", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6c9178ed1e68f858" }], + "startTime": 1605873894683042, + "duration": 18823, + "tags": [], + "logs": [ + { "timestamp": 1605873894683045, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894701863, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d9927e5c258d511", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682730, + "duration": 19136, + "tags": [ + { "key": "blockID", "type": "string", "value": "794e2adc-701e-4c2d-907a-66221b4455d3" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894701864, + "fields": [ + { "key": "bytes", "type": "int64", "value": 289928 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6c9178ed1e68f858", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "41f1eb48b61ef185" }], + "startTime": 1605873894683039, + "duration": 18834, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "445d4f3f2dc4d0ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "30dd998b2082f2b9" }], + "startTime": 1605873894684214, + "duration": 17919, + "tags": [], + "logs": [ + { "timestamp": 1605873894684221, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702132, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "30dd998b2082f2b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "660886869edd36cf" }], + "startTime": 1605873894684210, + "duration": 17958, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2ff9bbb6c991a0ea", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "310a2399bb07e8bd" }], + "startTime": 1605873894683877, + "duration": 18301, + "tags": [], + "logs": [ + { "timestamp": 1605873894683883, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702177, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "310a2399bb07e8bd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "58435ec74d79cc93" }], + "startTime": 1605873894683873, + "duration": 18311, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19021bbbe6310785", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683360, + "duration": 18978, + "tags": [ + { "key": "blockID", "type": "string", "value": "d2212e62-5b1a-41e2-ae43-c0a596125f1b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702335, + "fields": [ + { "key": "bytes", "type": "int64", "value": 199208 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "021f72c9979124b5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684621, + "duration": 17816, + "tags": [ + { "key": "blockID", "type": "string", "value": "06ebaf3b-4501-4cda-91fb-c48a9d33a99c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702434, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384696 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "68a1e78424019eb9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "244e73561d0c691d" }], + "startTime": 1605873894683657, + "duration": 18900, + "tags": [], + "logs": [ + { "timestamp": 1605873894683663, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702556, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "244e73561d0c691d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "71ee8c7b83046da0" }], + "startTime": 1605873894683653, + "duration": 18910, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5c1d1b2d38dddcfb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "364583eecf36b543" }], + "startTime": 1605873894683518, + "duration": 19222, + "tags": [], + "logs": [ + { "timestamp": 1605873894683526, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702739, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "364583eecf36b543", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6a7ba0261825c53c" }], + "startTime": 1605873894683513, + "duration": 19232, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22e42286de359dc4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7b936283fac4d0ac" }], + "startTime": 1605873894683843, + "duration": 18969, + "tags": [], + "logs": [ + { "timestamp": 1605873894683849, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702812, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7b936283fac4d0ac", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "46c6de90778460b1" }], + "startTime": 1605873894683838, + "duration": 18981, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "660886869edd36cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684202, + "duration": 18627, + "tags": [ + { "key": "blockID", "type": "string", "value": "f07137b8-7a0b-4199-b1a7-6b7d5b230723" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894702824, + "fields": [ + { "key": "bytes", "type": "int64", "value": 293936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57ed8902af3a60b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "64cadcdb4f18b2f7" }], + "startTime": 1605873894683549, + "duration": 19426, + "tags": [], + "logs": [ + { "timestamp": 1605873894683554, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894702972, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64cadcdb4f18b2f7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2422bf6c2ed108c2" }], + "startTime": 1605873894683544, + "duration": 19437, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25f434fb5960aaef", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "62afac560d435620" }], + "startTime": 1605873894683933, + "duration": 19303, + "tags": [], + "logs": [ + { "timestamp": 1605873894683939, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703235, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62afac560d435620", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6aa5ddd42d96f825" }], + "startTime": 1605873894683929, + "duration": 19314, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58435ec74d79cc93", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683865, + "duration": 19469, + "tags": [ + { "key": "blockID", "type": "string", "value": "f2a53e6e-e261-4ec2-92bd-97c5a4c4b760" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703331, + "fields": [ + { "key": "bytes", "type": "int64", "value": 390648 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "578849d0d44400b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1f5faebfb90378ad" }], + "startTime": 1605873894684004, + "duration": 19335, + "tags": [], + "logs": [ + { "timestamp": 1605873894684012, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703337, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1f5faebfb90378ad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1ef8e63340342174" }], + "startTime": 1605873894683999, + "duration": 19346, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "41f1eb48b61ef185", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683035, + "duration": 20463, + "tags": [ + { "key": "blockID", "type": "string", "value": "941a63d4-2739-4ba2-9a15-08256b5c9eae" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703490, + "fields": [ + { "key": "bytes", "type": "int64", "value": 438128 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71ee8c7b83046da0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683645, + "duration": 19895, + "tags": [ + { "key": "blockID", "type": "string", "value": "151c489c-a86a-49b7-9fa9-31d1714d59ee" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894703538, + "fields": [ + { "key": "bytes", "type": "int64", "value": 325344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bf030a07aaceb80", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "54b34afd73af12d1" }], + "startTime": 1605873894683218, + "duration": 20692, + "tags": [], + "logs": [ + { "timestamp": 1605873894683225, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894703909, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "54b34afd73af12d1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "58cee6c544e69e4f" }], + "startTime": 1605873894683215, + "duration": 21011, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a7ba0261825c53c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683505, + "duration": 20615, + "tags": [ + { "key": "blockID", "type": "string", "value": "db0fa030-4607-40e5-998b-47029aa3430e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704118, + "fields": [ + { "key": "bytes", "type": "int64", "value": 411272 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a597269b23b1bcb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "66d886579510b6fd" }], + "startTime": 1605873894683426, + "duration": 20720, + "tags": [], + "logs": [ + { "timestamp": 1605873894683431, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704146, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66d886579510b6fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1bd34d50efadb568" }], + "startTime": 1605873894683422, + "duration": 20841, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1ef8e63340342174", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683990, + "duration": 20334, + "tags": [ + { "key": "blockID", "type": "string", "value": "a10ec85d-9fd2-403e-abcd-6f4ec49b0396" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704322, + "fields": [ + { "key": "bytes", "type": "int64", "value": 442360 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46c6de90778460b1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683830, + "duration": 20675, + "tags": [ + { "key": "blockID", "type": "string", "value": "7e9e0142-15ff-461e-8e05-6c62d920603a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704502, + "fields": [ + { "key": "bytes", "type": "int64", "value": 465744 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "686f3e58fe28940f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5960c1f5750b1cde" }], + "startTime": 1605873894696113, + "duration": 8480, + "tags": [], + "logs": [ + { "timestamp": 1605873894696126, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704591, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5960c1f5750b1cde", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4e48e93f70e06522" }], + "startTime": 1605873894696104, + "duration": 8495, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6aa5ddd42d96f825", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683921, + "duration": 20886, + "tags": [ + { "key": "blockID", "type": "string", "value": "43e5ad4f-11d6-4f25-9925-652cb801fd58" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704804, + "fields": [ + { "key": "bytes", "type": "int64", "value": 405344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "166377800e8e82a7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5e84f8676ef1efad" }], + "startTime": 1605873894683214, + "duration": 21686, + "tags": [], + "logs": [ + { "timestamp": 1605873894683221, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894704899, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e84f8676ef1efad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "42fac7c66e0ca970" }], + "startTime": 1605873894683209, + "duration": 21696, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "713c834576a0d9b0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "209c0e336c71e932" }], + "startTime": 1605873894683037, + "duration": 22197, + "tags": [], + "logs": [ + { "timestamp": 1605873894683045, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894705234, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "209c0e336c71e932", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1e1aa88072a7cefc" }], + "startTime": 1605873894683033, + "duration": 22209, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd34d50efadb568", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683414, + "duration": 21894, + "tags": [ + { "key": "blockID", "type": "string", "value": "b432160f-347c-41ad-882e-f1786e4b42b1" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705305, + "fields": [ + { "key": "bytes", "type": "int64", "value": 409104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58cee6c544e69e4f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683206, + "duration": 22173, + "tags": [ + { "key": "blockID", "type": "string", "value": "b12afd19-298a-443f-97ce-b5b2e5bc9d79" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705375, + "fields": [ + { "key": "bytes", "type": "int64", "value": 376872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4e48e93f70e06522", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894696061, + "duration": 9466, + "tags": [ + { "key": "blockID", "type": "string", "value": "45701f45-c93a-4c35-9fed-9cce2c316a19" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705524, + "fields": [ + { "key": "bytes", "type": "int64", "value": 453296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2422bf6c2ed108c2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683536, + "duration": 20571, + "tags": [ + { "key": "blockID", "type": "string", "value": "51945006-c165-40af-baea-769b3199bf46" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894704104, + "fields": [ + { "key": "bytes", "type": "int64", "value": 342200 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42fac7c66e0ca970", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683200, + "duration": 22685, + "tags": [ + { "key": "blockID", "type": "string", "value": "8772aa40-3489-4b12-b685-9f708ae4de75" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894705882, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407152 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a86d93e70a1720c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "72991150a8c3cf08" }], + "startTime": 1605873894684627, + "duration": 21313, + "tags": [], + "logs": [ + { "timestamp": 1605873894684633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894705939, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72991150a8c3cf08", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5ba9d86263fc6da1" }], + "startTime": 1605873894684622, + "duration": 21322, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3ceac51ce73f994e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "704707012227a4f1" }], + "startTime": 1605873894683627, + "duration": 22375, + "tags": [], + "logs": [ + { "timestamp": 1605873894683633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706001, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "704707012227a4f1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7f20dbc684de78c8" }], + "startTime": 1605873894683623, + "duration": 22386, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26cf501f6dcbb968", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7ebb1c9d8a55ac56" }], + "startTime": 1605873894683959, + "duration": 22090, + "tags": [], + "logs": [ + { "timestamp": 1605873894683965, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706048, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ebb1c9d8a55ac56", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1aae38562e2b6a1f" }], + "startTime": 1605873894683952, + "duration": 22104, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd01ea1e13ac6fd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4f94f7e28081e1af" }], + "startTime": 1605873894683733, + "duration": 22571, + "tags": [], + "logs": [ + { "timestamp": 1605873894683739, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706303, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4f94f7e28081e1af", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7ccb153793c6afd9" }], + "startTime": 1605873894683728, + "duration": 22582, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "60fd2b3931676856", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "432bc11447588912" }], + "startTime": 1605873894684310, + "duration": 22119, + "tags": [], + "logs": [ + { "timestamp": 1605873894684317, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706428, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "432bc11447588912", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b27a749f4d1b557" }], + "startTime": 1605873894684305, + "duration": 22131, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e1aa88072a7cefc", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683026, + "duration": 23483, + "tags": [ + { "key": "blockID", "type": "string", "value": "9064347a-7c49-48d8-b348-8d734f7fd542" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894706506, + "fields": [ + { "key": "bytes", "type": "int64", "value": 365672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1fb49823a6f803bf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7db786f0da6d756d" }], + "startTime": 1605873894682930, + "duration": 23695, + "tags": [], + "logs": [ + { "timestamp": 1605873894682935, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706622, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7db786f0da6d756d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1cf9294062a5780b" }], + "startTime": 1605873894682926, + "duration": 23705, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01cb21bacc3933da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "260399c49430577a" }], + "startTime": 1605873894697497, + "duration": 9150, + "tags": [], + "logs": [ + { "timestamp": 1605873894697507, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706646, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "260399c49430577a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5bf10b9afef405a9" }], + "startTime": 1605873894697488, + "duration": 9166, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5ba9d86263fc6da1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684614, + "duration": 22250, + "tags": [ + { "key": "blockID", "type": "string", "value": "ca346cf4-8162-49e5-a0d0-0619d3813794" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894706862, + "fields": [ + { "key": "bytes", "type": "int64", "value": 416472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "77f27a840cd8b75b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4a48a86f95e117f9" }], + "startTime": 1605873894685061, + "duration": 21838, + "tags": [], + "logs": [ + { "timestamp": 1605873894685068, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706897, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a48a86f95e117f9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0a0b86e5738d630b" }], + "startTime": 1605873894685057, + "duration": 21850, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d1f782957acfe32", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77f8165c15176536" }], + "startTime": 1605873894682777, + "duration": 24168, + "tags": [], + "logs": [ + { "timestamp": 1605873894682783, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894706944, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "77f8165c15176536", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1dc5a0697b5d6161" }], + "startTime": 1605873894682773, + "duration": 24206, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7f20dbc684de78c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683615, + "duration": 23703, + "tags": [ + { "key": "blockID", "type": "string", "value": "f518974f-2e1e-41c8-b70c-cd2088f5a081" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707316, + "fields": [ + { "key": "bytes", "type": "int64", "value": 278728 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70a453eeff8ec687", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0c7d975a67c6d7bc" }], + "startTime": 1605873894684828, + "duration": 22510, + "tags": [], + "logs": [ + { "timestamp": 1605873894684835, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894707337, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c7d975a67c6d7bc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6b89efb6b9fb16fc" }], + "startTime": 1605873894684823, + "duration": 22520, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ccb153793c6afd9", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683720, + "duration": 23911, + "tags": [ + { "key": "blockID", "type": "string", "value": "6f72b73b-c5fe-4761-b91f-b92f447441fa" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707629, + "fields": [ + { "key": "bytes", "type": "int64", "value": 451984 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5bf10b9afef405a9", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894697476, + "duration": 10175, + "tags": [ + { "key": "blockID", "type": "string", "value": "61e0a11e-5e88-49c4-ad1d-81636670e642" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707648, + "fields": [ + { "key": "bytes", "type": "int64", "value": 296328 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1aae38562e2b6a1f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683945, + "duration": 23883, + "tags": [ + { "key": "blockID", "type": "string", "value": "7dda9580-666b-42f9-b8a1-1680a0de352f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707823, + "fields": [ + { "key": "bytes", "type": "int64", "value": 402936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1dc5a0697b5d6161", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682764, + "duration": 25091, + "tags": [ + { "key": "blockID", "type": "string", "value": "bf101e70-4a86-4d88-890c-e976330ba857" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894707853, + "fields": [ + { "key": "bytes", "type": "int64", "value": 385288 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3df0c4e2de834172", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "50f5d53109a047da" }], + "startTime": 1605873894684425, + "duration": 23557, + "tags": [], + "logs": [ + { "timestamp": 1605873894684432, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894707980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "50f5d53109a047da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3120fb610c52c9a6" }], + "startTime": 1605873894684421, + "duration": 23568, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64e62db2206bdda3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "40f0742ab8be92ab" }], + "startTime": 1605873894682595, + "duration": 25553, + "tags": [], + "logs": [ + { "timestamp": 1605873894682603, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708147, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "40f0742ab8be92ab", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7757c670662153b5" }], + "startTime": 1605873894682589, + "duration": 25564, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b89efb6b9fb16fc", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684815, + "duration": 23341, + "tags": [ + { "key": "blockID", "type": "string", "value": "84b0a7ea-895d-49f9-892c-11f689f0c13f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708154, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424816 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33c05fda4c7d3921", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "742995638b3636e6" }], + "startTime": 1605873894684414, + "duration": 23815, + "tags": [], + "logs": [ + { "timestamp": 1605873894684420, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708228, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "742995638b3636e6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1bcd55e85df0601a" }], + "startTime": 1605873894684409, + "duration": 23825, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1cf9294062a5780b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682920, + "duration": 25427, + "tags": [ + { "key": "blockID", "type": "string", "value": "e43ee3db-63c9-4d2b-a791-99a5a9203e4e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708341, + "fields": [ + { "key": "bytes", "type": "int64", "value": 396312 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6852631d2c6d1586", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1691ee4e1f907b39" }], + "startTime": 1605873894683843, + "duration": 24695, + "tags": [], + "logs": [ + { "timestamp": 1605873894683850, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894708538, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1691ee4e1f907b39", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "573267e2aab9eb37" }], + "startTime": 1605873894683839, + "duration": 24706, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bcd55e85df0601a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684400, + "duration": 24493, + "tags": [ + { "key": "blockID", "type": "string", "value": "211313f2-7284-43eb-b9dc-134b5b344524" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894708891, + "fields": [ + { "key": "bytes", "type": "int64", "value": 249032 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7757c670662153b5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682578, + "duration": 26605, + "tags": [ + { "key": "blockID", "type": "string", "value": "99a8b127-bef6-4718-997b-18e5cb6bee81" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894709180, + "fields": [ + { "key": "bytes", "type": "int64", "value": 443904 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "033e809d9deb02fb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0701e7633d141024" }], + "startTime": 1605873894683129, + "duration": 26149, + "tags": [], + "logs": [ + { "timestamp": 1605873894683139, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709277, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0701e7633d141024", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2e73b563bfa4df76" }], + "startTime": 1605873894683124, + "duration": 26160, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5a1fcbfa2c2e077e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2639318a16168a94" }], + "startTime": 1605873894682990, + "duration": 26296, + "tags": [], + "logs": [ + { "timestamp": 1605873894682993, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709285, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2639318a16168a94", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7a7bf32e81f4317e" }], + "startTime": 1605873894682987, + "duration": 26304, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "573267e2aab9eb37", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683831, + "duration": 25627, + "tags": [ + { "key": "blockID", "type": "string", "value": "9a5df823-d980-4671-b33f-ef92e485232f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894709455, + "fields": [ + { "key": "bytes", "type": "int64", "value": 357872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3705123c90491605", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "46138581a74be710" }], + "startTime": 1605873894683886, + "duration": 25575, + "tags": [], + "logs": [ + { "timestamp": 1605873894683890, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709460, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46138581a74be710", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1571e420dca57b9f" }], + "startTime": 1605873894683883, + "duration": 25585, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "369cd4694f877602", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "57f0ffddbcc40049" }], + "startTime": 1605873894684325, + "duration": 25173, + "tags": [], + "logs": [ + { "timestamp": 1605873894684385, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709498, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7aab906468c79c5b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0c27a77ad2f6bbb3" }], + "startTime": 1605873894703359, + "duration": 6145, + "tags": [], + "logs": [ + { "timestamp": 1605873894703375, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709503, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57f0ffddbcc40049", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4854f2803a2439d0" }], + "startTime": 1605873894684321, + "duration": 25185, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c27a77ad2f6bbb3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "777ba94dbf7e2679" }], + "startTime": 1605873894703354, + "duration": 6155, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27f360a42e423410", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7e5086a8bb3eb3b3" }], + "startTime": 1605873894696933, + "duration": 12666, + "tags": [], + "logs": [ + { "timestamp": 1605873894696942, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709598, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e5086a8bb3eb3b3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "417798c3fbab4244" }], + "startTime": 1605873894696926, + "duration": 12678, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42f4a2e45bc6b552", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "693c3e7a4e085ce6" }], + "startTime": 1605873894683602, + "duration": 26169, + "tags": [], + "logs": [ + { "timestamp": 1605873894683608, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "693c3e7a4e085ce6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2e958ff5d95860cf" }], + "startTime": 1605873894683598, + "duration": 26180, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7645427b1d8ca012", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2e6e130f1e7bf5ca" }], + "startTime": 1605873894694874, + "duration": 15023, + "tags": [], + "logs": [ + { "timestamp": 1605873894694896, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894709897, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e6e130f1e7bf5ca", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6b3d3f0643735e5f" }], + "startTime": 1605873894694866, + "duration": 15038, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2155087a44565c8a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "66ed873b2793ee77" }], + "startTime": 1605873894700685, + "duration": 9446, + "tags": [], + "logs": [ + { "timestamp": 1605873894700698, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894710131, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66ed873b2793ee77", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "605c87dc2e8efd47" }], + "startTime": 1605873894700678, + "duration": 9459, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69654d80ac69ec92", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3a0447242878ba00" }], + "startTime": 1605873894702020, + "duration": 8202, + "tags": [], + "logs": [ + { "timestamp": 1605873894702031, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894710221, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3a0447242878ba00", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3b9a85f6cd6075b8" }], + "startTime": 1605873894701338, + "duration": 8890, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e73b563bfa4df76", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683106, + "duration": 27358, + "tags": [ + { "key": "blockID", "type": "string", "value": "b89a056f-d8cd-41e9-84ad-445e68d0a0d5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710462, + "fields": [ + { "key": "bytes", "type": "int64", "value": 337664 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e958ff5d95860cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683591, + "duration": 27357, + "tags": [ + { "key": "blockID", "type": "string", "value": "4767ecb2-01d3-450b-b005-6b9219fdfd71" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710945, + "fields": [ + { "key": "bytes", "type": "int64", "value": 421576 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4854f2803a2439d0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684313, + "duration": 26669, + "tags": [ + { "key": "blockID", "type": "string", "value": "ec9c982f-485f-47b2-be74-a7f203368ede" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894710972, + "fields": [ + { "key": "bytes", "type": "int64", "value": 409016 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62aa1124fbaafe29", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "67ee705c301e7e2a" }], + "startTime": 1605873894684107, + "duration": 26934, + "tags": [], + "logs": [ + { "timestamp": 1605873894684113, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711040, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "67ee705c301e7e2a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1bdb019f3239f430" }], + "startTime": 1605873894684102, + "duration": 26945, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "417798c3fbab4244", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894696911, + "duration": 14252, + "tags": [ + { "key": "blockID", "type": "string", "value": "6a346739-04b1-4e86-8f87-e182b01cf5cd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894711160, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407144 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "17faaf92fbea2ed9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "29d4e2aa59eae59e" }], + "startTime": 1605873894683730, + "duration": 27707, + "tags": [], + "logs": [ + { "timestamp": 1605873894683736, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711435, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "29d4e2aa59eae59e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3f654e75b41629f5" }], + "startTime": 1605873894683726, + "duration": 27719, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3b9a85f6cd6075b8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894701327, + "duration": 10230, + "tags": [ + { "key": "blockID", "type": "string", "value": "ece056d3-aa27-464b-81a8-643b3ae208e4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894711554, + "fields": [ + { "key": "bytes", "type": "int64", "value": 359960 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0da2897c85659567", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4407d391acba81fc" }], + "startTime": 1605873894683377, + "duration": 28594, + "tags": [], + "logs": [ + { "timestamp": 1605873894683384, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711971, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4407d391acba81fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5b0d3da4dac0a4ab" }], + "startTime": 1605873894683373, + "duration": 28605, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1987773829521f8f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1c9553a6471269c6" }], + "startTime": 1605873894699105, + "duration": 12885, + "tags": [], + "logs": [ + { "timestamp": 1605873894699119, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894711989, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c9553a6471269c6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "604de25c9811a395" }], + "startTime": 1605873894699099, + "duration": 12896, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3dedf220c1f51d38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "25820f0eebf05ab3" }], + "startTime": 1605873894704853, + "duration": 7356, + "tags": [], + "logs": [ + { "timestamp": 1605873894704879, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712209, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25820f0eebf05ab3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "62ea00c2c871a91e" }], + "startTime": 1605873894704846, + "duration": 7370, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7027388faf7e1bf1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "44c6d6c7e1afb67d" }], + "startTime": 1605873894684937, + "duration": 27418, + "tags": [], + "logs": [ + { "timestamp": 1605873894684943, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712354, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "44c6d6c7e1afb67d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7867c14538ff0c61" }], + "startTime": 1605873894684933, + "duration": 27429, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3f654e75b41629f5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683718, + "duration": 28677, + "tags": [ + { "key": "blockID", "type": "string", "value": "0e5b1fbb-ab10-44b7-89a0-f8932ee26dcf" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712392, + "fields": [ + { "key": "bytes", "type": "int64", "value": 369000 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4fa1d1a031112ab0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2e0985a0b4168ff2" }], + "startTime": 1605873894682996, + "duration": 29565, + "tags": [], + "logs": [ + { "timestamp": 1605873894683006, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712560, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e0985a0b4168ff2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "33edb8776dee2f38" }], + "startTime": 1605873894682988, + "duration": 29577, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7a7bf32e81f4317e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682984, + "duration": 29753, + "tags": [ + { "key": "blockID", "type": "string", "value": "c56f4809-bc48-4f81-9656-a3bbb96ba87e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712734, + "fields": [ + { "key": "bytes", "type": "int64", "value": 406296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66d4f363dfa46bdb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "616b800031f78e5f" }], + "startTime": 1605873894684041, + "duration": 28730, + "tags": [], + "logs": [ + { "timestamp": 1605873894684111, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "616b800031f78e5f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4cff4ebd296d36f0" }], + "startTime": 1605873894684037, + "duration": 28741, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5b0d3da4dac0a4ab", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683364, + "duration": 29595, + "tags": [ + { "key": "blockID", "type": "string", "value": "10e42379-1c35-419e-a26c-2630b9d2cdd2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712956, + "fields": [ + { "key": "bytes", "type": "int64", "value": 354744 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1571e420dca57b9f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683878, + "duration": 29122, + "tags": [ + { "key": "blockID", "type": "string", "value": "adb287c7-69e4-4ed8-8604-c3302c766db2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894712996, + "fields": [ + { "key": "bytes", "type": "int64", "value": 300104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3120fb610c52c9a6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684415, + "duration": 28590, + "tags": [ + { "key": "blockID", "type": "string", "value": "faebcb3d-444a-4675-8e55-2f46dbcaa1d7" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713002, + "fields": [ + { "key": "bytes", "type": "int64", "value": 410672 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "46feff0edeabb674", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "32df737f09cd2bf9" }], + "startTime": 1605873894683135, + "duration": 29700, + "tags": [], + "logs": [ + { "timestamp": 1605873894683141, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894712834, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "32df737f09cd2bf9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "002c4324a5c24667" }], + "startTime": 1605873894683131, + "duration": 29904, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "604de25c9811a395", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894699064, + "duration": 14141, + "tags": [ + { "key": "blockID", "type": "string", "value": "7df8ef23-0902-4b4b-92aa-b6c1aeb3c9c2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713203, + "fields": [ + { "key": "bytes", "type": "int64", "value": 436328 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7867c14538ff0c61", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684923, + "duration": 28333, + "tags": [ + { "key": "blockID", "type": "string", "value": "a94e5162-7e01-4bd6-b5c8-1bc3b50c67c6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713253, + "fields": [ + { "key": "bytes", "type": "int64", "value": 433064 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04e793f4b075b20f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3084a10a11a62355" }], + "startTime": 1605873894684557, + "duration": 28822, + "tags": [], + "logs": [ + { "timestamp": 1605873894684565, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713378, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3084a10a11a62355", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0271272ae09aac5f" }], + "startTime": 1605873894684553, + "duration": 28832, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0a0b86e5738d630b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894685048, + "duration": 28355, + "tags": [ + { "key": "blockID", "type": "string", "value": "36ce4c95-0cb6-4803-bdfd-b316b3c0cc4c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713400, + "fields": [ + { "key": "bytes", "type": "int64", "value": 293136 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62ea00c2c871a91e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894704821, + "duration": 8702, + "tags": [ + { "key": "blockID", "type": "string", "value": "b9c0dc2b-ee12-4876-a517-2902c6fe655e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713521, + "fields": [ + { "key": "bytes", "type": "int64", "value": 415912 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0f54c2d4ac7df141", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5e650633f1c4cb45" }], + "startTime": 1605873894683769, + "duration": 29774, + "tags": [], + "logs": [ + { "timestamp": 1605873894683775, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713542, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e650633f1c4cb45", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "42698e68a26de8cf" }], + "startTime": 1605873894683764, + "duration": 29784, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4cff4ebd296d36f0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684028, + "duration": 29524, + "tags": [ + { "key": "blockID", "type": "string", "value": "e6200492-f24a-40ef-946a-e89170d1ac54" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894713549, + "fields": [ + { "key": "bytes", "type": "int64", "value": 447592 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0da82a874696fec5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "20334815e0eb1b97" }], + "startTime": 1605873894684230, + "duration": 29351, + "tags": [], + "logs": [ + { "timestamp": 1605873894684236, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713581, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "20334815e0eb1b97", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2cb4a90ec9e7ed56" }], + "startTime": 1605873894684226, + "duration": 29362, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "20de4a897b30c066", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6edcc31aa4c96617" }], + "startTime": 1605873894683315, + "duration": 30412, + "tags": [], + "logs": [ + { "timestamp": 1605873894683323, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713726, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6edcc31aa4c96617", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5472390246aac5c4" }], + "startTime": 1605873894683309, + "duration": 30424, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3594272577366bc9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "01e9f897c4145c38" }], + "startTime": 1605873894684491, + "duration": 29257, + "tags": [], + "logs": [ + { "timestamp": 1605873894684498, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713747, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01e9f897c4145c38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2b56997697dd91c0" }], + "startTime": 1605873894684485, + "duration": 29270, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "53e0bef2bbb77bea", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3df7804d8e682193" }], + "startTime": 1605873894684901, + "duration": 28911, + "tags": [], + "logs": [ + { "timestamp": 1605873894684908, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713812, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3df7804d8e682193", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "224b550ee6ad2bf2" }], + "startTime": 1605873894684897, + "duration": 28919, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5664530667612f1f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4b6340b15001f8c8" }], + "startTime": 1605873894682860, + "duration": 31015, + "tags": [], + "logs": [ + { "timestamp": 1605873894682871, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894713875, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b6340b15001f8c8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "178dbf7349f30deb" }], + "startTime": 1605873894682855, + "duration": 31026, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b3d3f0643735e5f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894694842, + "duration": 19190, + "tags": [ + { "key": "blockID", "type": "string", "value": "f0b87e56-00a6-4270-8ce0-b47affb9113e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714027, + "fields": [ + { "key": "bytes", "type": "int64", "value": 310320 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a4b3e0d2f115bcf", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1e0da3179b38449d" }], + "startTime": 1605873894683987, + "duration": 30363, + "tags": [], + "logs": [ + { "timestamp": 1605873894683994, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714350, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1e0da3179b38449d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0e9c6b89215308ba" }], + "startTime": 1605873894683983, + "duration": 30374, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "318fcd8e3bfc42c7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "37e82ddc44e6bf60" }], + "startTime": 1605873894684209, + "duration": 30152, + "tags": [], + "logs": [ + { "timestamp": 1605873894684215, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714360, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "37e82ddc44e6bf60", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "718a103bd19501b2" }], + "startTime": 1605873894684205, + "duration": 30162, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0271272ae09aac5f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684542, + "duration": 29977, + "tags": [ + { "key": "blockID", "type": "string", "value": "bfbb9652-84f8-4145-8091-8197ea922ad3" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714514, + "fields": [ + { "key": "bytes", "type": "int64", "value": 432848 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2d80feb23cbbb7cd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "01ad9e5d3837c5b6" }], + "startTime": 1605873894684508, + "duration": 30161, + "tags": [], + "logs": [ + { "timestamp": 1605873894684515, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714668, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01ad9e5d3837c5b6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2d4e045a72c17ff2" }], + "startTime": 1605873894684503, + "duration": 30172, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42698e68a26de8cf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683756, + "duration": 30942, + "tags": [ + { "key": "blockID", "type": "string", "value": "55f71d63-05b0-4c3a-b79f-a2563307bf40" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714695, + "fields": [ + { "key": "bytes", "type": "int64", "value": 402624 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ea302c343fec88f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "279e17d93d4978da" }], + "startTime": 1605873894683645, + "duration": 31247, + "tags": [], + "logs": [ + { "timestamp": 1605873894683652, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714891, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "279e17d93d4978da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1c049cad7edf280e" }], + "startTime": 1605873894683641, + "duration": 31258, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "59b29ac1ab225873", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4196b1f250632b3e" }], + "startTime": 1605873894683001, + "duration": 31930, + "tags": [], + "logs": [ + { "timestamp": 1605873894683006, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894714930, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4196b1f250632b3e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4b9772650994e725" }], + "startTime": 1605873894682998, + "duration": 31938, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "224b550ee6ad2bf2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684889, + "duration": 30088, + "tags": [ + { "key": "blockID", "type": "string", "value": "07baec6a-187a-493b-b160-772936b5a3f0" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894714974, + "fields": [ + { "key": "bytes", "type": "int64", "value": 429360 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "368bcd97b5e9dde0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3d88bddf112b8ae2" }], + "startTime": 1605873894684678, + "duration": 30934, + "tags": [], + "logs": [ + { "timestamp": 1605873894684685, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715611, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d88bddf112b8ae2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "25548a46750dfecb" }], + "startTime": 1605873894684672, + "duration": 30946, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "718a103bd19501b2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684196, + "duration": 31424, + "tags": [ + { "key": "blockID", "type": "string", "value": "a85669d8-148b-4d61-a359-8f97c036b880" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715617, + "fields": [ + { "key": "bytes", "type": "int64", "value": 401280 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2b56997697dd91c0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684472, + "duration": 31151, + "tags": [ + { "key": "blockID", "type": "string", "value": "75911f2c-fc5e-4ef1-bcff-9abad2120f23" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715621, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c049cad7edf280e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683630, + "duration": 32119, + "tags": [ + { "key": "blockID", "type": "string", "value": "fdcc5380-c15f-41c2-9a34-623d6cdd2d5a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894715733, + "fields": [ + { "key": "bytes", "type": "int64", "value": 397464 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e3d16e8ed14d90c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "08a9d074d520a512" }], + "startTime": 1605873894702877, + "duration": 12975, + "tags": [], + "logs": [ + { "timestamp": 1605873894702894, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715852, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7bd595782cdb70c3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "083316368540b811" }], + "startTime": 1605873894699700, + "duration": 16154, + "tags": [], + "logs": [ + { "timestamp": 1605873894699708, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894715853, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08a9d074d520a512", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0cc47cc1eb5deb29" }], + "startTime": 1605873894702864, + "duration": 12993, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "083316368540b811", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "16870d24920c25b8" }], + "startTime": 1605873894699695, + "duration": 16164, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7f067cadc2b4569d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5061bd596bc8a7e7" }], + "startTime": 1605873894684535, + "duration": 31520, + "tags": [], + "logs": [ + { "timestamp": 1605873894684540, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716053, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5061bd596bc8a7e7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "645e4b6ea98c26bc" }], + "startTime": 1605873894684531, + "duration": 31530, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b9772650994e725", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682990, + "duration": 33209, + "tags": [ + { "key": "blockID", "type": "string", "value": "61022db6-4401-40b6-a3a2-1f4cd5ccb430" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716196, + "fields": [ + { "key": "bytes", "type": "int64", "value": 380504 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0e9c6b89215308ba", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683975, + "duration": 32340, + "tags": [ + { "key": "blockID", "type": "string", "value": "f17c848f-2f99-4215-a5e9-1f55d8e15c1e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716311, + "fields": [ + { "key": "bytes", "type": "int64", "value": 447736 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "030573bc0520e3c2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0d2e16a8cf201e5a" }], + "startTime": 1605873894683061, + "duration": 33348, + "tags": [], + "logs": [ + { "timestamp": 1605873894683067, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716409, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0d2e16a8cf201e5a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5f4913a50dcd37c8" }], + "startTime": 1605873894683057, + "duration": 33357, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0361f359be22f9c8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5310c5c355550cad" }], + "startTime": 1605873894702474, + "duration": 14135, + "tags": [], + "logs": [ + { "timestamp": 1605873894702483, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716607, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5310c5c355550cad", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "03fac2f4c91b31b6" }], + "startTime": 1605873894702463, + "duration": 14156, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "16870d24920c25b8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894699686, + "duration": 17072, + "tags": [ + { "key": "blockID", "type": "string", "value": "320a9ecd-a9fd-4c88-8aeb-e8a312dcce0c" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716753, + "fields": [ + { "key": "bytes", "type": "int64", "value": 424544 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62090e9e1c22bb56", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "02d91deb1ff0ea76" }], + "startTime": 1605873894707676, + "duration": 9095, + "tags": [], + "logs": [ + { "timestamp": 1605873894707691, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716771, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "02d91deb1ff0ea76", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3a7fc15a2fb60753" }], + "startTime": 1605873894707670, + "duration": 9107, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0cc47cc1eb5deb29", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894702844, + "duration": 13991, + "tags": [ + { "key": "blockID", "type": "string", "value": "04e21143-53ef-4083-948e-3bbe502c2d44" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716832, + "fields": [ + { "key": "bytes", "type": "int64", "value": 401728 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b27a749f4d1b557", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684297, + "duration": 32550, + "tags": [ + { "key": "blockID", "type": "string", "value": "d1ffbf86-0e11-4b6e-b9ae-8466e7c42a90" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894716844, + "fields": [ + { "key": "bytes", "type": "int64", "value": 193992 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3c5f2282a3e7c658", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6fc62f7a1ae1a6e9" }], + "startTime": 1605873894683345, + "duration": 33584, + "tags": [], + "logs": [ + { "timestamp": 1605873894683352, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894716927, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6fc62f7a1ae1a6e9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7bcf0390730028ef" }], + "startTime": 1605873894683340, + "duration": 33596, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "524a9c941765266a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5bcaa6a4a1c06160" }], + "startTime": 1605873894684584, + "duration": 32449, + "tags": [], + "logs": [ + { "timestamp": 1605873894684592, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717030, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5bcaa6a4a1c06160", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "22a3f914c23d3456" }], + "startTime": 1605873894684579, + "duration": 32460, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2d4e045a72c17ff2", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684465, + "duration": 32666, + "tags": [ + { "key": "blockID", "type": "string", "value": "4c9f58b7-b944-4692-9d5b-14270bd1b8d6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717127, + "fields": [ + { "key": "bytes", "type": "int64", "value": 436224 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25548a46750dfecb", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684652, + "duration": 32684, + "tags": [ + { "key": "blockID", "type": "string", "value": "ffd8fb66-db97-4451-9a97-bfb6631b82a5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717332, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434536 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f4913a50dcd37c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683050, + "duration": 34487, + "tags": [ + { "key": "blockID", "type": "string", "value": "0255db6b-061e-4ceb-9ae9-598588995be8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717533, + "fields": [ + { "key": "bytes", "type": "int64", "value": 392520 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6750f7ac4a5b50e8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "41f4b72bd0a14291" }], + "startTime": 1605873894684648, + "duration": 32974, + "tags": [], + "logs": [ + { "timestamp": 1605873894684654, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717621, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "41f4b72bd0a14291", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6dd256468dea419f" }], + "startTime": 1605873894684644, + "duration": 32984, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "03fac2f4c91b31b6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894702448, + "duration": 15285, + "tags": [ + { "key": "blockID", "type": "string", "value": "f7da9248-f02e-44df-b243-c1f5f69e0f67" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894717730, + "fields": [ + { "key": "bytes", "type": "int64", "value": 366872 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4934a16eeec96b0d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0ebf8034f9944320" }], + "startTime": 1605873894684005, + "duration": 33827, + "tags": [], + "logs": [ + { "timestamp": 1605873894684010, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894717831, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0ebf8034f9944320", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b30f12cd1728ebf" }], + "startTime": 1605873894684001, + "duration": 33836, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0cc1f6dfcc153616", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "501e4211325ef503" }], + "startTime": 1605873894683672, + "duration": 34388, + "tags": [], + "logs": [ + { "timestamp": 1605873894683679, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718059, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "501e4211325ef503", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "27cbe9623a4ee3a0" }], + "startTime": 1605873894683667, + "duration": 34399, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7bcf0390730028ef", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683332, + "duration": 34943, + "tags": [ + { "key": "blockID", "type": "string", "value": "cbeb7cd1-c8b1-4290-be74-4caf7b3f2d69" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718272, + "fields": [ + { "key": "bytes", "type": "int64", "value": 432424 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1b30f12cd1728ebf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683995, + "duration": 34418, + "tags": [ + { "key": "blockID", "type": "string", "value": "2dd90b29-ffb5-4a27-bcd7-0950ca151c14" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718411, + "fields": [ + { "key": "bytes", "type": "int64", "value": 210280 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22a3f914c23d3456", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684570, + "duration": 34106, + "tags": [ + { "key": "blockID", "type": "string", "value": "4fca87b1-ceb1-4290-a3cb-c0a970a7c5a6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718671, + "fields": [ + { "key": "bytes", "type": "int64", "value": 422528 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "169359b95c501fae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "71541fab4a38308f" }], + "startTime": 1605873894683035, + "duration": 35830, + "tags": [], + "logs": [ + { "timestamp": 1605873894683041, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718864, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "71541fab4a38308f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3dc2e79f403ae0d0" }], + "startTime": 1605873894683031, + "duration": 35841, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3a7fc15a2fb60753", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894707659, + "duration": 11315, + "tags": [ + { "key": "blockID", "type": "string", "value": "57b3be9d-2234-4b8b-a380-424f30717e5b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894718970, + "fields": [ + { "key": "bytes", "type": "int64", "value": 437088 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "10e57e001b6c6127", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2c3fb8ad983d67fc" }], + "startTime": 1605873894683210, + "duration": 35772, + "tags": [], + "logs": [ + { "timestamp": 1605873894683220, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894718980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2c3fb8ad983d67fc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "45d91fa92cb81841" }], + "startTime": 1605873894683206, + "duration": 35784, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3fca8d21c0827061", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "25a226515c2f9150" }], + "startTime": 1605873894684366, + "duration": 34674, + "tags": [], + "logs": [ + { "timestamp": 1605873894684372, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719039, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "25a226515c2f9150", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7aed634e79451eff" }], + "startTime": 1605873894684362, + "duration": 34687, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fb9111ac6a5d18d", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6945097dfeae216a" }], + "startTime": 1605873894683274, + "duration": 35965, + "tags": [], + "logs": [ + { "timestamp": 1605873894683286, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719238, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6945097dfeae216a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "156254fce90fef6d" }], + "startTime": 1605873894683268, + "duration": 35979, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6dd256468dea419f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684628, + "duration": 34986, + "tags": [ + { "key": "blockID", "type": "string", "value": "0a5b8e26-05d5-4df2-97c1-a57ccb631b5e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894719610, + "fields": [ + { "key": "bytes", "type": "int64", "value": 368392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "22c3bb99916b1cf9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7786d37aacb34302" }], + "startTime": 1605873894684954, + "duration": 34724, + "tags": [], + "logs": [ + { "timestamp": 1605873894684961, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894719678, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7786d37aacb34302", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1561e391ecd756d5" }], + "startTime": 1605873894684949, + "duration": 34735, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0b4489a19011e658", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5fe309dbb10a8aa0" }], + "startTime": 1605873894702395, + "duration": 17621, + "tags": [], + "logs": [ + { "timestamp": 1605873894702404, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720015, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fe309dbb10a8aa0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5477c4334a555c1a" }], + "startTime": 1605873894702385, + "duration": 17639, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "28a88c33b44009c0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2361a627270177b2" }], + "startTime": 1605873894684502, + "duration": 35545, + "tags": [], + "logs": [ + { "timestamp": 1605873894684508, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6d20c9fb0d7d023a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6365c636dea9cf69" }], + "startTime": 1605873894683045, + "duration": 37003, + "tags": [], + "logs": [ + { "timestamp": 1605873894683057, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7aed634e79451eff", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684353, + "duration": 35695, + "tags": [ + { "key": "blockID", "type": "string", "value": "c4b4a484-2704-49e8-926b-e7bb7a13c520" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720046, + "fields": [ + { "key": "bytes", "type": "int64", "value": 394640 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2361a627270177b2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4cb042697154defa" }], + "startTime": 1605873894684498, + "duration": 35556, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6365c636dea9cf69", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "69bb9bf8c37d9faf" }], + "startTime": 1605873894683040, + "duration": 37014, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7fd1af0b8e4b13e5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4d0b05c2fe988374" }], + "startTime": 1605873894704467, + "duration": 15587, + "tags": [], + "logs": [ + { "timestamp": 1605873894704477, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720054, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4d0b05c2fe988374", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "01afdbfe975f8d6d" }], + "startTime": 1605873894704458, + "duration": 15600, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "45d91fa92cb81841", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683191, + "duration": 36908, + "tags": [ + { "key": "blockID", "type": "string", "value": "abec5c1e-02b5-4165-8b3d-2940d3adb991" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720066, + "fields": [ + { "key": "bytes", "type": "int64", "value": 352392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4287af315802d4cd", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "62b352c305041dcc" }], + "startTime": 1605873894704355, + "duration": 15863, + "tags": [], + "logs": [ + { "timestamp": 1605873894704369, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720218, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62b352c305041dcc", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1c881037e38b18ad" }], + "startTime": 1605873894704348, + "duration": 15876, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ec165f264482f57", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7400527184eeef19" }], + "startTime": 1605873894683346, + "duration": 37139, + "tags": [], + "logs": [ + { "timestamp": 1605873894683351, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720484, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7400527184eeef19", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4ff7c150586c7e6f" }], + "startTime": 1605873894683342, + "duration": 37152, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=4ff7c150586c7e6f; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1561e391ecd756d5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684939, + "duration": 35696, + "tags": [ + { "key": "blockID", "type": "string", "value": "7dbea947-c624-454c-a99a-b2aa0c96c19f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720631, + "fields": [ + { "key": "bytes", "type": "int64", "value": 328592 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "57f916fcf19f117f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1d4458304925bc0f" }], + "startTime": 1605873894682643, + "duration": 38011, + "tags": [], + "logs": [ + { "timestamp": 1605873894682653, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720650, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d4458304925bc0f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3ff0fd3a1cdb9b5e" }], + "startTime": 1605873894682638, + "duration": 38028, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=3ff0fd3a1cdb9b5e; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6f251bfe2c45ae12", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5365877f5f1070a3" }], + "startTime": 1605873894709481, + "duration": 11241, + "tags": [], + "logs": [ + { "timestamp": 1605873894709489, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720722, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5365877f5f1070a3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5d1a0e533881c649" }], + "startTime": 1605873894709476, + "duration": 11253, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=5d1a0e533881c649; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5472390246aac5c4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683265, + "duration": 37540, + "tags": [ + { "key": "blockID", "type": "string", "value": "31cd1597-b435-467c-8726-9fd43cb8f75a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720802, + "fields": [ + { "key": "bytes", "type": "int64", "value": 263520 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "38b14977915ca22c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "08ecb88049158355" }], + "startTime": 1605873894683471, + "duration": 37385, + "tags": [], + "logs": [ + { "timestamp": 1605873894683477, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894720855, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08ecb88049158355", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "241c721a4337e64b" }], + "startTime": 1605873894683466, + "duration": 37394, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": ["invalid parent span IDs=241c721a4337e64b; skipping clock skew adjustment"] + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4cb042697154defa", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684490, + "duration": 36497, + "tags": [ + { "key": "blockID", "type": "string", "value": "37430ec1-eb84-4ad4-9bea-64b05bc05f0b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720984, + "fields": [ + { "key": "bytes", "type": "int64", "value": 329440 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01afdbfe975f8d6d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894704258, + "duration": 16738, + "tags": [ + { "key": "blockID", "type": "string", "value": "52136585-3c3c-418c-85bb-079c46f30ee8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894720994, + "fields": [ + { "key": "bytes", "type": "int64", "value": 271408 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c881037e38b18ad", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894704334, + "duration": 16710, + "tags": [ + { "key": "blockID", "type": "string", "value": "293f4ca9-60cf-4dce-84f9-90d7a8903467" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721042, + "fields": [ + { "key": "bytes", "type": "int64", "value": 448208 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "727cf2a7b14f8891", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6e28aae41ed950a0" }], + "startTime": 1605873894683209, + "duration": 37913, + "tags": [], + "logs": [ + { "timestamp": 1605873894683217, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721121, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72a3d0dd535ed714", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1034cca4b87566b9" }], + "startTime": 1605873894683196, + "duration": 37928, + "tags": [], + "logs": [ + { "timestamp": 1605873894683202, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721124, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e28aae41ed950a0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "27ca437dde2b9612" }], + "startTime": 1605873894683204, + "duration": 37923, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1034cca4b87566b9", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "31a678641a0daa14" }], + "startTime": 1605873894683192, + "duration": 37937, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5477c4334a555c1a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894702369, + "duration": 18817, + "tags": [ + { "key": "blockID", "type": "string", "value": "65c35f00-7bf4-4d7c-884e-57e1f4f386f1" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721184, + "fields": [ + { "key": "bytes", "type": "int64", "value": 343160 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "156254fce90fef6d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683261, + "duration": 38071, + "tags": [ + { "key": "blockID", "type": "string", "value": "5f6da848-1f43-4327-b791-c8607c834469" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721329, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425008 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "18174ee576735b69", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3c984a418432da06" }], + "startTime": 1605873894683380, + "duration": 38087, + "tags": [], + "logs": [ + { "timestamp": 1605873894683386, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894721466, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3c984a418432da06", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "24bddd4ea3487e35" }], + "startTime": 1605873894683376, + "duration": 38097, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2cb4a90ec9e7ed56", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684207, + "duration": 37304, + "tags": [ + { "key": "blockID", "type": "string", "value": "da15aeab-47f3-4150-a3a0-0b899f5728e0" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721505, + "fields": [ + { "key": "bytes", "type": "int64", "value": 435448 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31a678641a0daa14", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683185, + "duration": 38722, + "tags": [ + { "key": "blockID", "type": "string", "value": "7f859498-9292-4be9-9902-9cc0cc94db7f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894721902, + "fields": [ + { "key": "bytes", "type": "int64", "value": 382184 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27ca437dde2b9612", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683192, + "duration": 38914, + "tags": [ + { "key": "blockID", "type": "string", "value": "723cdf42-e4bc-48dc-bda5-6173eb15dee4" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722104, + "fields": [ + { "key": "bytes", "type": "int64", "value": 374272 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "777ba94dbf7e2679", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894703342, + "duration": 18950, + "tags": [ + { "key": "blockID", "type": "string", "value": "9ffb7568-b253-46bf-ae30-275a5370abdd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722288, + "fields": [ + { "key": "bytes", "type": "int64", "value": 296800 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "447a1de4607678e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4a1cb35fb165238f" }], + "startTime": 1605873894716862, + "duration": 5567, + "tags": [], + "logs": [ + { "timestamp": 1605873894716881, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722428, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4a1cb35fb165238f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "53d7bccf2fc103c5" }], + "startTime": 1605873894716854, + "duration": 5582, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "263d88ba7760646a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7cb0a9332c646221" }], + "startTime": 1605873894707662, + "duration": 14938, + "tags": [], + "logs": [ + { "timestamp": 1605873894707677, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722599, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7cb0a9332c646221", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "50ea9bcb501f096f" }], + "startTime": 1605873894707654, + "duration": 14951, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "178dbf7349f30deb", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682835, + "duration": 39846, + "tags": [ + { "key": "blockID", "type": "string", "value": "ae4b5b30-d87d-459f-8bfe-d05f4f169ced" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894722679, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384344 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "150994409f1cb25a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "585e5d65d550b215" }], + "startTime": 1605873894700618, + "duration": 22118, + "tags": [], + "logs": [ + { "timestamp": 1605873894700633, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722736, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "585e5d65d550b215", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4bf98f62683b0e8e" }], + "startTime": 1605873894700613, + "duration": 22129, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "27511615066e34db", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "79b947d9ed7866a6" }], + "startTime": 1605873894684102, + "duration": 38879, + "tags": [], + "logs": [ + { "timestamp": 1605873894684108, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894722980, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "79b947d9ed7866a6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "462c7cc77e9bde26" }], + "startTime": 1605873894684097, + "duration": 38891, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6f2e6507fe12975c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "00cdeb6a7479c53f" }], + "startTime": 1605873894717161, + "duration": 5887, + "tags": [], + "logs": [ + { "timestamp": 1605873894717174, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723047, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "00cdeb6a7479c53f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7b094f4ebdce31c3" }], + "startTime": 1605873894717155, + "duration": 5899, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "24bddd4ea3487e35", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683371, + "duration": 39804, + "tags": [ + { "key": "blockID", "type": "string", "value": "d7601ebc-c33c-492c-a1da-4aa053533084" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723171, + "fields": [ + { "key": "bytes", "type": "int64", "value": 365144 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "53d7bccf2fc103c5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894716842, + "duration": 6390, + "tags": [ + { "key": "blockID", "type": "string", "value": "ca64f28a-77dc-4745-abdc-44054c1e5e40" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723228, + "fields": [ + { "key": "bytes", "type": "int64", "value": 289352 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "552db884462abcc8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "66bfd77009ceabee" }], + "startTime": 1605873894683762, + "duration": 39518, + "tags": [], + "logs": [ + { "timestamp": 1605873894683768, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723279, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "66bfd77009ceabee", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "62e3ccbe325de3d1" }], + "startTime": 1605873894683756, + "duration": 39531, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "39ecc86ead7ef908", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "23e63f9ee6638cc5" }], + "startTime": 1605873894684607, + "duration": 38759, + "tags": [], + "logs": [ + { "timestamp": 1605873894684613, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723365, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "23e63f9ee6638cc5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6d4dfd6622f9d4e5" }], + "startTime": 1605873894684602, + "duration": 38769, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3995f8a937161d18", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7d8cbf547f13bab8" }], + "startTime": 1605873894685603, + "duration": 37861, + "tags": [], + "logs": [ + { "timestamp": 1605873894685611, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723463, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7d8cbf547f13bab8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0c763a5ef614a2f4" }], + "startTime": 1605873894685598, + "duration": 37871, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "50ea9bcb501f096f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894707639, + "duration": 15983, + "tags": [ + { "key": "blockID", "type": "string", "value": "7ba16a23-7c29-4c4e-a0a6-f5a35697f61e" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894723607, + "fields": [ + { "key": "bytes", "type": "int64", "value": 283976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "36174ad72177e7e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6eb33f7265438984" }], + "startTime": 1605873894683065, + "duration": 40713, + "tags": [], + "logs": [ + { "timestamp": 1605873894683103, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723777, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6eb33f7265438984", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3ffd9ecc1161334a" }], + "startTime": 1605873894683062, + "duration": 40722, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0d173415b42b54df", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0353b10977450cf7" }], + "startTime": 1605873894684351, + "duration": 39618, + "tags": [], + "logs": [ + { "timestamp": 1605873894684357, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894723968, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0353b10977450cf7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7ea1f5a8b4dab8a7" }], + "startTime": 1605873894684346, + "duration": 39628, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7b094f4ebdce31c3", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894717141, + "duration": 6985, + "tags": [ + { "key": "blockID", "type": "string", "value": "f1a4a13f-5e5d-484e-8b53-1f4f8e1bad37" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724124, + "fields": [ + { "key": "bytes", "type": "int64", "value": 448472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4bf98f62683b0e8e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894700602, + "duration": 23557, + "tags": [ + { "key": "blockID", "type": "string", "value": "2c8d9e08-28c9-43e0-a38e-48e205e70c0a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724156, + "fields": [ + { "key": "bytes", "type": "int64", "value": 454976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d32ea43a7ace6a3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "42780d9e0c2beb80" }], + "startTime": 1605873894685122, + "duration": 39094, + "tags": [], + "logs": [ + { "timestamp": 1605873894685153, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724214, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42780d9e0c2beb80", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1a42c28bcd21acc8" }], + "startTime": 1605873894685114, + "duration": 39108, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6d4dfd6622f9d4e5", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684594, + "duration": 39780, + "tags": [ + { "key": "blockID", "type": "string", "value": "6926ecb7-efff-41c5-ae95-85e0b8e75bed" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724372, + "fields": [ + { "key": "bytes", "type": "int64", "value": 364000 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "462c7cc77e9bde26", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684089, + "duration": 40384, + "tags": [ + { "key": "blockID", "type": "string", "value": "eacc5319-5c66-4ef0-bdc7-61ebcd665770" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894724469, + "fields": [ + { "key": "bytes", "type": "int64", "value": 375312 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "64c5e8cb8a8c9c87", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6a2abf3fa1e44a02" }], + "startTime": 1605873894684553, + "duration": 40087, + "tags": [], + "logs": [ + { "timestamp": 1605873894684559, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724639, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a2abf3fa1e44a02", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "445f67ce7c86e918" }], + "startTime": 1605873894684548, + "duration": 40098, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5185d47ca37c94b2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "63f3f66dc1b96cb5" }], + "startTime": 1605873894684289, + "duration": 40372, + "tags": [], + "logs": [ + { "timestamp": 1605873894684296, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724661, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "63f3f66dc1b96cb5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "635a7471f4256c0b" }], + "startTime": 1605873894684283, + "duration": 40383, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "09dfedf04619fd00", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4fcf6b895ba07eb1" }], + "startTime": 1605873894683527, + "duration": 41169, + "tags": [], + "logs": [ + { "timestamp": 1605873894683532, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724695, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4fcf6b895ba07eb1", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "021658e91c35b26e" }], + "startTime": 1605873894683524, + "duration": 41178, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d3e7eff78cb43af", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "43860f9f193430f0" }], + "startTime": 1605873894684135, + "duration": 40664, + "tags": [], + "logs": [ + { "timestamp": 1605873894684142, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894724800, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "43860f9f193430f0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "551f266c080ab0c6" }], + "startTime": 1605873894684131, + "duration": 40675, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7ea1f5a8b4dab8a7", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684338, + "duration": 40775, + "tags": [ + { "key": "blockID", "type": "string", "value": "2906f33b-d748-4827-8eb7-de90927a65dd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725108, + "fields": [ + { "key": "bytes", "type": "int64", "value": 431856 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "512c60978bd2eac4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7e897df0e96d32b5" }], + "startTime": 1605873894721019, + "duration": 4095, + "tags": [], + "logs": [ + { "timestamp": 1605873894721028, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725112, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e897df0e96d32b5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5262951e45efa67d" }], + "startTime": 1605873894721010, + "duration": 4112, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "787b23c8fa301dd7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2f86e3ff470976d3" }], + "startTime": 1605873894706900, + "duration": 18234, + "tags": [], + "logs": [ + { "timestamp": 1605873894706927, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725133, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3ffd9ecc1161334a", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683055, + "duration": 42085, + "tags": [ + { "key": "blockID", "type": "string", "value": "26f1bad8-cd58-4801-8785-d52b8d833a90" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725137, + "fields": [ + { "key": "bytes", "type": "int64", "value": 414056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2f86e3ff470976d3", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "72970316c65770af" }], + "startTime": 1605873894706887, + "duration": 18254, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1a42c28bcd21acc8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894685104, + "duration": 40144, + "tags": [ + { "key": "blockID", "type": "string", "value": "777d1eb8-cd33-44d5-8e34-2d253bd948a6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725246, + "fields": [ + { "key": "bytes", "type": "int64", "value": 407792 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "445f67ce7c86e918", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684540, + "duration": 40905, + "tags": [ + { "key": "blockID", "type": "string", "value": "d4b28adc-eb54-4e54-8639-40acbe82196a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725443, + "fields": [ + { "key": "bytes", "type": "int64", "value": 312232 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4dc9df94476d41ef", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1f47248f05173a34" }], + "startTime": 1605873894713432, + "duration": 12062, + "tags": [], + "logs": [ + { "timestamp": 1605873894713448, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725366, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1f47248f05173a34", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6b4bc2ee6a63726e" }], + "startTime": 1605873894713423, + "duration": 12078, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6ab47177b7f5e532", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "62088478a235ead5" }], + "startTime": 1605873894684151, + "duration": 41357, + "tags": [], + "logs": [ + { "timestamp": 1605873894684157, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725507, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62088478a235ead5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4b52acf382a86008" }], + "startTime": 1605873894684146, + "duration": 41368, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "021658e91c35b26e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683518, + "duration": 42121, + "tags": [ + { "key": "blockID", "type": "string", "value": "6deff928-b65a-437a-8d56-64d2397d9d1f" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725636, + "fields": [ + { "key": "bytes", "type": "int64", "value": 238936 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "48401abd95ffa153", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6a6bcedc4fc18a61" }], + "startTime": 1605873894712421, + "duration": 13359, + "tags": [], + "logs": [ + { "timestamp": 1605873894712431, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894725779, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a6bcedc4fc18a61", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5f55f469fc2d6d29" }], + "startTime": 1605873894712416, + "duration": 13371, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "551f266c080ab0c6", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684122, + "duration": 41711, + "tags": [ + { "key": "blockID", "type": "string", "value": "5180765b-50b6-4de3-abab-ceea6086afb8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725830, + "fields": [ + { "key": "bytes", "type": "int64", "value": 393392 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "72970316c65770af", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894706872, + "duration": 18990, + "tags": [ + { "key": "blockID", "type": "string", "value": "541a6a31-d25a-4275-9688-228355c81085" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725860, + "fields": [ + { "key": "bytes", "type": "int64", "value": 280296 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "635a7471f4256c0b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684275, + "duration": 41695, + "tags": [ + { "key": "blockID", "type": "string", "value": "6bf57585-a03d-44e1-bd18-081b679d3e4a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894725967, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434432 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7607fc837fc26251", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "01269c3f9d50434a" }], + "startTime": 1605873894683802, + "duration": 42223, + "tags": [], + "logs": [ + { "timestamp": 1605873894683808, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726025, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "01269c3f9d50434a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "420fc28f38c0b620" }], + "startTime": 1605873894683798, + "duration": 42233, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70114fe92b16120e", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "37a69429cff69860" }], + "startTime": 1605873894721062, + "duration": 5033, + "tags": [], + "logs": [ + { "timestamp": 1605873894721068, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726093, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "37a69429cff69860", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6b343c544d82bb3b" }], + "startTime": 1605873894721058, + "duration": 5043, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5262951e45efa67d", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894720997, + "duration": 5110, + "tags": [ + { "key": "blockID", "type": "string", "value": "5c09e7bd-2f9c-42d3-8d2f-863e93eaa939" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726103, + "fields": [ + { "key": "bytes", "type": "int64", "value": 246840 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1d6a76dd2ca6c3e6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7c75cd286737359f" }], + "startTime": 1605873894711590, + "duration": 14746, + "tags": [], + "logs": [ + { "timestamp": 1605873894711600, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726335, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7c75cd286737359f", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "19235cb1f2dccb32" }], + "startTime": 1605873894711582, + "duration": 14759, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "78415d3812916d77", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5f7ac8c4fd5c680a" }], + "startTime": 1605873894707879, + "duration": 18497, + "tags": [], + "logs": [ + { "timestamp": 1605873894707887, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726375, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f7ac8c4fd5c680a", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6c1c11a742626433" }], + "startTime": 1605873894707874, + "duration": 18513, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b4bc2ee6a63726e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894713413, + "duration": 13261, + "tags": [ + { "key": "blockID", "type": "string", "value": "b654e510-386a-470a-98c4-fb9833d21728" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726671, + "fields": [ + { "key": "bytes", "type": "int64", "value": 384056 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5f55f469fc2d6d29", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894712403, + "duration": 14367, + "tags": [ + { "key": "blockID", "type": "string", "value": "3fca3f89-a174-460d-9a87-92ed03555497" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726767, + "fields": [ + { "key": "bytes", "type": "int64", "value": 334896 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "19235cb1f2dccb32", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894711567, + "duration": 15276, + "tags": [ + { "key": "blockID", "type": "string", "value": "de228107-4ff6-449e-9f1f-6ab34765ab68" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894726841, + "fields": [ + { "key": "bytes", "type": "int64", "value": 225832 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "29b186beff361524", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0acce3e2af327bae" }], + "startTime": 1605873894699284, + "duration": 27562, + "tags": [], + "logs": [ + { "timestamp": 1605873894699298, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894726845, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0acce3e2af327bae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6e59a327558a7329" }], + "startTime": 1605873894699278, + "duration": 27578, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b343c544d82bb3b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894721050, + "duration": 5994, + "tags": [ + { "key": "blockID", "type": "string", "value": "459be32f-b91d-491b-aa61-5389b239eed8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727041, + "fields": [ + { "key": "bytes", "type": "int64", "value": 429792 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "62e3ccbe325de3d1", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683746, + "duration": 43412, + "tags": [ + { "key": "blockID", "type": "string", "value": "a1b8740a-6430-4113-93f4-ad9c52e42d62" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727155, + "fields": [ + { "key": "bytes", "type": "int64", "value": 366096 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "310178852fbf88e0", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "42bb5e9919ea40f4" }], + "startTime": 1605873894682731, + "duration": 44481, + "tags": [], + "logs": [ + { "timestamp": 1605873894682741, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727211, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "42bb5e9919ea40f4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2a694924a7a45244" }], + "startTime": 1605873894682726, + "duration": 44493, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "150c8cfeedab6a38", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1dda89d441503741" }], + "startTime": 1605873894708915, + "duration": 18374, + "tags": [], + "logs": [ + { "timestamp": 1605873894708922, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727288, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1dda89d441503741", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "69bd1c5d5184626b" }], + "startTime": 1605873894708910, + "duration": 18384, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6c1c11a742626433", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894707863, + "duration": 19558, + "tags": [ + { "key": "blockID", "type": "string", "value": "ef68962f-224d-4b6c-9dcd-ef9be8607c72" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727419, + "fields": [ + { "key": "bytes", "type": "int64", "value": 423912 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4393a9fa65c8ceae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1392dcdbb07bc781" }], + "startTime": 1605873894707853, + "duration": 19639, + "tags": [], + "logs": [ + { "timestamp": 1605873894707866, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727491, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1392dcdbb07bc781", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "18688fac379c4a9e" }], + "startTime": 1605873894707848, + "duration": 19650, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0804f1e82c8828e2", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4e9e2ce15a6e596c" }], + "startTime": 1605873894713028, + "duration": 14650, + "tags": [], + "logs": [ + { "timestamp": 1605873894713040, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894727677, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4e9e2ce15a6e596c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "47c93569ac9ecd04" }], + "startTime": 1605873894713023, + "duration": 14661, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0c763a5ef614a2f4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894685588, + "duration": 42409, + "tags": [ + { "key": "blockID", "type": "string", "value": "65fbe578-d535-4032-a12f-f249e7405363" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894727993, + "fields": [ + { "key": "bytes", "type": "int64", "value": 441088 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31c1f2fd1bde2d49", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "73f7696fdccac589" }], + "startTime": 1605873894682863, + "duration": 45441, + "tags": [], + "logs": [ + { "timestamp": 1605873894682870, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728303, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a694924a7a45244", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682710, + "duration": 45639, + "tags": [ + { "key": "blockID", "type": "string", "value": "06a1301e-5b48-425e-a506-a4350afe3d0d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728346, + "fields": [ + { "key": "bytes", "type": "int64", "value": 418168 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "73f7696fdccac589", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4614a3c3374430a7" }], + "startTime": 1605873894682859, + "duration": 45516, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4b52acf382a86008", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894684137, + "duration": 44241, + "tags": [ + { "key": "blockID", "type": "string", "value": "57df43b4-8552-49e3-a1cd-f442609deaf2" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728373, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425104 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "18688fac379c4a9e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894707836, + "duration": 20550, + "tags": [ + { "key": "blockID", "type": "string", "value": "c53e7db4-34cd-43d0-9383-de5e40936182" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728383, + "fields": [ + { "key": "bytes", "type": "int64", "value": 389928 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5fccf30d8fc010b8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1c2bf57fc386ac18" }], + "startTime": 1605873894726139, + "duration": 2327, + "tags": [], + "logs": [ + { "timestamp": 1605873894726153, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728465, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1c2bf57fc386ac18", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "4702ca2057b120a0" }], + "startTime": 1605873894726130, + "duration": 2343, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7cfea87d3c8d06ca", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "12a80c97c2a42f05" }], + "startTime": 1605873894685457, + "duration": 43096, + "tags": [], + "logs": [ + { "timestamp": 1605873894685465, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728552, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "12a80c97c2a42f05", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "33a5b0766e98269c" }], + "startTime": 1605873894685452, + "duration": 43107, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "08122694d5e37cb7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "313e6147867246d6" }], + "startTime": 1605873894723200, + "duration": 5455, + "tags": [], + "logs": [ + { "timestamp": 1605873894723209, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728654, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "313e6147867246d6", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3de17f2475734d79" }], + "startTime": 1605873894723194, + "duration": 5466, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2e4f73b3315eb612", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7e0457958022516b" }], + "startTime": 1605873894715649, + "duration": 13049, + "tags": [], + "logs": [ + { "timestamp": 1605873894715658, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728698, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7e0457958022516b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "398ed8e3573cf781" }], + "startTime": 1605873894715644, + "duration": 13060, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "04db9d1320bc1720", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "5e2c536cf48b42a8" }], + "startTime": 1605873894683309, + "duration": 45454, + "tags": [], + "logs": [ + { "timestamp": 1605873894683316, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894728762, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4702ca2057b120a0", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894726116, + "duration": 2652, + "tags": [ + { "key": "blockID", "type": "string", "value": "ec5b73d8-61f8-4210-8838-e6cfd253294b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728766, + "fields": [ + { "key": "bytes", "type": "int64", "value": 173264 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5e2c536cf48b42a8", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "40af99634093c17e" }], + "startTime": 1605873894683305, + "duration": 45464, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69bd1c5d5184626b", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894708900, + "duration": 19981, + "tags": [ + { "key": "blockID", "type": "string", "value": "de324468-b889-4f4c-af77-907353a719cd" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894728878, + "fields": [ + { "key": "bytes", "type": "int64", "value": 307032 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6849c669006c2759", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6836249c56ca87ae" }], + "startTime": 1605873894714727, + "duration": 14480, + "tags": [], + "logs": [ + { "timestamp": 1605873894714736, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894729206, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6836249c56ca87ae", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3d61171be03f3db4" }], + "startTime": 1605873894714722, + "duration": 14490, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4614a3c3374430a7", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894682850, + "duration": 46413, + "tags": [ + { "key": "blockID", "type": "string", "value": "ea416fc3-eedc-413e-9cc1-d8fd3548cfe6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729260, + "fields": [ + { "key": "bytes", "type": "int64", "value": 392160 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33a5b0766e98269c", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894685441, + "duration": 44269, + "tags": [ + { "key": "blockID", "type": "string", "value": "b50c3305-8d80-42fc-88a4-97a8604c7066" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729705, + "fields": [ + { "key": "bytes", "type": "int64", "value": 346072 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "38da2ce44e352b40", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2a0fc54146d07c21" }], + "startTime": 1605873894722577, + "duration": 7233, + "tags": [], + "logs": [ + { "timestamp": 1605873894722588, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894729809, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2a0fc54146d07c21", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "0dc60016513590c8" }], + "startTime": 1605873894722569, + "duration": 7247, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "398ed8e3573cf781", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894715632, + "duration": 14249, + "tags": [ + { "key": "blockID", "type": "string", "value": "2d9f964a-aac5-42e1-b410-866ad1706d7a" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729879, + "fields": [ + { "key": "bytes", "type": "int64", "value": 441600 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "47c93569ac9ecd04", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894713013, + "duration": 16911, + "tags": [ + { "key": "blockID", "type": "string", "value": "96c1b791-af15-4554-a1bb-b0f200625856" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729919, + "fields": [ + { "key": "bytes", "type": "int64", "value": 446504 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3d61171be03f3db4", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894714709, + "duration": 15261, + "tags": [ + { "key": "blockID", "type": "string", "value": "ae957436-40cc-4d15-a3c3-26d10613aaf6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729967, + "fields": [ + { "key": "bytes", "type": "int64", "value": 287976 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3de17f2475734d79", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894723182, + "duration": 6796, + "tags": [ + { "key": "blockID", "type": "string", "value": "eb218dd8-99fa-4de7-87cd-8998b89e0778" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894729976, + "fields": [ + { "key": "bytes", "type": "int64", "value": 403400 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "712c995480f17232", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "26950bc4bf84c34b" }], + "startTime": 1605873894713402, + "duration": 16771, + "tags": [], + "logs": [ + { "timestamp": 1605873894713412, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730172, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "26950bc4bf84c34b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "682e6d017ce71dad" }], + "startTime": 1605873894713392, + "duration": 16787, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "1bd3c2e5acbea9c4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "2106e0853647ac08" }], + "startTime": 1605873894701374, + "duration": 29019, + "tags": [], + "logs": [ + { "timestamp": 1605873894701383, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730393, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "2106e0853647ac08", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "31f86900e7598e55" }], + "startTime": 1605873894701369, + "duration": 29030, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6e59a327558a7329", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894699265, + "duration": 31183, + "tags": [ + { "key": "blockID", "type": "string", "value": "d8b5fee2-e2b3-445c-8ea9-243519a5c104" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894730443, + "fields": [ + { "key": "bytes", "type": "int64", "value": 400224 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "037acce8385b1858", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3547a2504168d8c7" }], + "startTime": 1605873894721017, + "duration": 9605, + "tags": [], + "logs": [ + { "timestamp": 1605873894721028, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730621, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3547a2504168d8c7", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "1b6f5fc9639df46e" }], + "startTime": 1605873894721012, + "duration": 9617, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "0dc60016513590c8", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894722299, + "duration": 8583, + "tags": [ + { "key": "blockID", "type": "string", "value": "69120461-fbd8-4ca0-a5fb-957d745a22a8" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894730879, + "fields": [ + { "key": "bytes", "type": "int64", "value": 370472 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5d18bf1b78bd4e75", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3bdc5547104db28c" }], + "startTime": 1605873894684396, + "duration": 46590, + "tags": [], + "logs": [ + { "timestamp": 1605873894684402, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894730985, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3bdc5547104db28c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "50a435658a82b02f" }], + "startTime": 1605873894684392, + "duration": 46600, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "682e6d017ce71dad", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894713268, + "duration": 17796, + "tags": [ + { "key": "blockID", "type": "string", "value": "84644a06-da08-4c3a-936f-70a634d8052d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731057, + "fields": [ + { "key": "bytes", "type": "int64", "value": 353808 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3bbbdf937ccb2ccb", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "7c40bff5b749a532" }], + "startTime": 1605873894721935, + "duration": 9258, + "tags": [], + "logs": [ + { "timestamp": 1605873894721944, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731193, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7c40bff5b749a532", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "44394ae14fcf852d" }], + "startTime": 1605873894721928, + "duration": 9272, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "31f86900e7598e55", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894701358, + "duration": 29885, + "tags": [ + { "key": "blockID", "type": "string", "value": "186f31e1-409b-4c9c-95b5-abc662389d3b" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731240, + "fields": [ + { "key": "bytes", "type": "int64", "value": 434496 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "7471b3c5a188e8da", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "33cb5e55876f584c" }], + "startTime": 1605873894729995, + "duration": 1327, + "tags": [], + "logs": [ + { "timestamp": 1605873894730003, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731321, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "33cb5e55876f584c", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "58b7d0b41550e88f" }], + "startTime": 1605873894729989, + "duration": 1338, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "58b7d0b41550e88f", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894729978, + "duration": 1396, + "tags": [ + { "key": "blockID", "type": "string", "value": "e0d8f1ac-a48f-4dea-868f-183e1a124fb5" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731373, + "fields": [ + { "key": "bytes", "type": "int64", "value": 16488 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70405f4198f01d16", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "56f0f0d7120ea5a5" }], + "startTime": 1605873894721544, + "duration": 9893, + "tags": [], + "logs": [ + { "timestamp": 1605873894721555, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731436, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "56f0f0d7120ea5a5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "24cfd5da176c2c8c" }], + "startTime": 1605873894721540, + "duration": 9903, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "69bb9bf8c37d9faf", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894683022, + "duration": 48646, + "tags": [ + { "key": "blockID", "type": "string", "value": "cf7747f5-68f9-490b-840d-9975c057c7e6" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894731663, + "fields": [ + { "key": "bytes", "type": "int64", "value": 425640 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "4ef61721dfd7f61b", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "70e8aa6d13e56007" }], + "startTime": 1605873894720840, + "duration": 10841, + "tags": [], + "logs": [ + { "timestamp": 1605873894720869, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731680, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "70e8aa6d13e56007", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77690db84ecb8d63" }], + "startTime": 1605873894720827, + "duration": 10860, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "297ff96c736c18f4", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6a80209f067be4f5" }], + "startTime": 1605873894705333, + "duration": 26573, + "tags": [], + "logs": [ + { "timestamp": 1605873894705342, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894731904, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6a80209f067be4f5", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3950b74a02171611" }], + "startTime": 1605873894705328, + "duration": 26585, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "5b3db530e83db855", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "6b2458a9486a4298" }], + "startTime": 1605873894731272, + "duration": 796, + "tags": [], + "logs": [ + { "timestamp": 1605873894731284, "fields": [{ "key": "keys requested", "type": "int64", "value": 1 }] }, + { "timestamp": 1605873894732067, "fields": [{ "key": "keys found", "type": "int64", "value": 1 }] } + ], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "6b2458a9486a4298", + "operationName": "Memcache.GetMulti", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "3139145bb422702e" }], + "startTime": 1605873894731265, + "duration": 886, + "tags": [ + { "key": "organization", "type": "string", "value": "1" }, + { "key": "span.kind", "type": "string", "value": "client" } + ], + "logs": [], + "processID": "p1", + "warnings": null + }, + { + "traceID": "3fa414edcef6ad90", + "spanID": "3139145bb422702e", + "operationName": "block.Find", + "references": [{ "refType": "CHILD_OF", "traceID": "3fa414edcef6ad90", "spanID": "77ba522b6ac9d789" }], + "startTime": 1605873894731251, + "duration": 941, + "tags": [ + { "key": "blockID", "type": "string", "value": "f9fd03ba-91a8-476b-b809-d2b11bfa790d" }, + { "key": "shardKey", "type": "int64", "value": 5 } + ], + "logs": [ + { + "timestamp": 1605873894732190, + "fields": [ + { "key": "bytes", "type": "int64", "value": 8680 }, + { "key": "msg", "type": "string", "value": "bloom" } + ] + } + ], + "processID": "p1", + "warnings": null + } + ], + "processes": { + "p1": { + "serviceName": "s1" + } + }, + "warnings": null + } + ], + "total": 0, + "limit": 0, + "offset": 0, + "errors": null +} diff --git a/packages/grafana-e2e/cypress/plugins/compareScreenshots.js b/packages/grafana-e2e/cypress/plugins/compareScreenshots.js new file mode 100644 index 0000000..426d12a --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/compareScreenshots.js @@ -0,0 +1,49 @@ +'use strict'; +const BlinkDiff = require('blink-diff'); +const { resolve } = require('path'); + +// @todo use npmjs.com/pixelmatch or an available cypress plugin +const compareScreenshots = async ({ config, screenshotsFolder, specName }) => { + const name = config.name || config; // @todo use `??` + const threshold = config.threshold || 0.001; // @todo use `??` + + const imageAPath = `${screenshotsFolder}/${specName}/${name}.png`; + const imageBPath = resolve(`${screenshotsFolder}/../expected/${specName}/${name}.png`); + + const imageOutputPath = screenshotsFolder.endsWith('actual') ? imageAPath.replace('.png', '.diff.png') : undefined; + + const { code } = await new Promise((resolve, reject) => { + new BlinkDiff({ + imageAPath, + imageBPath, + imageOutputPath, + threshold, + thresholdType: BlinkDiff.THRESHOLD_PERCENT, + }).run((error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + if (code <= 1) { + let msg = `\nThe screenshot [${imageAPath}] differs from [${imageBPath}]`; + msg += '\n'; + msg += '\nCheck the Artifacts tab in the CircleCi build output for the actual screenshots.'; + msg += '\n'; + msg += '\n If the difference between expected and outcome is NOT acceptable then do the following:'; + msg += '\n - Check the code for changes that causes this difference, fix that and retry.'; + msg += '\n'; + msg += '\n If the difference between expected and outcome is acceptable then do the following:'; + msg += '\n - Replace the expected image with the outcome and retry.'; + msg += '\n'; + throw new Error(msg); + } else { + // Must return a value + return true; + } +}; + +module.exports = compareScreenshots; diff --git a/packages/grafana-e2e/cypress/plugins/extendConfig.js b/packages/grafana-e2e/cypress/plugins/extendConfig.js new file mode 100644 index 0000000..85b9088 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/extendConfig.js @@ -0,0 +1,79 @@ +'use strict'; +const { + promises: { readFile }, +} = require('fs'); +const { resolve } = require('path'); + +// @todo use https://github.com/bahmutov/cypress-extends when possible +module.exports = async (baseConfig) => { + // From CLI + const { + env: { CWD, UPDATE_SCREENSHOTS }, + } = baseConfig; + + if (CWD) { + // @todo: https://github.com/cypress-io/cypress/issues/6406 + const jsonReporter = require.resolve('@mochajs/json-file-reporter'); + + // @todo `baseUrl: env.CYPRESS_BASEURL` + const projectConfig = { + fixturesFolder: `${CWD}/cypress/fixtures`, + integrationFolder: `${CWD}/cypress/integration`, + reporter: jsonReporter, + reporterOptions: { + output: `${CWD}/cypress/report.json`, + }, + screenshotsFolder: `${CWD}/cypress/screenshots/${UPDATE_SCREENSHOTS ? 'expected' : 'actual'}`, + videosFolder: `${CWD}/cypress/videos`, + }; + + const customProjectConfig = await readFile(`${CWD}/cypress.json`, 'utf8') + .then(JSON.parse) + .then((config) => { + const pathKeys = [ + 'fileServerFolder', + 'fixturesFolder', + 'ignoreTestFiles', + 'integrationFolder', + 'pluginsFile', + 'screenshotsFolder', + 'supportFile', + 'testFiles', + 'videosFolder', + ]; + + return Object.fromEntries( + Object.entries(config).map(([key, value]) => { + if (pathKeys.includes(key)) { + return [key, resolve(CWD, value)]; + } else { + return [key, value]; + } + }) + ); + }) + .catch((error) => { + if (error.code === 'ENOENT') { + // File is optional + return {}; + } else { + // Unexpected error + throw error; + } + }); + + return { + ...baseConfig, + ...projectConfig, + ...customProjectConfig, + reporterOptions: { + ...baseConfig.reporterOptions, + ...projectConfig.reporterOptions, + ...customProjectConfig.reporterOptions, + }, + }; + } else { + // Temporary legacy support for Grafana core (using `yarn start`) + return baseConfig; + } +}; diff --git a/packages/grafana-e2e/cypress/plugins/index.js b/packages/grafana-e2e/cypress/plugins/index.js new file mode 100644 index 0000000..c671748 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/index.js @@ -0,0 +1,19 @@ +const compareScreenshots = require('./compareScreenshots'); +const extendConfig = require('./extendConfig'); +const readProvisions = require('./readProvisions'); +const typescriptPreprocessor = require('./typescriptPreprocessor'); + +module.exports = (on, config) => { + on('file:preprocessor', typescriptPreprocessor); + on('task', { compareScreenshots, readProvisions }); + on('task', { + log({ message, optional }) { + optional ? console.log(message, optional) : console.log(message); + return null; + }, + }); + + // Always extend with this library's config and return for diffing + // @todo remove this when possible: https://github.com/cypress-io/cypress/issues/5674 + return extendConfig(config); +}; diff --git a/packages/grafana-e2e/cypress/plugins/readProvisions.js b/packages/grafana-e2e/cypress/plugins/readProvisions.js new file mode 100644 index 0000000..92f6c16 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/readProvisions.js @@ -0,0 +1,14 @@ +'use strict'; +const { parse: parseYml } = require('yaml'); +const { + promises: { readFile }, +} = require('fs'); +const { resolve: resolvePath } = require('path'); + +const readProvision = (filePath) => readFile(filePath, 'utf8').then((contents) => parseYml(contents)); + +const readProvisions = (filePaths) => Promise.all(filePaths.map(readProvision)); + +// Paths are relative to /provisioning +module.exports = ({ CWD, filePaths }) => + readProvisions(filePaths.map((filePath) => resolvePath(CWD, 'provisioning', filePath))); diff --git a/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js b/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js new file mode 100644 index 0000000..ed596a1 --- /dev/null +++ b/packages/grafana-e2e/cypress/plugins/typescriptPreprocessor.js @@ -0,0 +1,39 @@ +const { resolve } = require('path'); +const wp = require('@cypress/webpack-preprocessor'); + +const anyNodeModules = /node_modules/; +const packageRoot = resolve(`${__dirname}/../../`); +const packageModules = `${packageRoot}/node_modules`; + +const webpackOptions = { + module: { + rules: [ + { + include: (modulePath) => { + if (!anyNodeModules.test(modulePath)) { + // Is a file within the project + return true; + } else { + // Is a file within this package + return modulePath.startsWith(packageRoot) && !modulePath.startsWith(packageModules); + } + }, + test: /\.ts$/, + use: [ + { + loader: 'ts-loader', + }, + ], + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, +}; + +const options = { + webpackOptions, +}; + +module.exports = wp(options); diff --git a/packages/grafana-e2e/cypress/support/commands.ts b/packages/grafana-e2e/cypress/support/commands.ts new file mode 100644 index 0000000..4413d0c --- /dev/null +++ b/packages/grafana-e2e/cypress/support/commands.ts @@ -0,0 +1,25 @@ +import 'cypress-file-upload'; + +interface CompareScreenshotsConfig { + name: string; + threshold?: number; +} + +Cypress.Commands.add('compareScreenshots', (config: CompareScreenshotsConfig | string) => { + cy.task('compareScreenshots', { + config, + screenshotsFolder: Cypress.config('screenshotsFolder'), + specName: Cypress.spec.name, + }); +}); + +Cypress.Commands.add('logToConsole', (message: string, optional?: any) => { + cy.task('log', { message, optional }); +}); + +Cypress.Commands.add('readProvisions', (filePaths: string[]) => { + cy.task('readProvisions', { + CWD: Cypress.env('CWD'), + filePaths, + }); +}); diff --git a/packages/grafana-e2e/cypress/support/index.d.ts b/packages/grafana-e2e/cypress/support/index.d.ts new file mode 100644 index 0000000..3a559aa --- /dev/null +++ b/packages/grafana-e2e/cypress/support/index.d.ts @@ -0,0 +1,9 @@ +/// + +declare namespace Cypress { + interface Chainable { + compareScreenshots(config: CompareScreenshotsConfig | string): Chainable; + logToConsole(message: string, optional?: any): void; + readProvisions(filePaths: string[]): Chainable; + } +} diff --git a/packages/grafana-e2e/cypress/support/index.ts b/packages/grafana-e2e/cypress/support/index.ts new file mode 100644 index 0000000..0e38642 --- /dev/null +++ b/packages/grafana-e2e/cypress/support/index.ts @@ -0,0 +1,39 @@ +// yarn build fails with: +// >> /Users/hugo/go/src/github.com/grafana/grafana/node_modules/stringmap/stringmap.js:99 +// >> throw new Error("StringMap expected string key"); +// require('cypress-failed-log'); +import './commands'; + +Cypress.Screenshot.defaults({ + screenshotOnRunFailure: false, +}); + +const COMMAND_DELAY = 1000; + +if (Cypress.env('SLOWMO')) { + const commandsToModify = ['clear', 'click', 'contains', 'reload', 'then', 'trigger', 'type', 'visit']; + + commandsToModify.forEach((command) => { + // @ts-ignore -- https://github.com/cypress-io/cypress/issues/7807 + Cypress.Commands.overwrite(command, (originalFn, ...args) => { + const origVal = originalFn(...args); + + return new Promise((resolve) => { + setTimeout(() => resolve(origVal), COMMAND_DELAY); + }); + }); + }); +} + +// @todo remove when possible: https://github.com/cypress-io/cypress/issues/95 +Cypress.on('window:before:load', (win) => { + // @ts-ignore + delete win.fetch; +}); + +// uncomment below to prevent Cypress from failing tests when unhandled errors are thrown +// Cypress.on('uncaught:exception', (err, runnable) => { +// // returning false here prevents Cypress from +// // failing the test +// return false; +// }); diff --git a/packages/grafana-e2e/cypress/tsconfig.json b/packages/grafana-e2e/cypress/tsconfig.json new file mode 100644 index 0000000..c7e1a52 --- /dev/null +++ b/packages/grafana-e2e/cypress/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "declaration": false, + "module": "commonjs", + "types": ["cypress", "cypress-file-upload"] + }, + "extends": "@grafana/tsconfig", + "include": ["**/*.ts"] +} diff --git a/packages/grafana-e2e/index.js b/packages/grafana-e2e/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-e2e/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json new file mode 100644 index 0000000..e921905 --- /dev/null +++ b/packages/grafana-e2e/package.json @@ -0,0 +1,60 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/e2e", + "version": "7.5.5", + "description": "Grafana End-to-End Test Library", + "keywords": [ + "cli", + "grafana", + "e2e", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-e2e" + }, + "main": "src/index.ts", + "bin": { + "grafana-e2e": "bin/grafana-e2e.js" + }, + "scripts": { + "build": "grafana-toolkit package:build --scope=e2e", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "open": "cypress open", + "start": "cypress run --headless --browser chrome", + "test": "pushd test && node ../dist/bin/grafana-e2e.js run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/node": "13.7.7", + "@types/rollup-plugin-visualizer": "2.6.0", + "rollup": "2.33.3", + "rollup-plugin-copy": "3.3.0", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0" + }, + "types": "src/index.ts", + "dependencies": { + "@cypress/webpack-preprocessor": "4.1.3", + "@grafana/e2e-selectors": "7.5.5", + "@grafana/tsconfig": "^1.0.0-rc1", + "@mochajs/json-file-reporter": "^1.2.0", + "blink-diff": "1.0.13", + "commander": "5.0.0", + "cypress": "^6.3.0", + "cypress-file-upload": "^4.0.7", + "execa": "4.0.0", + "resolve-as-bin": "2.1.0", + "ts-loader": "6.2.1", + "typescript": "4.1.2", + "yaml": "^1.8.3" + } +} diff --git a/packages/grafana-e2e/rollup.config.ts b/packages/grafana-e2e/rollup.config.ts new file mode 100644 index 0000000..341c18b --- /dev/null +++ b/packages/grafana-e2e/rollup.config.ts @@ -0,0 +1,39 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import copy from 'rollup-plugin-copy'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const { name } = require('./package.json'); + +const buildCjsPackage = ({ env }) => ({ + input: 'compiled/index.js', + output: { + file: `dist/index.${env}.js`, + name, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + external: ['@grafana/e2e-selectors'], + plugins: [ + copy({ + flatten: false, + targets: [ + { src: 'bin/**/*.*', dest: 'dist/bin/' }, + { src: 'cli.js', dest: 'dist/' }, + { src: 'cypress.json', dest: 'dist/' }, + { src: 'cypress/**/*.*', dest: 'dist/cypress/' }, + ], + }), + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], +}); + +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-e2e/src/components/index.ts b/packages/grafana-e2e/src/components/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts new file mode 100644 index 0000000..1dc1807 --- /dev/null +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -0,0 +1,218 @@ +import { DeleteDashboardConfig } from './deleteDashboard'; +import { e2e } from '../index'; +import { getDashboardUid } from '../support/url'; +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; +import { v4 as uuidv4 } from 'uuid'; + +export interface AddAnnotationConfig { + dataSource: string; + dataSourceForm?: () => void; + name: string; +} + +export interface AddDashboardConfig { + annotations: AddAnnotationConfig[]; + timeRange: TimeRangeConfig; + title: string; + variables: PartialAddVariableConfig[]; +} + +interface AddVariableDefault { + hide: string; + type: string; +} + +interface AddVariableOptional { + constantValue?: string; + dataSource?: string; + label?: string; + query?: string; + regex?: string; +} + +interface AddVariableRequired { + name: string; +} + +export type PartialAddVariableConfig = Partial & AddVariableOptional & AddVariableRequired; +export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired; + +export const addDashboard = (config?: Partial) => { + const fullConfig: AddDashboardConfig = { + annotations: [], + title: `e2e-${uuidv4()}`, + variables: [], + ...config, + timeRange: { + from: '2020-01-01 00:00:00', + to: '2020-01-01 06:00:00', + zone: 'Coordinated Universal Time', + ...config?.timeRange, + }, + }; + + const { annotations, timeRange, title, variables } = fullConfig; + + e2e().logToConsole('Adding dashboard with title:', title); + + e2e.pages.AddDashboard.visit(); + + if (annotations.length > 0 || variables.length > 0) { + e2e.components.PageToolbar.item('Dashboard settings').click(); + addAnnotations(annotations); + + fullConfig.variables = addVariables(variables); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + } + + setDashboardTimeRange(timeRange); + + e2e.components.PageToolbar.item('Save dashboard').click(); + e2e.pages.SaveDashboardAsModal.newName().clear().type(title); + e2e.pages.SaveDashboardAsModal.save().click(); + e2e.flows.assertSuccessNotification(); + + e2e().logToConsole('Added dashboard with title:', title); + + return e2e() + .url() + .then((url: string) => { + const uid = getDashboardUid(url); + + e2e.getScenarioContext().then(({ addedDashboards }: any) => { + e2e.setScenarioContext({ + addedDashboards: [...addedDashboards, { title, uid } as DeleteDashboardConfig], + }); + }); + + // @todo remove `wrap` when possible + return e2e().wrap( + { + config: fullConfig, + uid, + }, + { log: false } + ); + }); +}; + +const addAnnotation = (config: AddAnnotationConfig, isFirst: boolean) => { + if (isFirst) { + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTA().click(); + } else { + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.btn', 'New').click(); + } + + const { dataSource, dataSourceForm, name } = config; + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.gf-form', 'Data source').find('select').select(dataSource); + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.gf-form', 'Name').find('input').type(name); + + if (dataSourceForm) { + dataSourceForm(); + } + + // @todo add to e2e-selectors and `aria-label` + e2e().contains('.btn', 'Add').click(); +}; + +const addAnnotations = (configs: AddAnnotationConfig[]) => { + if (configs.length > 0) { + e2e.pages.Dashboard.Settings.General.sectionItems('Annotations').click(); + } + + return configs.forEach((config, i) => addAnnotation(config, i === 0)); +}; + +export const VARIABLE_HIDE_LABEL = 'Label'; +export const VARIABLE_HIDE_NOTHING = ''; +export const VARIABLE_HIDE_VARIABLE = 'Variable'; + +export const VARIABLE_TYPE_AD_HOC_FILTERS = 'Ad hoc filters'; +export const VARIABLE_TYPE_CONSTANT = 'Constant'; +export const VARIABLE_TYPE_DATASOURCE = 'Datasource'; +export const VARIABLE_TYPE_QUERY = 'Query'; + +const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVariableConfig => { + const fullConfig = { + hide: VARIABLE_HIDE_NOTHING, + type: VARIABLE_TYPE_QUERY, + ...config, + }; + + if (isFirst) { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click(); + } else { + e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); + } + + const { constantValue, dataSource, label, name, query, regex, type } = fullConfig; + + // This field is key to many reactive changes + if (type !== VARIABLE_TYPE_QUERY) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('have.text', 'Query').click().type(`${type}{enter}`); + }); + } + + if (label) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label); + } + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name); + + if ( + dataSource && + (type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY) + ) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type(`${dataSource}{enter}`); + }); + } + + if (constantValue && type === VARIABLE_TYPE_CONSTANT) { + e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInput().type(constantValue); + } + + if (type === VARIABLE_TYPE_QUERY) { + if (query) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().type(query); + } + + if (regex) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInput().type(regex); + } + } + + // Avoid flakiness + e2e().focused().blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('exist') + .within((previewOfValues) => { + if (type === VARIABLE_TYPE_CONSTANT) { + expect(previewOfValues.text()).equals(constantValue); + } + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + + return fullConfig; +}; + +const addVariables = (configs: PartialAddVariableConfig[]): AddVariableConfig[] => { + if (configs.length > 0) { + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click(); + } + + return configs.map((config, i) => addVariable(config, i === 0)); +}; diff --git a/packages/grafana-e2e/src/flows/addDataSource.ts b/packages/grafana-e2e/src/flows/addDataSource.ts new file mode 100644 index 0000000..5b15464 --- /dev/null +++ b/packages/grafana-e2e/src/flows/addDataSource.ts @@ -0,0 +1,115 @@ +import { DeleteDataSourceConfig } from './deleteDataSource'; +import { e2e } from '../index'; +import { fromBaseUrl, getDataSourceId } from '../support/url'; +import { v4 as uuidv4 } from 'uuid'; + +export interface AddDataSourceConfig { + basicAuth: boolean; + basicAuthPassword: string; + basicAuthUser: string; + checkHealth: boolean; + expectedAlertMessage: string | RegExp; + form: () => void; + name: string; + skipTlsVerify: boolean; + type: string; + timeout?: number; +} + +// @todo this actually returns type `Cypress.Chainable` +export const addDataSource = (config?: Partial) => { + const fullConfig: AddDataSourceConfig = { + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + checkHealth: false, + expectedAlertMessage: 'Data source is working', + form: () => {}, + name: `e2e-${uuidv4()}`, + skipTlsVerify: false, + type: 'TestData DB', + ...config, + }; + + const { + basicAuth, + basicAuthPassword, + basicAuthUser, + checkHealth, + expectedAlertMessage, + form, + name, + skipTlsVerify, + type, + timeout, + } = fullConfig; + + e2e().logToConsole('Adding data source with name:', name); + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePlugins(type) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click(); + + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(name); + + if (basicAuth) { + e2e().contains('label', 'Basic auth').scrollIntoView().click(); + e2e() + .contains('.gf-form-group', 'Basic Auth Details') + .should('be.visible') + .scrollIntoView() + .within(() => { + if (basicAuthUser) { + e2e().get('[placeholder=user]').type(basicAuthUser); + } + if (basicAuthPassword) { + e2e().get('[placeholder=Password]').type(basicAuthPassword); + } + }); + } + + if (skipTlsVerify) { + e2e().contains('label', 'Skip TLS Verify').scrollIntoView().click(); + } + + form(); + + e2e.pages.DataSource.saveAndTest().click(); + + // use the timeout passed in if it exists, otherwise, continue to use the default + e2e.pages.DataSource.alert() + .should('exist') + .contains(expectedAlertMessage, { + timeout: timeout ?? e2e.config().defaultCommandTimeout, + }); + e2e().logToConsole('Added data source with name:', name); + + return e2e() + .url() + .then((url: string) => { + const id = getDataSourceId(url); + + e2e.getScenarioContext().then(({ addedDataSources }: any) => { + e2e.setScenarioContext({ + addedDataSources: [...addedDataSources, { id, name } as DeleteDataSourceConfig], + }); + }); + + if (checkHealth) { + const healthUrl = fromBaseUrl(`/api/datasources/${id}/health`); + e2e().logToConsole(`Fetching ${healthUrl}`); + e2e().request(healthUrl).its('body').should('have.property', 'status').and('eq', 'OK'); + } + + // @todo remove `wrap` when possible + return e2e().wrap( + { + config: fullConfig, + id, + }, + { log: false } + ); + }); +}; diff --git a/packages/grafana-e2e/src/flows/addPanel.ts b/packages/grafana-e2e/src/flows/addPanel.ts new file mode 100644 index 0000000..9e1907f --- /dev/null +++ b/packages/grafana-e2e/src/flows/addPanel.ts @@ -0,0 +1,14 @@ +import { configurePanel, PartialAddPanelConfig } from './configurePanel'; +import { getScenarioContext } from '../support/scenarioContext'; +import { v4 as uuidv4 } from 'uuid'; + +export const addPanel = (config?: Partial) => + getScenarioContext().then(({ lastAddedDataSource }: any) => + configurePanel({ + dataSourceName: lastAddedDataSource, + panelTitle: `e2e-${uuidv4()}`, + ...config, + isEdit: false, + isExplore: false, + }) + ); diff --git a/packages/grafana-e2e/src/flows/assertSuccessNotification.ts b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts new file mode 100644 index 0000000..c86f4cc --- /dev/null +++ b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts @@ -0,0 +1,5 @@ +import { e2e } from '../index'; + +export const assertSuccessNotification = () => { + e2e().get('[aria-label^="Alert success"]').should('exist'); +}; diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts new file mode 100644 index 0000000..07cedf0 --- /dev/null +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -0,0 +1,303 @@ +import { e2e } from '../index'; +import { getLocalStorage, requireLocalStorage } from '../support/localStorage'; +import { getScenarioContext } from '../support/scenarioContext'; +import { selectOption } from './selectOption'; +import { setDashboardTimeRange } from './setDashboardTimeRange'; +import { setTimeRange, TimeRangeConfig } from './setTimeRange'; + +interface AddPanelOverrides { + dataSourceName: string; + queriesForm: (config: AddPanelConfig) => void; + panelTitle: string; +} + +interface EditPanelOverrides { + queriesForm?: (config: EditPanelConfig) => void; + panelTitle: string; +} + +interface ConfigurePanelDefault { + chartData: { + method: string; + route: string | RegExp; + }; + dashboardUid: string; + matchScreenshot: boolean; + saveDashboard: boolean; + screenshotName: string; + visitDashboardAtStart: boolean; // @todo remove when possible +} + +interface ConfigurePanelOptional { + dataSourceName?: string; + queriesForm?: (config: ConfigurePanelConfig) => void; + panelTitle?: string; + timeRange?: TimeRangeConfig; + visualizationName?: string; + matchExploreTable?: boolean; +} + +interface ConfigurePanelRequired { + isEdit: boolean; + isExplore: boolean; +} + +export type PartialConfigurePanelConfig = Partial & + ConfigurePanelOptional & + ConfigurePanelRequired; + +export type ConfigurePanelConfig = ConfigurePanelDefault & ConfigurePanelOptional & ConfigurePanelRequired; + +export type PartialAddPanelConfig = PartialConfigurePanelConfig & AddPanelOverrides; +export type AddPanelConfig = ConfigurePanelConfig & AddPanelOverrides; + +export type PartialEditPanelConfig = PartialConfigurePanelConfig & EditPanelOverrides; +export type EditPanelConfig = ConfigurePanelConfig & EditPanelOverrides; + +// @todo this actually returns type `Cypress.Chainable` +export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelConfig | PartialConfigurePanelConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }: any) => { + const fullConfig: AddPanelConfig | EditPanelConfig | ConfigurePanelConfig = { + chartData: { + method: 'POST', + route: '/api/ds/query', + }, + dashboardUid: lastAddedDashboardUid, + matchScreenshot: false, + saveDashboard: true, + screenshotName: 'panel-visualization', + visitDashboardAtStart: true, + ...config, + }; + + const { + chartData, + dashboardUid, + dataSourceName, + isEdit, + isExplore, + matchExploreTable, + matchScreenshot, + panelTitle, + queriesForm, + screenshotName, + timeRange, + visitDashboardAtStart, + visualizationName, + } = fullConfig; + + if (isEdit && isExplore) { + throw new TypeError('Invalid configuration'); + } + + if (isExplore) { + e2e.pages.Explore.visit(); + } else { + if (visitDashboardAtStart) { + e2e.flows.openDashboard({ uid: dashboardUid }); + } + + if (isEdit) { + e2e.components.Panels.Panel.title(panelTitle).click(); + e2e.components.Panels.Panel.headerItems('Edit').click(); + } else { + e2e.components.PageToolbar.item('Add panel').click(); + e2e.pages.AddDashboard.addNewPanel().click(); + } + } + + if (timeRange) { + if (isExplore) { + e2e.pages.Explore.Toolbar.navBar().within(() => setTimeRange(timeRange)); + } else { + setDashboardTimeRange(timeRange); + } + } + + e2e().server(); + + // @todo alias '/**/*.js*' as '@pluginModule' when possible: https://github.com/cypress-io/cypress/issues/1296 + + e2e().route(chartData.method, chartData.route).as('chartData'); + + if (dataSourceName) { + selectOption({ + container: e2e.components.DataSourcePicker.container(), + optionText: dataSourceName, + }); + } + + // @todo instead wait for '@pluginModule' if not already loaded + e2e().wait(2000); + + if (!isExplore) { + if (!isEdit) { + // Fields could be covered due to an empty query editor + closeRequestErrors(); + } + + // `panelTitle` is needed to edit the panel, and unlikely to have its value changed at that point + const changeTitle = panelTitle && !isEdit; + + if (changeTitle || visualizationName) { + openOptions(); + + if (changeTitle) { + openOptionsGroup('settings'); + getOptionsGroup('settings') + .find('[value="Panel Title"]') + .scrollIntoView() + .clear() + .type(panelTitle as string); + } + + if (visualizationName) { + openOptionsGroup('type'); + e2e.components.PluginVisualization.item(visualizationName).scrollIntoView().click(); + + // @todo wait for '@pluginModule' if not a core visualization and not already loaded + e2e().wait(2000); + } + + // Consistently closed + closeOptionsGroup('settings'); + closeOptionsGroup('type'); + closeOptions(); + } else { + // Consistently closed + closeOptions(); + } + } + + if (queriesForm) { + queriesForm(fullConfig); + e2e().wait('@chartData'); + + // Wait for a possible complex visualization to render (or something related, as this isn't necessary on the dashboard page) + // Can't assert that its HTML changed because a new query could produce the same results + e2e().wait(1000); + } + + // @todo enable when plugins have this implemented + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //e2e().wait('@chartData'); + //e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data'); + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //e2e().wait('@chartData'); + + if (!isExplore) { + e2e().get('button[title="Apply changes and go back to dashboard"]').click(); + e2e().url().should('include', `/d/${dashboardUid}`); + } + + // Avoid annotations flakiness + e2e.components.RefreshPicker.runButton().should('be.visible').click(); + + e2e().wait('@chartData'); + + // Wait for RxJS + e2e().wait(500); + + if (matchScreenshot) { + let visualization; + + if (isExplore) { + visualization = matchExploreTable ? e2e.pages.Explore.General.table() : e2e.pages.Explore.General.graph(); + } else { + visualization = e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content'); + } + + visualization.scrollIntoView().screenshot(screenshotName); + e2e().compareScreenshots(screenshotName); + } + + // @todo remove `wrap` when possible + return e2e().wrap({ config: fullConfig }, { log: false }); + }); + +// @todo this actually returns type `Cypress.Chainable` +const closeOptions = (): any => + isOptionsOpen().then((isOpen: any) => { + if (isOpen) { + e2e.components.PanelEditor.OptionsPane.close().click(); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const closeOptionsGroup = (name: string): any => + isOptionsGroupOpen(name).then((isOpen: any) => { + if (isOpen) { + toggleOptionsGroup(name); + } + }); + +const closeRequestErrors = () => { + e2e().wait(1000); // emulate `cy.get()` for nested errors + e2e() + .get('app-notifications-list') + .then(($elm) => { + // Avoid failing when none are found + const selector = '[aria-label="Alert error"]:contains("Failed to call resource")'; + const numErrors = $elm.find(selector).length; + + for (let i = 0; i < numErrors; i++) { + e2e().get(selector).first().find('button').click(); + } + }); +}; + +const getOptionsGroup = (name: string) => e2e().get(`.options-group:has([aria-label="Options group Panel ${name}"])`); + +// @todo this actually returns type `Cypress.Chainable` +const isOptionsGroupOpen = (name: string): any => + requireLocalStorage(`grafana.dashboard.editor.ui.optionGroup[Panel ${name}]`).then(({ defaultToClosed }: any) => { + // @todo remove `wrap` when possible + return e2e().wrap(!defaultToClosed, { log: false }); + }); + +// @todo this actually returns type `Cypress.Chainable` +const isOptionsOpen = (): any => + getLocalStorage('grafana.dashboard.editor.ui').then((data: any) => { + if (data) { + // @todo remove `wrap` when possible + return e2e().wrap(data.isPanelOptionsVisible, { log: false }); + } else { + // @todo remove `wrap` when possible + return e2e().wrap(true, { log: false }); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const openOptions = (): any => + isOptionsOpen().then((isOpen: any) => { + if (!isOpen) { + e2e.components.PanelEditor.OptionsPane.open().click(); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +const openOptionsGroup = (name: string): any => + isOptionsGroupOpen(name).then((isOpen: any) => { + if (!isOpen) { + toggleOptionsGroup(name); + } + }); + +const toggleOptionsGroup = (name: string) => getOptionsGroup(name).find('.editor-options-group-toggle').click(); + +export const VISUALIZATION_ALERT_LIST = 'Alert list'; +export const VISUALIZATION_BAR_GAUGE = 'Bar gauge'; +export const VISUALIZATION_CLOCK = 'Clock'; +export const VISUALIZATION_DASHBOARD_LIST = 'Dashboard list'; +export const VISUALIZATION_GAUGE = 'Gauge'; +export const VISUALIZATION_GRAPH = 'Graph'; +export const VISUALIZATION_HEAT_MAP = 'Heatmap'; +export const VISUALIZATION_LOGS = 'Logs'; +export const VISUALIZATION_NEWS = 'News'; +export const VISUALIZATION_PIE_CHART = 'Pie Chart'; +export const VISUALIZATION_PLUGIN_LIST = 'Plugin list'; +export const VISUALIZATION_POLYSTAT = 'Polystat'; +export const VISUALIZATION_STAT = 'Stat'; +export const VISUALIZATION_TABLE = 'Table'; +export const VISUALIZATION_TEXT = 'Text'; +export const VISUALIZATION_WORLD_MAP = 'Worldmap Panel'; diff --git a/packages/grafana-e2e/src/flows/deleteDashboard.ts b/packages/grafana-e2e/src/flows/deleteDashboard.ts new file mode 100644 index 0000000..9bb90a6 --- /dev/null +++ b/packages/grafana-e2e/src/flows/deleteDashboard.ts @@ -0,0 +1,47 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDashboardConfig { + quick?: boolean; + title: string; + uid: string; +} + +export const deleteDashboard = ({ quick = false, title, uid }: DeleteDashboardConfig) => { + e2e().logToConsole('Deleting dashboard with uid:', uid); + + if (quick) { + quickDelete(uid); + } else { + uiDelete(uid, title); + } + + e2e().logToConsole('Deleted dashboard with uid:', uid); + + e2e.getScenarioContext().then(({ addedDashboards }: any) => { + e2e.setScenarioContext({ + addedDashboards: addedDashboards.filter((dashboard: DeleteDashboardConfig) => { + return dashboard.title !== title && dashboard.uid !== uid; + }), + }); + }); +}; + +const quickDelete = (uid: string) => { + e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${uid}`)); +}; + +const uiDelete = (uid: string, title: string) => { + e2e.pages.Dashboard.visit(uid); + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.pages.Dashboard.Settings.General.deleteDashBoard().click(); + e2e.pages.ConfirmModal.delete().click(); + e2e.flows.assertSuccessNotification(); + + e2e.pages.Dashboards.visit(); + + // @todo replace `e2e.pages.Dashboards.dashboards` with this when argument is empty + e2e() + .get('[aria-label^="Dashboard search item "]') + .each((item) => e2e().wrap(item).should('not.contain', title)); +}; diff --git a/packages/grafana-e2e/src/flows/deleteDataSource.ts b/packages/grafana-e2e/src/flows/deleteDataSource.ts new file mode 100644 index 0000000..f68b38f --- /dev/null +++ b/packages/grafana-e2e/src/flows/deleteDataSource.ts @@ -0,0 +1,46 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDataSourceConfig { + id: string; + name: string; + quick?: boolean; +} + +export const deleteDataSource = ({ id, name, quick = false }: DeleteDataSourceConfig) => { + e2e().logToConsole('Deleting data source with name:', name); + + if (quick) { + quickDelete(name); + } else { + uiDelete(name); + } + + e2e().logToConsole('Deleted data source with name:', name); + + e2e.getScenarioContext().then(({ addedDataSources }: any) => { + e2e.setScenarioContext({ + addedDataSources: addedDataSources.filter((dataSource: DeleteDataSourceConfig) => { + return dataSource.id !== id && dataSource.name !== name; + }), + }); + }); +}; + +const quickDelete = (name: string) => { + e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${name}`)); +}; + +const uiDelete = (name: string) => { + e2e.pages.DataSources.visit(); + e2e.pages.DataSources.dataSources(name).click(); + e2e.pages.DataSource.delete().click(); + e2e.pages.ConfirmModal.delete().click(); + + e2e.pages.DataSources.visit(); + + // @todo replace `e2e.pages.DataSources.dataSources` with this when argument is empty + e2e() + .get('[aria-label^="Data source list item "]') + .each((item) => e2e().wrap(item).should('not.contain', name)); +}; diff --git a/packages/grafana-e2e/src/flows/editPanel.ts b/packages/grafana-e2e/src/flows/editPanel.ts new file mode 100644 index 0000000..c003d47 --- /dev/null +++ b/packages/grafana-e2e/src/flows/editPanel.ts @@ -0,0 +1,8 @@ +import { configurePanel, PartialEditPanelConfig } from './configurePanel'; + +export const editPanel = (config: Partial) => + configurePanel({ + ...config, + isEdit: true, + isExplore: false, + }); diff --git a/packages/grafana-e2e/src/flows/explore.ts b/packages/grafana-e2e/src/flows/explore.ts new file mode 100644 index 0000000..cc9dd10 --- /dev/null +++ b/packages/grafana-e2e/src/flows/explore.ts @@ -0,0 +1,19 @@ +import { configurePanel, PartialConfigurePanelConfig } from './configurePanel'; +import { getScenarioContext } from '../support/scenarioContext'; + +export const explore = (config: Partial) => + getScenarioContext().then(({ lastAddedDataSource }: any) => + configurePanel({ + dataSourceName: lastAddedDataSource, + screenshotName: 'explore-graph', + ...config, + isEdit: false, + isExplore: true, + timeRange: { + from: '2020-01-01 00:00:00', + to: '2020-01-01 06:00:00', + zone: 'Coordinated Universal Time', + ...config.timeRange, + }, + }) + ); diff --git a/packages/grafana-e2e/src/flows/index.ts b/packages/grafana-e2e/src/flows/index.ts new file mode 100644 index 0000000..0ba42e7 --- /dev/null +++ b/packages/grafana-e2e/src/flows/index.ts @@ -0,0 +1,33 @@ +export * from './addDashboard'; +export * from './addDataSource'; +export * from './addPanel'; +export * from './assertSuccessNotification'; +export * from './deleteDashboard'; +export * from './deleteDataSource'; +export * from './editPanel'; +export * from './explore'; +export * from './login'; +export * from './openDashboard'; +export * from './openPanelMenuItem'; +export * from './revertAllChanges'; +export * from './saveDashboard'; +export * from './selectOption'; + +export { + VISUALIZATION_ALERT_LIST, + VISUALIZATION_BAR_GAUGE, + VISUALIZATION_CLOCK, + VISUALIZATION_DASHBOARD_LIST, + VISUALIZATION_GAUGE, + VISUALIZATION_GRAPH, + VISUALIZATION_HEAT_MAP, + VISUALIZATION_LOGS, + VISUALIZATION_NEWS, + VISUALIZATION_PIE_CHART, + VISUALIZATION_PLUGIN_LIST, + VISUALIZATION_POLYSTAT, + VISUALIZATION_STAT, + VISUALIZATION_TABLE, + VISUALIZATION_TEXT, + VISUALIZATION_WORLD_MAP, +} from './configurePanel'; diff --git a/packages/grafana-e2e/src/flows/login.ts b/packages/grafana-e2e/src/flows/login.ts new file mode 100644 index 0000000..300d8fe --- /dev/null +++ b/packages/grafana-e2e/src/flows/login.ts @@ -0,0 +1,22 @@ +import { e2e } from '../index'; + +const DEFAULT_USERNAME = 'admin'; +const DEFAULT_PASSWORD = 'admin'; + +export const login = (username = DEFAULT_USERNAME, password = DEFAULT_PASSWORD) => { + e2e().logToConsole('Logging in with username:', username); + e2e.pages.Login.visit(); + e2e.pages.Login.username() + .should('be.visible') // prevents flakiness + .type(username); + e2e.pages.Login.password().type(password); + e2e.pages.Login.submit().click(); + + // Local tests will have insecure credentials + if (password === DEFAULT_PASSWORD) { + e2e.pages.Login.skip().should('be.visible').click(); + } + + e2e().get('.login-page').should('not.exist'); + e2e().logToConsole('Logged in with username:', username); +}; diff --git a/packages/grafana-e2e/src/flows/openDashboard.ts b/packages/grafana-e2e/src/flows/openDashboard.ts new file mode 100644 index 0000000..3bbcec0 --- /dev/null +++ b/packages/grafana-e2e/src/flows/openDashboard.ts @@ -0,0 +1,34 @@ +import { e2e } from '../index'; +import { getScenarioContext } from '../support/scenarioContext'; +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; + +interface OpenDashboardDefault { + uid: string; +} + +interface OpenDashboardOptional { + timeRange?: TimeRangeConfig; +} + +export type PartialOpenDashboardConfig = Partial & OpenDashboardOptional; +export type OpenDashboardConfig = OpenDashboardDefault & OpenDashboardOptional; + +// @todo this actually returns type `Cypress.Chainable` +export const openDashboard = (config?: PartialOpenDashboardConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }: any) => { + const fullConfig: OpenDashboardConfig = { + uid: lastAddedDashboardUid, + ...config, + }; + + const { timeRange, uid } = fullConfig; + + e2e.pages.Dashboard.visit(uid); + + if (timeRange) { + setDashboardTimeRange(timeRange); + } + + // @todo remove `wrap` when possible + return e2e().wrap({ config: fullConfig }, { log: false }); + }); diff --git a/packages/grafana-e2e/src/flows/openPanelMenuItem.ts b/packages/grafana-e2e/src/flows/openPanelMenuItem.ts new file mode 100644 index 0000000..5d767ea --- /dev/null +++ b/packages/grafana-e2e/src/flows/openPanelMenuItem.ts @@ -0,0 +1,12 @@ +import { e2e } from '../index'; + +export enum PanelMenuItems { + Edit = 'Edit', + Inspect = 'Inspect', +} + +export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title') => { + e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click(); + + e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click(); +}; diff --git a/packages/grafana-e2e/src/flows/revertAllChanges.ts b/packages/grafana-e2e/src/flows/revertAllChanges.ts new file mode 100644 index 0000000..8fda82d --- /dev/null +++ b/packages/grafana-e2e/src/flows/revertAllChanges.ts @@ -0,0 +1,8 @@ +import { e2e } from '../index'; + +export const revertAllChanges = () => { + e2e.getScenarioContext().then(({ addedDashboards, addedDataSources }: any) => { + addedDashboards.forEach((dashboard: any) => e2e.flows.deleteDashboard({ ...dashboard, quick: true })); + addedDataSources.forEach((dataSource: any) => e2e.flows.deleteDataSource({ ...dataSource, quick: true })); + }); +}; diff --git a/packages/grafana-e2e/src/flows/saveDashboard.ts b/packages/grafana-e2e/src/flows/saveDashboard.ts new file mode 100644 index 0000000..37d5238 --- /dev/null +++ b/packages/grafana-e2e/src/flows/saveDashboard.ts @@ -0,0 +1,9 @@ +import { e2e } from '../index'; + +export const saveDashboard = () => { + e2e.components.PageToolbar.item('Save dashboard').click(); + + e2e.pages.SaveDashboardModal.save().click(); + + e2e.flows.assertSuccessNotification(); +}; diff --git a/packages/grafana-e2e/src/flows/selectOption.ts b/packages/grafana-e2e/src/flows/selectOption.ts new file mode 100644 index 0000000..0a9e505 --- /dev/null +++ b/packages/grafana-e2e/src/flows/selectOption.ts @@ -0,0 +1,39 @@ +import { e2e } from '../index'; + +export interface SelectOptionConfig { + clickToOpen?: boolean; + container: any; + forceClickOption?: boolean; + optionText: string | RegExp; +} + +// @todo this actually returns type `Cypress.Chainable` +export const selectOption = (config: SelectOptionConfig): any => { + const fullConfig: SelectOptionConfig = { + clickToOpen: true, + forceClickOption: false, + ...config, + }; + + const { clickToOpen, container, forceClickOption, optionText } = fullConfig; + + return container.within(() => { + if (clickToOpen) { + e2e().get('[class$="-input-suffix"]').click(); + } + + e2e.components.Select.option() + .filter((_, { textContent }) => { + if (textContent === null) { + return false; + } else if (typeof optionText === 'string') { + return textContent.includes(optionText); + } else { + return optionText.test(textContent); + } + }) + .scrollIntoView() + .click({ force: forceClickOption }); + e2e().root().scrollIntoView(); + }); +}; diff --git a/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts b/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts new file mode 100644 index 0000000..9b04412 --- /dev/null +++ b/packages/grafana-e2e/src/flows/setDashboardTimeRange.ts @@ -0,0 +1,7 @@ +import { e2e } from '../index'; +import { setTimeRange, TimeRangeConfig } from './setTimeRange'; + +export { TimeRangeConfig }; + +export const setDashboardTimeRange = (config: TimeRangeConfig) => + e2e.components.PageToolbar.container().within(() => setTimeRange(config)); diff --git a/packages/grafana-e2e/src/flows/setTimeRange.ts b/packages/grafana-e2e/src/flows/setTimeRange.ts new file mode 100644 index 0000000..ec78123 --- /dev/null +++ b/packages/grafana-e2e/src/flows/setTimeRange.ts @@ -0,0 +1,29 @@ +import { e2e } from '../index'; +import { selectOption } from './selectOption'; + +export interface TimeRangeConfig { + from: string; + to: string; + zone?: string; +} + +export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => { + e2e().get('[aria-label="TimePicker Open Button"]').click(); + + if (zone) { + e2e().contains('button', 'Change time zone').click(); + + selectOption({ + clickToOpen: false, + container: e2e.components.TimeZonePicker.container(), + optionText: zone, + }); + } + + // For smaller screens + e2e().get('[aria-label="TimePicker absolute time range"]').click(); + + e2e().get('[aria-label="TimePicker from field"]').clear().type(from); + e2e().get('[aria-label="TimePicker to field"]').clear().type(to); + e2e().get('[aria-label="TimePicker submit button"]').click(); +}; diff --git a/packages/grafana-e2e/src/index.ts b/packages/grafana-e2e/src/index.ts new file mode 100644 index 0000000..87a0a6a --- /dev/null +++ b/packages/grafana-e2e/src/index.ts @@ -0,0 +1,28 @@ +/** + * A library for writing end-to-end tests for Grafana and its ecosystem. + * + * @packageDocumentation + */ +import { e2eScenario, ScenarioArguments } from './support/scenario'; +import { getScenarioContext, setScenarioContext } from './support/scenarioContext'; +import { e2eFactory } from './support'; +import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors'; +import * as flows from './flows'; +import * as typings from './typings'; + +const e2eObject = { + env: (args: string) => Cypress.env(args), + config: () => Cypress.config(), + blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob), + imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url), + scenario: (args: ScenarioArguments) => e2eScenario(args), + pages: e2eFactory({ selectors: selectors.pages }), + typings, + components: e2eFactory({ selectors: selectors.components }), + flows, + getScenarioContext, + setScenarioContext, + getSelectors: (selectors: E2ESelectors) => e2eFactory({ selectors }), +}; + +export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject); diff --git a/packages/grafana-e2e/src/support/index.ts b/packages/grafana-e2e/src/support/index.ts new file mode 100644 index 0000000..1ebbc3f --- /dev/null +++ b/packages/grafana-e2e/src/support/index.ts @@ -0,0 +1,4 @@ +export * from './localStorage'; +export * from './scenarioContext'; +export * from './selector'; +export * from './types'; diff --git a/packages/grafana-e2e/src/support/localStorage.ts b/packages/grafana-e2e/src/support/localStorage.ts new file mode 100644 index 0000000..eec9955 --- /dev/null +++ b/packages/grafana-e2e/src/support/localStorage.ts @@ -0,0 +1,23 @@ +import { e2e } from '../index'; + +// @todo this actually returns type `Cypress.Chainable` +const get = (key: string): any => + e2e() + .wrap({ getLocalStorage: () => localStorage.getItem(key) }, { log: false }) + .invoke('getLocalStorage'); + +// @todo this actually returns type `Cypress.Chainable` +export const getLocalStorage = (key: string): any => + get(key).then((value: any) => { + if (value === null) { + return value; + } else { + return JSON.parse(value); + } + }); + +// @todo this actually returns type `Cypress.Chainable` +export const requireLocalStorage = (key: string): any => + get(key) // `getLocalStorage()` would turn 'null' into `null` + .should('not.equal', null) + .then((value: any) => JSON.parse(value as string)); diff --git a/packages/grafana-e2e/src/support/scenario.ts b/packages/grafana-e2e/src/support/scenario.ts new file mode 100644 index 0000000..1a499c8 --- /dev/null +++ b/packages/grafana-e2e/src/support/scenario.ts @@ -0,0 +1,46 @@ +import { e2e } from '../'; + +export interface ScenarioArguments { + describeName: string; + itName: string; + scenario: Function; + skipScenario?: boolean; + addScenarioDataSource?: boolean; + addScenarioDashBoard?: boolean; +} + +export const e2eScenario = ({ + describeName, + itName, + scenario, + skipScenario = false, + addScenarioDataSource = false, + addScenarioDashBoard = false, +}: ScenarioArguments) => { + describe(describeName, () => { + if (skipScenario) { + it.skip(itName, () => scenario()); + } else { + before(() => e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'))); + + beforeEach(() => { + Cypress.Cookies.preserveOnce('grafana_session'); + + if (addScenarioDataSource) { + e2e.flows.addDataSource(); + } + if (addScenarioDashBoard) { + e2e.flows.addDashboard(); + } + }); + + afterEach(() => e2e.flows.revertAllChanges()); + after(() => e2e().clearCookies()); + + it(itName, () => scenario()); + + // @todo remove when possible: https://github.com/cypress-io/cypress/issues/2831 + it('temporary', () => {}); + } + }); +}; diff --git a/packages/grafana-e2e/src/support/scenarioContext.ts b/packages/grafana-e2e/src/support/scenarioContext.ts new file mode 100644 index 0000000..eeba377 --- /dev/null +++ b/packages/grafana-e2e/src/support/scenarioContext.ts @@ -0,0 +1,61 @@ +import { e2e } from '../index'; +import { DeleteDashboardConfig } from '../flows/deleteDashboard'; +import { DeleteDataSourceConfig } from '../flows/deleteDataSource'; + +export interface ScenarioContext { + addedDashboards: DeleteDashboardConfig[]; + addedDataSources: DeleteDataSourceConfig[]; + lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle` + lastAddedDashboardUid: string; + lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName` + lastAddedDataSourceId: string; + [key: string]: any; +} + +const scenarioContext: ScenarioContext = { + addedDashboards: [], + addedDataSources: [], + get lastAddedDashboard() { + return lastProperty(this.addedDashboards, 'title'); + }, + get lastAddedDashboardUid() { + return lastProperty(this.addedDashboards, 'uid'); + }, + get lastAddedDataSource() { + return lastProperty(this.addedDataSources, 'name'); + }, + get lastAddedDataSourceId() { + return lastProperty(this.addedDataSources, 'id'); + }, +}; + +const lastProperty = ( + items: T[], + key: K +) => items[items.length - 1]?.[key] ?? ''; + +// @todo this actually returns type `Cypress.Chainable` +export const getScenarioContext = (): any => + e2e() + .wrap( + { + getScenarioContext: (): ScenarioContext => ({ ...scenarioContext }), + }, + { log: false } + ) + .invoke({ log: false }, 'getScenarioContext'); + +// @todo this actually returns type `Cypress.Chainable` +export const setScenarioContext = (newContext: Partial): any => + e2e() + .wrap( + { + setScenarioContext: () => { + Object.entries(newContext).forEach(([key, value]) => { + scenarioContext[key] = value; + }); + }, + }, + { log: false } + ) + .invoke({ log: false }, 'setScenarioContext'); diff --git a/packages/grafana-e2e/src/support/selector.ts b/packages/grafana-e2e/src/support/selector.ts new file mode 100644 index 0000000..54aa938 --- /dev/null +++ b/packages/grafana-e2e/src/support/selector.ts @@ -0,0 +1,9 @@ +export interface SelectorApi { + fromAriaLabel: (selector: string) => string; + fromSelector: (selector: string) => string; +} + +export const Selector: SelectorApi = { + fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`, + fromSelector: (selector: string) => selector, +}; diff --git a/packages/grafana-e2e/src/support/types.ts b/packages/grafana-e2e/src/support/types.ts new file mode 100644 index 0000000..0476d31 --- /dev/null +++ b/packages/grafana-e2e/src/support/types.ts @@ -0,0 +1,123 @@ +import { CssSelector, FunctionSelector, Selectors, StringSelector, UrlSelector } from '@grafana/e2e-selectors'; +import { e2e } from '../index'; +import { Selector } from './selector'; +import { fromBaseUrl } from './url'; + +export type VisitFunction = (args?: string) => Cypress.Chainable; +export type E2EVisit = { visit: VisitFunction }; +export type E2EFunction = ((text?: string, options?: CypressOptions) => Cypress.Chainable>) & + E2EFunctionWithOnlyOptions; +export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.Chainable>; + +export type TypeSelectors = S extends StringSelector + ? E2EFunctionWithOnlyOptions + : S extends FunctionSelector + ? E2EFunction + : S extends CssSelector + ? E2EFunction + : S extends UrlSelector + ? E2EVisit & Omit, 'url'> + : S extends Record + ? E2EFunctions + : S; + +export type E2EFunctions = { + [P in keyof S]: TypeSelectors; +}; + +export type E2EObjects = E2EFunctions; + +export type E2EFactoryArgs = { selectors: S }; + +export type CypressOptions = Partial; + +const processSelectors = (e2eObjects: E2EFunctions, selectors: S): E2EFunctions => { + const logOutput = (data: any) => e2e().logToConsole('Retrieving Selector:', data); + const keys = Object.keys(selectors); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + const value = selectors[key]; + + if (key === 'url') { + // @ts-ignore + e2eObjects['visit'] = (args?: string) => { + let parsedUrl = ''; + if (typeof value === 'string') { + parsedUrl = fromBaseUrl(value); + } + + if (typeof value === 'function' && args) { + parsedUrl = fromBaseUrl(value(args)); + } + + e2e().logToConsole('Visiting', parsedUrl); + return e2e().visit(parsedUrl); + }; + + continue; + } + + if (typeof value === 'string') { + // @ts-ignore + e2eObjects[key] = (options?: CypressOptions) => { + logOutput(value); + return e2e().get(Selector.fromAriaLabel(value), options); + }; + + continue; + } + + if (typeof value === 'function') { + // @ts-ignore + e2eObjects[key] = function (textOrOptions?: string | CypressOptions, options?: CypressOptions) { + // the input can only be () + if (arguments.length === 0) { + const selector = value((undefined as unknown) as string); + + logOutput(selector); + return e2e().get(selector); + } + + // the input can be (text) or (options) + if (arguments.length === 1) { + if (typeof textOrOptions === 'string') { + const ariaText = value(textOrOptions); + const selector = Selector.fromAriaLabel(ariaText); + + logOutput(selector); + return e2e().get(selector); + } + const selector = value((undefined as unknown) as string); + + logOutput(selector); + return e2e().get(selector, textOrOptions); + } + + // the input can only be (text, options) + if (arguments.length === 2) { + const ariaText = value(textOrOptions as string); + const selector = Selector.fromAriaLabel(ariaText); + + logOutput(selector); + return e2e().get(selector, options); + } + }; + + continue; + } + + if (typeof value === 'object') { + // @ts-ignore + e2eObjects[key] = processSelectors({}, value); + } + } + + return e2eObjects; +}; + +export const e2eFactory = ({ selectors }: E2EFactoryArgs): E2EObjects => { + const e2eObjects: E2EFunctions = {} as E2EFunctions; + processSelectors(e2eObjects, selectors); + + return { ...e2eObjects }; +}; diff --git a/packages/grafana-e2e/src/support/url.ts b/packages/grafana-e2e/src/support/url.ts new file mode 100644 index 0000000..9d579d7 --- /dev/null +++ b/packages/grafana-e2e/src/support/url.ts @@ -0,0 +1,23 @@ +import { e2e } from '../index'; + +const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000'; + +export const fromBaseUrl = (url = '') => new URL(url, getBaseUrl()).href; + +export const getDashboardUid = (url: string): string => { + const matches = new URL(url).pathname.match(/\/d\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse uid from ${url}`); + } else { + return matches[1]; + } +}; + +export const getDataSourceId = (url: string): string => { + const matches = new URL(url).pathname.match(/\/edit\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse id from ${url}`); + } else { + return matches[1]; + } +}; diff --git a/packages/grafana-e2e/src/typings/index.ts b/packages/grafana-e2e/src/typings/index.ts new file mode 100644 index 0000000..d4a4805 --- /dev/null +++ b/packages/grafana-e2e/src/typings/index.ts @@ -0,0 +1 @@ +export { undo } from './undo'; diff --git a/packages/grafana-e2e/src/typings/undo.ts b/packages/grafana-e2e/src/typings/undo.ts new file mode 100644 index 0000000..9ee3c1c --- /dev/null +++ b/packages/grafana-e2e/src/typings/undo.ts @@ -0,0 +1,19 @@ +// https://nodejs.org/api/os.html#os_os_platform +enum Platform { + osx = 'darwin', + windows = 'win32', + linux = 'linux', + aix = 'aix', + freebsd = 'freebsd', + openbsd = 'openbsd', + sunos = 'sunos', +} + +export const undo = () => { + switch (Cypress.platform) { + case Platform.osx: + return '{cmd}z'; + default: + return '{ctrl}z'; + } +}; diff --git a/packages/grafana-e2e/test/cypress/integration/0.cli.ts b/packages/grafana-e2e/test/cypress/integration/0.cli.ts new file mode 100644 index 0000000..52d23a4 --- /dev/null +++ b/packages/grafana-e2e/test/cypress/integration/0.cli.ts @@ -0,0 +1,3 @@ +describe('CLI', () => { + it('compiles this file and runs it', () => {}); +}); diff --git a/packages/grafana-e2e/test/cypress/integration/1.api.ts b/packages/grafana-e2e/test/cypress/integration/1.api.ts new file mode 100644 index 0000000..00e790f --- /dev/null +++ b/packages/grafana-e2e/test/cypress/integration/1.api.ts @@ -0,0 +1,7 @@ +import { e2e } from '../../../dist'; + +describe('API', () => { + it('can be imported', () => { + expect(e2e).to.be.a('function'); + }); +}); diff --git a/packages/grafana-e2e/test/cypress/tsconfig.json b/packages/grafana-e2e/test/cypress/tsconfig.json new file mode 100644 index 0000000..1fa1340 --- /dev/null +++ b/packages/grafana-e2e/test/cypress/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["**/*.ts"], + "compilerOptions": { + "baseUrl": "../node_modules", + "types": ["cypress", "cypress-file-upload"] + } +} diff --git a/packages/grafana-e2e/tsconfig.build.json b/packages/grafana-e2e/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-e2e/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-e2e/tsconfig.json b/packages/grafana-e2e/tsconfig.json new file mode 100644 index 0000000..7e51e9f --- /dev/null +++ b/packages/grafana-e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types"], + "types": ["cypress"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts", "cypress/support/index.d.ts"] +} diff --git a/packages/grafana-runtime/.eslintrc b/packages/grafana-runtime/.eslintrc new file mode 100644 index 0000000..566825e --- /dev/null +++ b/packages/grafana-runtime/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime", "@grafana/data/*", "@grafana/ui/*", "@grafana/e2e/*"] }] + } +} diff --git a/packages/grafana-runtime/CHANGELOG.md b/packages/grafana-runtime/CHANGELOG.md new file mode 100644 index 0000000..556d424 --- /dev/null +++ b/packages/grafana-runtime/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-runtime/README.md b/packages/grafana-runtime/README.md new file mode 100644 index 0000000..c4732cc --- /dev/null +++ b/packages/grafana-runtime/README.md @@ -0,0 +1,5 @@ +# Grafana Runtime library + +> **@grafana/runtime is currently in BETA**. + +This package allows access to grafana services. It requires Grafana to be running already and the functions to be imported as externals. diff --git a/packages/grafana-runtime/api-extractor.json b/packages/grafana-runtime/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-runtime/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-runtime/index.js b/packages/grafana-runtime/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-runtime/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json new file mode 100644 index 0000000..9787b54 --- /dev/null +++ b/packages/grafana-runtime/package.json @@ -0,0 +1,47 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/runtime", + "version": "7.5.5", + "description": "Grafana Runtime Library", + "keywords": [ + "grafana", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-runtime" + }, + "main": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=runtime", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@grafana/data": "7.5.5", + "@grafana/ui": "7.5.5", + "systemjs": "0.20.19", + "systemjs-plugin-css": "0.1.37" + }, + "devDependencies": { + "@grafana/tsconfig": "^1.0.0-rc1", + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-node-resolve": "10.0.0", + "@types/jest": "26.0.15", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/systemjs": "^0.20.6", + "lodash": "4.17.21", + "pretty-format": "25.1.0", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "typescript": "4.1.2" + }, + "types": "src/index.ts" +} diff --git a/packages/grafana-runtime/rollup.config.ts b/packages/grafana-runtime/rollup.config.ts new file mode 100644 index 0000000..6b9900d --- /dev/null +++ b/packages/grafana-runtime/rollup.config.ts @@ -0,0 +1,34 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import sourceMaps from 'rollup-plugin-sourcemaps'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + file: `dist/index.${env}.js`, + name: libraryName, + format: 'cjs', + sourcemap: true, + exports: 'named', + globals: {}, + }, + ], + external: ['lodash', '@grafana/ui', '@grafana/data'], // Use Lodash from grafana + plugins: [ + commonjs({ + include: /node_modules/, + }), + resolve(), + sourceMaps(), + env === 'production' && terser(), + ], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts new file mode 100644 index 0000000..d88e059 --- /dev/null +++ b/packages/grafana-runtime/src/config.ts @@ -0,0 +1,122 @@ +import merge from 'lodash/merge'; +import { getTheme } from '@grafana/ui'; +import { + BuildInfo, + DataSourceInstanceSettings, + FeatureToggles, + GrafanaConfig, + GrafanaTheme, + GrafanaThemeType, + LicenseInfo, + PanelPluginMeta, + systemDateFormats, + SystemDateFormatSettings, +} from '@grafana/data'; + +export class GrafanaBootConfig implements GrafanaConfig { + datasources: { [str: string]: DataSourceInstanceSettings } = {}; + panels: { [key: string]: PanelPluginMeta } = {}; + minRefreshInterval = ''; + appUrl = ''; + appSubUrl = ''; + windowTitlePrefix = ''; + buildInfo: BuildInfo = {} as BuildInfo; + newPanelTitle = ''; + bootData: any; + externalUserMngLinkUrl = ''; + externalUserMngLinkName = ''; + externalUserMngInfo = ''; + allowOrgCreate = false; + disableLoginForm = false; + defaultDatasource = ''; + alertingEnabled = false; + alertingErrorOrTimeout = ''; + alertingNoDataOrNullValues = ''; + alertingMinInterval = 1; + authProxyEnabled = false; + exploreEnabled = false; + ldapEnabled = false; + sigV4AuthEnabled = false; + samlEnabled = false; + autoAssignOrg = true; + verifyEmailEnabled = false; + oauth: any; + disableUserSignUp = false; + loginHint: any; + passwordHint: any; + loginError: any; + navTree: any; + viewersCanEdit = false; + editorsCanAdmin = false; + disableSanitizeHtml = false; + theme: GrafanaTheme; + pluginsToPreload: string[] = []; + featureToggles: FeatureToggles = { + live: false, + meta: false, + ngalert: false, + panelLibrary: false, + reportVariables: false, + }; + licenseInfo: LicenseInfo = {} as LicenseInfo; + rendererAvailable = false; + http2Enabled = false; + dateFormats?: SystemDateFormatSettings; + sentry = { + enabled: false, + dsn: '', + customEndpoint: '', + sampleRate: 1, + }; + marketplaceUrl?: string; + expressionsEnabled = false; + customTheme?: any; + awsAllowedAuthProviders: string[] = []; + awsAssumeRoleEnabled = false; + + constructor(options: GrafanaBootConfig) { + this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); + + const defaults = { + datasources: {}, + windowTitlePrefix: 'Grafana - ', + panels: {}, + newPanelTitle: 'Panel Title', + playlist_timespan: '1m', + unsaved_changes_warning: true, + appUrl: '', + appSubUrl: '', + buildInfo: { + version: 'v1.0', + commit: '1', + env: 'production', + isEnterprise: false, + }, + viewersCanEdit: false, + editorsCanAdmin: false, + disableSanitizeHtml: false, + }; + + merge(this, defaults, options); + + if (this.dateFormats) { + systemDateFormats.update(this.dateFormats); + } + } +} + +const bootData = (window as any).grafanaBootData || { + settings: {}, + user: {}, + navTree: [], +}; + +const options = bootData.settings; +options.bootData = bootData; + +/** + * Use this to access the {@link GrafanaBootConfig} for the current running Grafana instance. + * + * @public + */ +export const config = new GrafanaBootConfig(options); diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts new file mode 100644 index 0000000..a8f1390 --- /dev/null +++ b/packages/grafana-runtime/src/index.ts @@ -0,0 +1,14 @@ +/** + * A library containing services, configurations etc. used to interact with the Grafana engine. + * + * @packageDocumentation + */ +export * from './services'; +export * from './config'; +export * from './types'; +export * from './measurement'; +export { loadPluginCss, SystemJS, PluginCssOptions } from './utils/plugin'; +export { reportMetaAnalytics } from './utils/analytics'; +export { logInfo, logDebug, logWarning, logError } from './utils/logging'; +export { DataSourceWithBackend, HealthCheckResult, HealthStatus } from './utils/DataSourceWithBackend'; +export { toDataQueryError, toDataQueryResponse, frameToMetricFindValue } from './utils/queryResponse'; diff --git a/packages/grafana-runtime/src/measurement/collector.test.ts b/packages/grafana-runtime/src/measurement/collector.test.ts new file mode 100644 index 0000000..31e6258 --- /dev/null +++ b/packages/grafana-runtime/src/measurement/collector.test.ts @@ -0,0 +1,250 @@ +import { MeasurementCollector } from './collector'; +import { MeasurementAction } from './types'; + +describe('MeasurementCollector', () => { + it('should collect values', () => { + const collector = new MeasurementCollector(); + collector.addBatch({ + measurements: [ + { + name: 'test', + labels: { host: 'a' }, + time: 100, + values: { + f0: 0, + f1: 1, + f2: 'hello', + }, + }, + { + name: 'test', + labels: { host: 'b' }, + time: 101, + values: { + f0: 0, + f1: 1, + f2: 'hello', + }, + config: { + f2: { + unit: 'mph', + }, + }, + }, + { + name: 'test', + time: 102, + labels: { host: 'a' }, // should append to first value + values: { + // note the missing values for f0/1 + f2: 'world', + }, + }, + ], + }); + + const frames = collector.getData(); + expect(frames.length).toEqual(2); + expect(frames[0]).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 100, + 102, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f0", + "type": "number", + "values": Array [ + 0, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f1", + "type": "number", + "values": Array [ + 1, + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f2", + "type": "string", + "values": Array [ + "hello", + "world", + ], + }, + ], + "meta": Object { + "custom": Object { + "labels": Object { + "host": "a", + }, + }, + }, + "name": "test", + "refId": undefined, + } + `); + expect(frames[1]).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 101, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "b", + }, + "name": "f0", + "type": "number", + "values": Array [ + 0, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "b", + }, + "name": "f1", + "type": "number", + "values": Array [ + 1, + ], + }, + Object { + "config": Object { + "unit": "mph", + }, + "labels": Object { + "host": "b", + }, + "name": "f2", + "type": "string", + "values": Array [ + "hello", + ], + }, + ], + "meta": Object { + "custom": Object { + "labels": Object { + "host": "b", + }, + }, + }, + "name": "test", + "refId": undefined, + } + `); + + collector.addBatch({ + action: MeasurementAction.Replace, + measurements: [ + { + name: 'test', + time: 105, + labels: { host: 'a' }, + values: { + f1: 10, + }, + }, + ], + }); + + const frames2 = collector.getData(); + expect(frames2.length).toEqual(2); + expect(frames2[0].length).toEqual(1); // not three! + expect(frames2[0]).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 105, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f0", + "type": "number", + "values": Array [ + undefined, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f1", + "type": "number", + "values": Array [ + 10, + ], + }, + Object { + "config": Object {}, + "labels": Object { + "host": "a", + }, + "name": "f2", + "type": "string", + "values": Array [ + undefined, + ], + }, + ], + "meta": Object { + "custom": Object { + "labels": Object { + "host": "a", + }, + }, + }, + "name": "test", + "refId": undefined, + } + `); + + collector.addBatch({ + action: MeasurementAction.Clear, + measurements: [], + }); + expect(collector.getData().length).toEqual(0); + }); +}); diff --git a/packages/grafana-runtime/src/measurement/collector.ts b/packages/grafana-runtime/src/measurement/collector.ts new file mode 100644 index 0000000..f84070e --- /dev/null +++ b/packages/grafana-runtime/src/measurement/collector.ts @@ -0,0 +1,214 @@ +import { + CircularDataFrame, + Labels, + formatLabels, + FieldType, + DataFrame, + matchAllLabels, + parseLabels, + CircularVector, + ArrayVector, +} from '@grafana/data'; +import { Measurement, MeasurementBatch, LiveMeasurements, MeasurementsQuery, MeasurementAction } from './types'; + +interface MeasurementCacheConfig { + append?: 'head' | 'tail'; + capacity?: number; +} + +/** This is a cache scoped to a the measurement name + * + * @alpha -- experimental + */ +export class MeasurementCache { + readonly frames: Record = {}; // key is the labels + + constructor(public name: string, private config: MeasurementCacheConfig) { + if (!this.config) { + this.config = { + append: 'tail', + capacity: 600, // Default capacity 10min @ 1hz + }; + } + } + + getFrames(match?: Labels): DataFrame[] { + const frames = Object.values(this.frames); + if (!match) { + return frames; + } + return frames.filter((f) => { + return matchAllLabels(match, f.meta?.custom?.labels); + }); + } + + addMeasurement(m: Measurement, action: MeasurementAction): DataFrame { + const key = m.labels ? formatLabels(m.labels) : ''; + let frame = this.frames[key]; + + if (!frame) { + frame = new CircularDataFrame(this.config); + frame.name = this.name; + frame.addField({ + name: 'time', + type: FieldType.time, + }); + + for (const [key, value] of Object.entries(m.values)) { + frame.addFieldFor(value, key).labels = m.labels; + } + + frame.meta = { + custom: { + labels: m.labels, + }, + }; + + this.frames[key] = frame; + } + + // Clear existing values + if (action === MeasurementAction.Replace) { + for (const field of frame.fields) { + (field.values as ArrayVector).buffer.length = 0; // same buffer, but reset to empty length + } + } + + // Add the timestamp + frame.fields[0].values.add(m.time || Date.now()); + + // Attach field config to the current fields + if (m.config) { + for (const [key, value] of Object.entries(m.config)) { + const f = frame.fields.find((f) => f.name === key); + if (f) { + f.config = value; + } + } + } + + // Append all values (a row) + for (const [key, value] of Object.entries(m.values)) { + const existingField = frame.fields.find((v) => v.name === key); + if (!existingField) { + const f = frame.addFieldFor(value, key); + f.labels = m.labels; + f.values.add(value); + } else { + existingField.values.add(value); + } + } + + // Make sure all fields have the same length + frame.validate(); + return frame; + } +} + +/** + * @alpha -- experimental + */ +export class MeasurementCollector implements LiveMeasurements { + measurements = new Map(); + config: MeasurementCacheConfig = { + append: 'tail', + capacity: 600, // Default capacity 10min @ 1hz + }; + + //------------------------------------------------------ + // Public + //------------------------------------------------------ + + getData(query?: MeasurementsQuery): DataFrame[] { + const { name, labels, fields } = query || {}; + + let data: DataFrame[] = []; + if (name) { + // for now we only match exact names + const m = this.measurements.get(name); + if (m) { + data = m.getFrames(labels); + } + } else { + for (const f of this.measurements.values()) { + data.push.apply(data, f.getFrames(labels)); + } + } + + if (fields && fields.length) { + let filtered: DataFrame[] = []; + for (const frame of data) { + const match = frame.fields.filter((f) => fields.includes(f.name)); + if (match.length > 0) { + filtered.push({ ...frame, fields: match }); // Copy the frame with fewer fields + } + } + } + return data; + } + + getDistinctNames(): string[] { + return Object.keys(this.measurements); + } + + getDistinctLabels(name: string): Labels[] { + const m = this.measurements.get(name); + if (m) { + return Object.keys(m.frames).map((k) => parseLabels(k)); + } + return []; + } + + setCapacity(size: number) { + this.config.capacity = size; + + // Now update all the circular buffers + for (const wrap of this.measurements.values()) { + for (const frame of Object.values(wrap.frames)) { + for (const field of frame.fields) { + (field.values as CircularVector).setCapacity(size); + } + } + } + } + + getCapacity() { + return this.config.capacity!; + } + + clear() { + this.measurements.clear(); + } + + //------------------------------------------------------ + // Collector + //------------------------------------------------------ + + addBatch = (batch: MeasurementBatch) => { + let action = batch.action ?? MeasurementAction.Append; + if (action === MeasurementAction.Clear) { + this.measurements.clear(); + action = MeasurementAction.Append; + } + + // Change the local buffer size + if (batch.capacity && batch.capacity !== this.config.capacity) { + this.setCapacity(batch.capacity); + } + + for (const measure of batch.measurements) { + const name = measure.name || ''; + let m = this.measurements.get(name); + if (!m) { + m = new MeasurementCache(name, this.config); + this.measurements.set(name, m); + } + if (measure.values) { + m.addMeasurement(measure, action); + } else { + console.log('invalid measurement', measure); + } + } + return this; + }; +} diff --git a/packages/grafana-runtime/src/measurement/index.ts b/packages/grafana-runtime/src/measurement/index.ts new file mode 100644 index 0000000..56180b6 --- /dev/null +++ b/packages/grafana-runtime/src/measurement/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './collector'; +export * from './query'; diff --git a/packages/grafana-runtime/src/measurement/query.ts b/packages/grafana-runtime/src/measurement/query.ts new file mode 100644 index 0000000..e4cbf0f --- /dev/null +++ b/packages/grafana-runtime/src/measurement/query.ts @@ -0,0 +1,77 @@ +import { + DataQueryResponse, + isLiveChannelMessageEvent, + isLiveChannelStatusEvent, + isValidLiveChannelAddress, + LiveChannelAddress, + LoadingState, +} from '@grafana/data'; +import { LiveMeasurements, MeasurementsQuery } from './types'; +import { getGrafanaLiveSrv } from '../services/live'; + +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** + * @alpha -- experimental + */ +export function getLiveMeasurements(addr: LiveChannelAddress): LiveMeasurements | undefined { + if (!isValidLiveChannelAddress(addr)) { + return undefined; + } + + const live = getGrafanaLiveSrv(); + if (!live) { + return undefined; + } + + const channel = live.getChannel(addr); + const getController = channel?.config?.getController; + return getController ? getController() : undefined; +} + +/** + * When you know the stream will be managed measurements + * + * @alpha -- experimental + */ +export function getLiveMeasurementsObserver( + addr: LiveChannelAddress, + requestId: string, + query?: MeasurementsQuery +): Observable { + const rsp: DataQueryResponse = { data: [] }; + if (!addr || !addr.path) { + return of(rsp); // Address not configured yet + } + + const live = getGrafanaLiveSrv(); + if (!live) { + // This will only happen with the feature flag is not enabled + rsp.error = { message: 'Grafana live is not initalized' }; + return of(rsp); + } + + rsp.key = requestId; + return live + .getChannel(addr) + .getStream() + .pipe( + map((evt) => { + if (isLiveChannelMessageEvent(evt)) { + rsp.data = evt.message.getData(query); + if (!rsp.data.length) { + // ?? skip when data is empty ??? + } + delete rsp.error; + rsp.state = LoadingState.Streaming; + } else if (isLiveChannelStatusEvent(evt)) { + if (evt.error != null) { + rsp.error = rsp.error; + rsp.state = LoadingState.Error; + } + } + return { ...rsp }; // send event on all status messages + }) + ); +} diff --git a/packages/grafana-runtime/src/measurement/types.ts b/packages/grafana-runtime/src/measurement/types.ts new file mode 100644 index 0000000..687e494 --- /dev/null +++ b/packages/grafana-runtime/src/measurement/types.ts @@ -0,0 +1,73 @@ +import { DataFrame, Labels, FieldConfig } from '@grafana/data'; + +/** + * the raw channel events are batches of Measurements + * + * @alpha -- experimental + */ +export interface Measurement { + name: string; + time?: number; // Missing will use the browser time + values: Record; + config?: Record; + labels?: Labels; +} + +/** + * @alpha -- experimental + */ +export enum MeasurementAction { + /** The measurements will be added to the client buffer */ + Append = 'append', + + /** The measurements will replace the client buffer */ + Replace = 'replace', + + /** All measurements will be removed from the client buffer before processing */ + Clear = 'clear', +} + +/** + * List of Measurements sent in a batch + * + * @alpha -- experimental + */ +export interface MeasurementBatch { + /** + * The default action is to append values to the client buffer + */ + action?: MeasurementAction; + + /** + * List of measurements to process + */ + measurements: Measurement[]; + + /** + * This will set the capacity on the client buffer for everything + * in the measurement channel + */ + capacity?: number; +} + +/** + * @alpha -- experimental + */ +export interface MeasurementsQuery { + name?: string; + labels?: Labels; + fields?: string[]; // only include the fields with these names +} + +/** + * Channels that receive Measurements can collect them into frames + * + * @alpha -- experimental + */ +export interface LiveMeasurements { + getData(query?: MeasurementsQuery): DataFrame[]; + getDistinctNames(): string[]; + getDistinctLabels(name: string): Labels[]; + setCapacity(size: number): void; + getCapacity(): number; +} diff --git a/packages/grafana-runtime/src/services/AngularLoader.ts b/packages/grafana-runtime/src/services/AngularLoader.ts new file mode 100644 index 0000000..c7beac4 --- /dev/null +++ b/packages/grafana-runtime/src/services/AngularLoader.ts @@ -0,0 +1,87 @@ +/** + * Used to enable rendering of Angular components within a + * React component without losing proper typings. + * + * @example + * ```typescript + * class Component extends PureComponent { + * element: HTMLElement; + * angularComponent: AngularComponent; + * + * componentDidMount() { + * const template = '' // angular template here; + * const scopeProps = { ctrl: angularController }; // angular scope properties here + * const loader = getAngularLoader(); + * this.angularComponent = loader.load(this.element, scopeProps, template); + * } + * + * componentWillUnmount() { + * if (this.angularComponent) { + * this.angularComponent.destroy(); + * } + * } + * + * render() { + * return ( + *
(this.element = element)} /> + * ); + * } + * } + * ``` + * + * @public + */ +export interface AngularComponent { + /** + * Should be called when the React component will unmount. + */ + destroy(): void; + /** + * Can be used to trigger a re-render of the Angular component. + */ + digest(): void; + /** + * Used to access the Angular scope from the React component. + */ + getScope(): any; +} + +/** + * Used to load an Angular component from the context of a React component. + * Please see the {@link AngularComponent} for a proper example. + * + * @public + */ +export interface AngularLoader { + /** + * + * @param elem - the element that the Angular component will be loaded into. + * @param scopeProps - values that will be accessed via the Angular scope. + * @param template - template used by the Angular component. + */ + load(elem: any, scopeProps: any, template: string): AngularComponent; +} + +let instance: AngularLoader; + +/** + * Used during startup by Grafana to set the AngularLoader so it is available + * via the {@link getAngularLoader} to the rest of the application. + * + * @internal + */ +export function setAngularLoader(v: AngularLoader) { + instance = v; +} + +/** + * Used to retrieve the {@link AngularLoader} that enables the use of Angular + * components within a React component. + * + * Please see the {@link AngularComponent} for a proper example. + * + * @public + */ +export function getAngularLoader(): AngularLoader { + return instance; +} diff --git a/packages/grafana-runtime/src/services/EchoSrv.ts b/packages/grafana-runtime/src/services/EchoSrv.ts new file mode 100644 index 0000000..098051d --- /dev/null +++ b/packages/grafana-runtime/src/services/EchoSrv.ts @@ -0,0 +1,140 @@ +/** + * Describes a size with width/height + * + * @public + */ +export interface SizeMeta { + width: number; + height: number; +} + +/** + * Describes the meta information that are sent together with each event. + * + * @public + */ +export interface EchoMeta { + screenSize: SizeMeta; + windowSize: SizeMeta; + userAgent: string; + url?: string; + /** + * A unique browser session + */ + sessionId: string; + /** + * The current users username used to login into Grafana e.g. email. + */ + userLogin: string; + /** + * The current users unique identifier. + */ + userId: number; + /** + * True when user is logged in into Grafana. + */ + userSignedIn: boolean; + /** + * A millisecond epoch + */ + ts: number; + /** + * A highres timestamp since navigation start + */ + timeSinceNavigationStart: number; +} + +/** + * Describes echo backends that can be registered to receive of events. + * + * @public + */ +export interface EchoBackend { + options: O; + supportedEvents: EchoEventType[]; + flush: () => void; + addEvent: (event: T) => void; +} + +/** + * Describes an echo event. + * + * @public + */ +export interface EchoEvent { + type: EchoEventType; + /** + * Event payload containing event specific data. + */ + payload: P; + meta: EchoMeta; +} + +/** + * Supported echo event types that can be sent via the {@link EchoSrv}. + * + * @public + */ +export enum EchoEventType { + Performance = 'performance', + MetaAnalytics = 'meta-analytics', + Sentry = 'sentry', +} + +/** + * Used to send events to all the registered backends. This should be accessed via the + * {@link getEchoSrv} function. Will, by default, flush events to the backends every + * 10s or when the flush function is triggered. + * + * @public + */ +export interface EchoSrv { + /** + * Call this to flush current events to the echo backends. + */ + flush(): void; + /** + * Add a new echo backend to the list of backends that will receive events. + */ + addBackend(backend: EchoBackend): void; + /** + * Call this to add event that will be sent to the echo backends upon next + * flush. + * + * @param event - Object containing event information. + * @param meta - Object that will extend/override the default meta object. + */ + addEvent(event: Omit, meta?: {}): void; +} + +let singletonInstance: EchoSrv; + +/** + * Used during startup by Grafana to set the EchoSrv so it is available + * via the {@link getEchoSrv} to the rest of the application. + * + * @internal + */ +export function setEchoSrv(instance: EchoSrv) { + singletonInstance = instance; +} + +/** + * Used to retrieve the {@link EchoSrv} that can be used to report events to registered + * echo backends. + * + * @public + */ +export function getEchoSrv(): EchoSrv { + return singletonInstance; +} + +/** + * Used to register echo backends that will receive Grafana echo events during application + * runtime. + * + * @public + */ +export const registerEchoBackend = (backend: EchoBackend) => { + getEchoSrv().addBackend(backend); +}; diff --git a/packages/grafana-runtime/src/services/LocationSrv.ts b/packages/grafana-runtime/src/services/LocationSrv.ts new file mode 100644 index 0000000..c1cdd03 --- /dev/null +++ b/packages/grafana-runtime/src/services/LocationSrv.ts @@ -0,0 +1,75 @@ +/** + * Passed as options to the {@link LocationSrv} to describe how the automatically navigation + * should be performed. + * + * @public + */ +import { UrlQueryMap } from '@grafana/data'; + +/** + * @public + */ +export interface LocationUpdate { + /** + * Target path where you automatically wants to navigate the user. + */ + path?: string; + + /** + * Specify this value if you want to add values to the query string of the URL. + */ + query?: UrlQueryMap; + + /** + * If set to true, the query argument will be added to the existing URL. + */ + partial?: boolean; + + /** + * Used internally to sync the Redux state from Angular to make sure that the Redux location + * state is in sync when navigating using the Angular router. + * + * @remarks + * Do not change this unless you are the Angular router. + * + * @internal + */ + routeParams?: UrlQueryMap; + + /* + * If set to true, this will replace URL state (ie. cause no new browser history). + */ + replace?: boolean; +} + +/** + * If you need to automatically navigate the user to a new place in the application this should + * be done via the LocationSrv and it will make sure to update the application state accordingly. + * + * @public + */ +export interface LocationSrv { + update(options: LocationUpdate): void; +} + +let singletonInstance: LocationSrv; + +/** + * Used during startup by Grafana to set the LocationSrv so it is available + * via the {@link getLocationSrv} to the rest of the application. + * + * @internal + */ +export function setLocationSrv(instance: LocationSrv) { + singletonInstance = instance; +} + +/** + * Used to retrieve the {@link LocationSrv} that can be used to automatically navigate + * the user to a new place in Grafana. + * + * @public + */ +export function getLocationSrv(): LocationSrv { + return singletonInstance; +} diff --git a/packages/grafana-runtime/src/services/backendSrv.ts b/packages/grafana-runtime/src/services/backendSrv.ts new file mode 100644 index 0000000..734980a --- /dev/null +++ b/packages/grafana-runtime/src/services/backendSrv.ts @@ -0,0 +1,187 @@ +import { Observable } from 'rxjs'; + +/** + * Used to initiate a remote call via the {@link BackendSrv} + * + * @public + */ +export type BackendSrvRequest = { + /** + * Request URL + */ + url: string; + + /** + * Number of times to retry the remote call if it fails. + */ + retry?: number; + + /** + * HTTP headers that should be passed along with the remote call. + * Please have a look at {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API} + * for supported headers. + */ + headers?: Record; + + /** + * HTTP verb to perform in the remote call GET, POST, PUT etc. + */ + method?: string; + + /** + * Set to false an success application alert box will not be shown for successful PUT, DELETE, POST requests + */ + showSuccessAlert?: boolean; + + /** + * Set to false to not show an application alert box for request errors + */ + showErrorAlert?: boolean; + + /** + * Provided by the initiator to identify a particular remote call. An example + * of this is when a datasource plugin triggers a query. If the request id already + * exist the backendSrv will try to cancel and replace the previous call with the + * new one. + */ + requestId?: string; + + /** + * Set to to true to not include call in query inspector + */ + hideFromInspector?: boolean; + + /** + * The data to send + */ + data?: any; + + /** + * Query params + */ + params?: Record; + + /** + * Define how the response object should be parsed. See: + * + * https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Sending_and_Receiving_Binary_Data + * + * By default values are json parsed from text + */ + responseType?: 'json' | 'text' | 'arraybuffer' | 'blob'; + + /** + * The credentials read-only property of the Request interface indicates whether the user agent should send cookies from the other domain in the case of cross-origin requests. + */ + credentials?: RequestCredentials; + + /** + * @deprecated withCredentials is deprecated in favor of credentials + */ + withCredentials?: boolean; +}; + +/** + * Response for fetch function in {@link BackendSrv} + * + * @public + */ +export interface FetchResponse { + data: T; + readonly status: number; + readonly statusText: string; + readonly ok: boolean; + readonly headers: Headers; + readonly redirected: boolean; + readonly type: ResponseType; + readonly url: string; + readonly config: BackendSrvRequest; +} + +/** + * Error type for fetch function in {@link BackendSrv} + * + * @public + */ +export interface FetchErrorDataProps { + message?: string; + status?: string; + error?: string | any; +} + +/** + * Error type for fetch function in {@link BackendSrv} + * + * @public + */ +export interface FetchError { + status: number; + statusText?: string; + data: T; + cancelled?: boolean; + isHandled?: boolean; + config: BackendSrvRequest; +} + +/** + * Used to communicate via http(s) to a remote backend such as the Grafana backend, + * a datasource etc. The BackendSrv is using the {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API} + * under the hood to handle all the communication. + * + * The request function can be used to perform a remote call by specifying a {@link BackendSrvRequest}. + * To make the BackendSrv a bit easier to use we have added a couple of shorthand functions that will + * use default values executing the request. + * + * @remarks + * By default, Grafana displays an error message alert if the remote call fails. To prevent this from + * happening `showErrorAlert = true` on the options object. + * + * @public + */ +export interface BackendSrv { + get(url: string, params?: any, requestId?: string): Promise; + delete(url: string): Promise; + post(url: string, data?: any): Promise; + patch(url: string, data?: any): Promise; + put(url: string, data?: any): Promise; + + /** + * @deprecated Use the fetch function instead. If you prefer to work with a promise + * call the toPromise() function on the Observable returned by fetch. + */ + request(options: BackendSrvRequest): Promise; + + /** + * @deprecated Use the fetch function instead + * Special function used to communicate with datasources that will emit core + * events that the Grafana QueryInspector and QueryEditor is listening for to be able + * to display datasource query information. Can be skipped by adding `option.silent` + * when initializing the request. + */ + datasourceRequest(options: BackendSrvRequest): Promise; + + /** + * Observable http request interface + */ + fetch(options: BackendSrvRequest): Observable>; +} + +let singletonInstance: BackendSrv; + +/** + * Used during startup by Grafana to set the BackendSrv so it is available + * via the {@link getBackendSrv} to the rest of the application. + * + * @internal + */ +export const setBackendSrv = (instance: BackendSrv) => { + singletonInstance = instance; +}; + +/** + * Used to retrieve the {@link BackendSrv} that can be used to communicate + * via http(s) to a remote backend such as the Grafana backend, a datasource etc. + * + * @public + */ +export const getBackendSrv = (): BackendSrv => singletonInstance; diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts new file mode 100644 index 0000000..2509429 --- /dev/null +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -0,0 +1,79 @@ +import { ScopedVars, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; + +/** + * This is the entry point for communicating with a datasource that is added as + * a plugin (both external and internal). Via this service you will get access + * to the {@link @grafana/data#DataSourceApi | DataSourceApi} that have a rich API for + * communicating with the datasource. + * + * @public + */ +export interface DataSourceSrv { + /** + * @param name - name of the datasource plugin you want to use. + * @param scopedVars - variables used to interpolate a templated passed as name. + */ + get(name?: string | null, scopedVars?: ScopedVars): Promise; + + /** + * Get a list of data sources + */ + getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[]; + + /** + * Get settings and plugin metadata by name or uid + */ + getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined; +} + +/** @public */ +export interface GetDataSourceListFilters { + /** Include mixed deta source by setting this to true */ + mixed?: boolean; + + /** Only return data sources that support metrics response */ + metrics?: boolean; + + /** Only return data sources that support tracing response */ + tracing?: boolean; + + /** Only return data sources that support annotations */ + annotations?: boolean; + + /** + * By default only data sources that can be queried will be returned. Meaning they have tracing, + * metrics, logs or annotations flag set in plugin.json file + * */ + all?: boolean; + + /** Set to true to return dashboard data source */ + dashboard?: boolean; + + /** Set to true to return data source variables */ + variables?: boolean; + + /** filter list by plugin */ + pluginId?: string; +} + +let singletonInstance: DataSourceSrv; + +/** + * Used during startup by Grafana to set the DataSourceSrv so it is available + * via the {@link getDataSourceSrv} to the rest of the application. + * + * @internal + */ +export function setDataSourceSrv(instance: DataSourceSrv) { + singletonInstance = instance; +} + +/** + * Used to retrieve the {@link DataSourceSrv} that is the entry point for communicating with + * a datasource that is added as a plugin (both external and internal). + * + * @public + */ +export function getDataSourceSrv(): DataSourceSrv { + return singletonInstance; +} diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts new file mode 100644 index 0000000..44182e2 --- /dev/null +++ b/packages/grafana-runtime/src/services/index.ts @@ -0,0 +1,8 @@ +export * from './backendSrv'; +export * from './AngularLoader'; +export * from './dataSourceSrv'; +export * from './LocationSrv'; +export * from './EchoSrv'; +export * from './templateSrv'; +export * from './legacyAngularInjector'; +export * from './live'; diff --git a/packages/grafana-runtime/src/services/legacyAngularInjector.ts b/packages/grafana-runtime/src/services/legacyAngularInjector.ts new file mode 100644 index 0000000..4c581ff --- /dev/null +++ b/packages/grafana-runtime/src/services/legacyAngularInjector.ts @@ -0,0 +1,23 @@ +import { auto } from 'angular'; + +let singleton: auto.IInjectorService; + +/** + * Used during startup by Grafana to temporarily expose the angular injector to + * pure javascript plugins using {@link getLegacyAngularInjector}. + * + * @internal + */ +export const setLegacyAngularInjector = (instance: auto.IInjectorService) => { + singleton = instance; +}; + +/** + * WARNING: this function provides a temporary way for plugins to access anything in the + * angular injector. While the migration from angular to react continues, there are a few + * options that do not yet have good alternatives. Note that use of this function will + * be removed in the future. + * + * @beta + */ +export const getLegacyAngularInjector = (): auto.IInjectorService => singleton; diff --git a/packages/grafana-runtime/src/services/live.ts b/packages/grafana-runtime/src/services/live.ts new file mode 100644 index 0000000..76ec6b6 --- /dev/null +++ b/packages/grafana-runtime/src/services/live.ts @@ -0,0 +1,47 @@ +import { LiveChannel, LiveChannelAddress } from '@grafana/data'; +import { Observable } from 'rxjs'; + +/** + * @alpha -- experimental + */ +export interface GrafanaLiveSrv { + /** + * Is the server currently connected + */ + isConnected(): boolean; + + /** + * Listen for changes to the main service + */ + getConnectionState(): Observable; + + /** + * Get a channel. If the scope, namespace, or path is invalid, a shutdown + * channel will be returned with an error state indicated in its status. + * + * This is a singleton instance that stays active until explicitly shutdown. + * Multiple requests for this channel will return the same object until + * the channel is shutdown + */ + getChannel(address: LiveChannelAddress): LiveChannel; +} + +let singletonInstance: GrafanaLiveSrv; + +/** + * Used during startup by Grafana to set the GrafanaLiveSrv so it is available + * via the {@link getGrafanaLiveSrv} to the rest of the application. + * + * @internal + */ +export const setGrafanaLiveSrv = (instance: GrafanaLiveSrv) => { + singletonInstance = instance; +}; + +/** + * Used to retrieve the GrafanaLiveSrv that allows you to subscribe to + * server side events and streams + * + * @alpha -- experimental + */ +export const getGrafanaLiveSrv = (): GrafanaLiveSrv => singletonInstance; diff --git a/packages/grafana-runtime/src/services/templateSrv.ts b/packages/grafana-runtime/src/services/templateSrv.ts new file mode 100644 index 0000000..962dacd --- /dev/null +++ b/packages/grafana-runtime/src/services/templateSrv.ts @@ -0,0 +1,40 @@ +import { VariableModel, ScopedVars } from '@grafana/data'; + +/** + * Via the TemplateSrv consumers get access to all the available template variables + * that can be used within the current active dashboard. + * + * For a more in-depth description visit: https://grafana.com/docs/grafana/latest/reference/templating + * @public + */ +export interface TemplateSrv { + /** + * List the dashboard variables + */ + getVariables(): VariableModel[]; + + /** + * Replace the values within the target string. See also {@link InterpolateFunction} + */ + replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string; +} + +let singletonInstance: TemplateSrv; + +/** + * Used during startup by Grafana to set the TemplateSrv so it is available + * via the {@link getTemplateSrv} to the rest of the application. + * + * @internal + */ +export const setTemplateSrv = (instance: TemplateSrv) => { + singletonInstance = instance; +}; + +/** + * Used to retrieve the {@link TemplateSrv} that can be used to fetch available + * template variables. + * + * @public + */ +export const getTemplateSrv = (): TemplateSrv => singletonInstance; diff --git a/packages/grafana-runtime/src/types/analytics.ts b/packages/grafana-runtime/src/types/analytics.ts new file mode 100644 index 0000000..767dc74 --- /dev/null +++ b/packages/grafana-runtime/src/types/analytics.ts @@ -0,0 +1,72 @@ +import { EchoEvent, EchoEventType } from '../services/EchoSrv'; + +/** + * Describes the basic dashboard information that can be passed as the meta + * analytics payload. + * + * @public + */ +export interface DashboardInfo { + dashboardId: number; + dashboardUid: string; + dashboardName: string; + folderName?: string; +} + +/** + * Describes the data request information passed as the meta analytics payload. + * + * @public + */ +export interface DataRequestInfo extends Partial { + datasourceName: string; + datasourceId: number; + datasourceType: string; + panelId?: number; + panelName?: string; + duration: number; + error?: string; + dataSize?: number; +} + +/** + * The meta analytics events that can be added to the echo service. + * + * @public + */ +export enum MetaAnalyticsEventName { + DashboardView = 'dashboard-view', + DataRequest = 'data-request', +} + +/** + * Describes the payload of a dashboard view event. + * + * @public + */ +export interface DashboardViewEventPayload extends DashboardInfo { + eventName: MetaAnalyticsEventName.DashboardView; +} + +/** + * Describes the payload of a data request event. + * + * @public + */ +export interface DataRequestEventPayload extends DataRequestInfo { + eventName: MetaAnalyticsEventName.DataRequest; +} + +/** + * Describes the meta analytics payload passed with the {@link MetaAnalyticsEvent} + * + * @public + */ +export type MetaAnalyticsEventPayload = DashboardViewEventPayload | DataRequestEventPayload; + +/** + * Describes meta analytics event with predefined {@link EchoEventType.EchoEventType} type. + * + * @public + */ +export interface MetaAnalyticsEvent extends EchoEvent {} diff --git a/packages/grafana-runtime/src/types/index.ts b/packages/grafana-runtime/src/types/index.ts new file mode 100644 index 0000000..77dc4f5 --- /dev/null +++ b/packages/grafana-runtime/src/types/index.ts @@ -0,0 +1 @@ +export * from './analytics'; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts new file mode 100644 index 0000000..9cc83b4 --- /dev/null +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts @@ -0,0 +1,76 @@ +import { BackendSrv, BackendSrvRequest } from 'src/services'; +import { DataSourceWithBackend } from './DataSourceWithBackend'; +import { DataSourceJsonData, DataQuery, DataSourceInstanceSettings, DataQueryRequest } from '@grafana/data'; +import { of } from 'rxjs'; + +class MyDataSource extends DataSourceWithBackend { + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + } +} + +const mockDatasourceRequest = jest.fn(); + +const backendSrv = ({ + fetch: (options: BackendSrvRequest) => { + return of(mockDatasourceRequest(options)); + }, +} as unknown) as BackendSrv; + +jest.mock('../services', () => ({ + getBackendSrv: () => backendSrv, + getDataSourceSrv: () => { + return { + getInstanceSettings: () => ({ id: 8674 }), + }; + }, +})); + +describe('DataSourceWithBackend', () => { + test('check the executed queries', () => { + const settings = { + name: 'test', + id: 1234, + jsonData: {}, + } as DataSourceInstanceSettings; + + mockDatasourceRequest.mockReset(); + mockDatasourceRequest.mockReturnValue(Promise.resolve({})); + const ds = new MyDataSource(settings); + + ds.query({ + maxDataPoints: 10, + intervalMs: 5000, + targets: [{ refId: 'A' }, { refId: 'B', datasource: 'sample' }], + } as DataQueryRequest); + + const mock = mockDatasourceRequest.mock; + expect(mock.calls.length).toBe(1); + + const args = mock.calls[0][0]; + expect(args).toMatchInlineSnapshot(` + Object { + "data": Object { + "queries": Array [ + Object { + "datasourceId": 1234, + "intervalMs": 5000, + "maxDataPoints": 10, + "refId": "A", + }, + Object { + "datasource": "sample", + "datasourceId": 8674, + "intervalMs": 5000, + "maxDataPoints": 10, + "refId": "B", + }, + ], + }, + "method": "POST", + "requestId": undefined, + "url": "/api/ds/query", + } + `); + }); +}); diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts new file mode 100644 index 0000000..6c942f7 --- /dev/null +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -0,0 +1,188 @@ +import { + DataSourceApi, + DataQueryRequest, + DataQueryResponse, + DataSourceInstanceSettings, + DataQuery, + DataSourceJsonData, + ScopedVars, +} from '@grafana/data'; +import { Observable, of } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; +import { getBackendSrv, getDataSourceSrv } from '../services'; +import { toDataQueryResponse } from './queryResponse'; + +const ExpressionDatasourceID = '__expr__'; + +/** + * Describes the current health status of a data source plugin. + * + * @public + */ +export enum HealthStatus { + Unknown = 'UNKNOWN', + OK = 'OK', + Error = 'ERROR', +} + +/** + * Describes the payload returned when checking the health of a data source + * plugin. + * + * @public + */ +export interface HealthCheckResult { + status: HealthStatus; + message: string; + details?: Record; +} + +/** + * Extend this class to implement a data source plugin that is depending on the Grafana + * backend API. + * + * @public + */ +export class DataSourceWithBackend< + TQuery extends DataQuery = DataQuery, + TOptions extends DataSourceJsonData = DataSourceJsonData +> extends DataSourceApi { + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + } + + /** + * Ideally final -- any other implementation may not work as expected + */ + query(request: DataQueryRequest): Observable { + const { intervalMs, maxDataPoints, range, requestId } = request; + let targets = request.targets; + + if (this.filterQuery) { + targets = targets.filter((q) => this.filterQuery!(q)); + } + + const queries = targets.map((q) => { + let datasourceId = this.id; + + if (q.datasource === ExpressionDatasourceID) { + return { + ...q, + datasourceId, + }; + } + + if (q.datasource) { + const ds = getDataSourceSrv().getInstanceSettings(q.datasource); + + if (!ds) { + throw new Error('Unknown Datasource: ' + q.datasource); + } + + datasourceId = ds.id; + } + + return { + ...this.applyTemplateVariables(q, request.scopedVars), + datasourceId, + intervalMs, + maxDataPoints, + }; + }); + + // Return early if no queries exist + if (!queries.length) { + return of({ data: [] }); + } + + const body: any = { queries }; + + if (range) { + body.range = range; + body.from = range.from.valueOf().toString(); + body.to = range.to.valueOf().toString(); + } + + return getBackendSrv() + .fetch({ + url: '/api/ds/query', + method: 'POST', + data: body, + requestId, + }) + .pipe( + map((rsp: any) => { + return toDataQueryResponse(rsp, queries as DataQuery[]); + }), + catchError((err) => { + return of(toDataQueryResponse(err)); + }) + ); + } + + /** + * Override to skip executing a query + * + * @returns false if the query should be skipped + * + * @virtual + */ + filterQuery?(query: TQuery): boolean; + + /** + * Override to apply template variables. The result is usually also `TQuery`, but sometimes this can + * be used to modify the query structure before sending to the backend. + * + * NOTE: if you do modify the structure or use template variables, alerting queries may not work + * as expected + * + * @virtual + */ + applyTemplateVariables(query: TQuery, scopedVars: ScopedVars): Record { + return query; + } + + /** + * Make a GET request to the datasource resource path + */ + async getResource(path: string, params?: any): Promise { + return getBackendSrv().get(`/api/datasources/${this.id}/resources/${path}`, params); + } + + /** + * Send a POST request to the datasource resource path + */ + async postResource(path: string, body?: any): Promise { + return getBackendSrv().post(`/api/datasources/${this.id}/resources/${path}`, { ...body }); + } + + /** + * Run the datasource healthcheck + */ + async callHealthCheck(): Promise { + return getBackendSrv() + .request({ method: 'GET', url: `/api/datasources/${this.id}/health`, showErrorAlert: false }) + .then((v) => { + return v as HealthCheckResult; + }) + .catch((err) => { + return err.data as HealthCheckResult; + }); + } + + /** + * Checks the plugin health + * see public/app/features/datasources/state/actions.ts for what needs to be returned here + */ + async testDatasource(): Promise { + return this.callHealthCheck().then((res) => { + if (res.status === HealthStatus.OK) { + return { + status: 'success', + message: res.message, + }; + } + throw new Error(res.message); + }); + } +} diff --git a/packages/grafana-runtime/src/utils/analytics.ts b/packages/grafana-runtime/src/utils/analytics.ts new file mode 100644 index 0000000..b3f090b --- /dev/null +++ b/packages/grafana-runtime/src/utils/analytics.ts @@ -0,0 +1,14 @@ +import { getEchoSrv, EchoEventType } from '../services/EchoSrv'; +import { MetaAnalyticsEvent, MetaAnalyticsEventPayload } from '../types/analytics'; + +/** + * Helper function to report meta analytics to the {@link EchoSrv}. + * + * @public + */ +export const reportMetaAnalytics = (payload: MetaAnalyticsEventPayload) => { + getEchoSrv().addEvent({ + type: EchoEventType.MetaAnalytics, + payload, + }); +}; diff --git a/packages/grafana-runtime/src/utils/logging.ts b/packages/grafana-runtime/src/utils/logging.ts new file mode 100644 index 0000000..0f8051a --- /dev/null +++ b/packages/grafana-runtime/src/utils/logging.ts @@ -0,0 +1,50 @@ +import { captureMessage, captureException, Severity as LogLevel } from '@sentry/browser'; +export { LogLevel }; + +// a bit stricter than what Sentry allows +type Contexts = Record>>; + +/** + * Log a message at INFO level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry + * + * @public + */ +export function logInfo(message: string, contexts?: Contexts) { + captureMessage(message, { + level: LogLevel.Info, + contexts, + }); +} + +/** + * Log a message at WARNING level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry + * + * @public + */ +export function logWarning(message: string, contexts?: Contexts) { + captureMessage(message, { + level: LogLevel.Warning, + contexts, + }); +} + +/** + * Log a message at DEBUG level. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry + * + * @public + */ +export function logDebug(message: string, contexts?: Contexts) { + captureMessage(message, { + level: LogLevel.Debug, + contexts, + }); +} + +/** + * Log an error. Depending on configuration might be forwarded to backend and logged to stdout or sent to Sentry + * + * @public + */ +export function logError(err: Error, contexts?: Contexts) { + captureException(err, { contexts }); +} diff --git a/packages/grafana-runtime/src/utils/plugin.ts b/packages/grafana-runtime/src/utils/plugin.ts new file mode 100644 index 0000000..5791dd0 --- /dev/null +++ b/packages/grafana-runtime/src/utils/plugin.ts @@ -0,0 +1,32 @@ +import { config } from '../config'; + +// @ts-ignore +import System from 'systemjs/dist/system.js'; + +/** + * Option to specify a plugin css that should be applied for the dark + * and the light theme. + * + * @public + */ +export interface PluginCssOptions { + light: string; + dark: string; +} + +/** + * @internal + */ +export const SystemJS = System; + +/** + * Use this to load css for a Grafana plugin by specifying a {@link PluginCssOptions} + * containing styling for the dark and the light theme. + * + * @param options - plugin styling for light and dark theme. + * @public + */ +export function loadPluginCss(options: PluginCssOptions): Promise { + const theme = config.bootData.user.lightTheme ? options.light : options.dark; + return SystemJS.import(`${theme}!css`); +} diff --git a/packages/grafana-runtime/src/utils/queryResponse.test.ts b/packages/grafana-runtime/src/utils/queryResponse.test.ts new file mode 100644 index 0000000..0adb915 --- /dev/null +++ b/packages/grafana-runtime/src/utils/queryResponse.test.ts @@ -0,0 +1,309 @@ +import { DataQuery, toDataFrameDTO, DataFrame } from '@grafana/data'; +import { toDataQueryResponse } from './queryResponse'; + +/* eslint-disable */ +const resp = { + data: { + results: { + A: { + refId: 'A', + series: null, + tables: null, + dataframes: [ + 'QVJST1cxAAD/////cAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAAAg////CAAAAAwAAAABAAAAQQAAAAUAAAByZWZJZAAAAED///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAgAAAHwAAAAEAAAAnv///xQAAABAAAAAQAAAAAAAAwFAAAAAAQAAAAQAAACM////CAAAABQAAAAIAAAAQS1zZXJpZXMAAAAABAAAAG5hbWUAAAAAAAAAAIb///8AAAIACAAAAEEtc2VyaWVzAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAKAUwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAABgAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAABYAAAABgAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAMAAAAAAAAAAAAAAAAgAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAQMC/OcElXhZAOAEFxCVeFkCwQtDGJV4WQCiEm8klXhZAoMVmzCVeFkAYBzLPJV4WAAAAAAAA8D8AAAAAAAA0QAAAAAAAgFZAAAAAAAAAPkAAAAAAAAAUQAAAAAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAMAAQAAAIABAAAAAAAAwAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAABQAAAAAgAAACgAAAAEAAAAIP///wgAAAAMAAAAAQAAAEEAAAAFAAAAcmVmSWQAAABA////CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAIAAAB8AAAABAAAAJ7///8UAAAAQAAAAEAAAAAAAAMBQAAAAAEAAAAEAAAAjP///wgAAAAUAAAACAAAAEEtc2VyaWVzAAAAAAQAAABuYW1lAAAAAAAAAACG////AAACAAgAAABBLXNlcmllcwAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAACgFMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAmAEAAEFSUk9XMQ==', + ], + }, + B: { + refId: 'B', + series: null, + tables: null, + dataframes: [ + 'QVJST1cxAAD/////cAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAAAg////CAAAAAwAAAABAAAAQgAAAAUAAAByZWZJZAAAAED///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAgAAAHwAAAAEAAAAnv///xQAAABAAAAAQAAAAAAAAwFAAAAAAQAAAAQAAACM////CAAAABQAAAAIAAAAQi1zZXJpZXMAAAAABAAAAG5hbWUAAAAAAAAAAIb///8AAAIACAAAAEItc2VyaWVzAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAKAUwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAHRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAdGltZQAAAAAAAAAA/////7gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAABgAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAABYAAAABgAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAMAAAAAAAAAAAAAAAAgAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAQMC/OcElXhZAOAEFxCVeFkCwQtDGJV4WQCiEm8klXhZAoMVmzCVeFkAYBzLPJV4WAAAAAAAA8D8AAAAAAAA0QAAAAAAAgFZAAAAAAAAAPkAAAAAAAAAUQAAAAAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAMAAQAAAIABAAAAAAAAwAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAABQAAAAAgAAACgAAAAEAAAAIP///wgAAAAMAAAAAQAAAEIAAAAFAAAAcmVmSWQAAABA////CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAIAAAB8AAAABAAAAJ7///8UAAAAQAAAAEAAAAAAAAMBQAAAAAEAAAAEAAAAjP///wgAAAAUAAAACAAAAEItc2VyaWVzAAAAAAQAAABuYW1lAAAAAAAAAACG////AAACAAgAAABCLXNlcmllcwAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAACgFMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAAB0aW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAHRpbWUAAAAAmAEAAEFSUk9XMQ==', + ], + }, + }, + }, +}; + +const resWithError = { + data: { + results: { + A: { + error: 'Hello Error', + series: null, + tables: null, + dataframes: [ + 'QVJST1cxAAD/////WAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAJwAAAADAAAATAAAACgAAAAEAAAAPP///wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABc////CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAHz///8IAAAANAAAACoAAAB7Im5vdGljZXMiOlt7InNldmVyaXR5IjoyLCJ0ZXh0IjoiVGV4dCJ9XX0AAAQAAABtZXRhAAAAAAEAAAAYAAAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAAA0wAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABwAAAG51bWJlcnMABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAgAHAAAAbnVtYmVycwAAAAAA/////4gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAQAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAAA4AAAAAgAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAIQBAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAADAAEAAABoAQAAAAAAAJAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAnAAAAAMAAABMAAAAKAAAAAQAAAA8////CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAFz///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAfP///wgAAAA0AAAAKgAAAHsibm90aWNlcyI6W3sic2V2ZXJpdHkiOjIsInRleHQiOiJUZXh0In1dfQAABAAAAG1ldGEAAAAAAQAAABgAAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAADTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAHAAAAbnVtYmVycwAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAACAAcAAABudW1iZXJzAIABAABBUlJPVzE=', + ], + }, + }, + }, +}; + +const emptyResults = { + data: { '': { refId: '', meta: null, series: null, tables: null, dataframes: null } }, +}; + +/* eslint-enable */ + +describe('Query Response parser', () => { + test('should parse output with dataframe', () => { + const res = toDataQueryResponse(resp); + const frames = res.data; + expect(frames).toHaveLength(2); + expect(frames[0].refId).toEqual('A'); + expect(frames[1].refId).toEqual('B'); + + const norm = frames.map((f) => toDataFrameDTO(f)); + expect(norm).toMatchInlineSnapshot(` + Array [ + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 1611767228473, + 1611767240473, + 1611767252473, + 1611767264473, + 1611767276473, + 1611767288473, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "A-series", + "type": "number", + "values": Array [ + 1, + 20, + 90, + 30, + 5, + 0, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": "A", + }, + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 1611767228473, + 1611767240473, + 1611767252473, + 1611767264473, + 1611767276473, + 1611767288473, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "B-series", + "type": "number", + "values": Array [ + 1, + 20, + 90, + 30, + 5, + 0, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": "B", + }, + ] + `); + }); + + test('should parse output with dataframe in order of queries', () => { + const queries: DataQuery[] = [{ refId: 'B' }, { refId: 'A' }]; + const res = toDataQueryResponse(resp, queries); + const frames = res.data; + expect(frames).toHaveLength(2); + expect(frames[0].refId).toEqual('B'); + expect(frames[1].refId).toEqual('A'); + + const norm = frames.map((f) => toDataFrameDTO(f)); + expect(norm).toMatchInlineSnapshot(` + Array [ + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 1611767228473, + 1611767240473, + 1611767252473, + 1611767264473, + 1611767276473, + 1611767288473, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "B-series", + "type": "number", + "values": Array [ + 1, + 20, + 90, + 30, + 5, + 0, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": "B", + }, + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "time", + "type": "time", + "values": Array [ + 1611767228473, + 1611767240473, + 1611767252473, + 1611767264473, + 1611767276473, + 1611767288473, + ], + }, + Object { + "config": Object {}, + "labels": undefined, + "name": "A-series", + "type": "number", + "values": Array [ + 1, + 20, + 90, + 30, + 5, + 0, + ], + }, + ], + "meta": undefined, + "name": undefined, + "refId": "A", + }, + ] + `); + }); + + test('processEmptyResults', () => { + const frames = toDataQueryResponse(emptyResults).data; + expect(frames.length).toEqual(0); + }); + + test('keeps query order', () => { + const resp = { + data: { + results: { + X: { + series: [ + { name: 'Requests/s', points: [[13.594958983547151, 1611839862951]], tables: null, dataframes: null }, + ], + }, + B: { + series: [ + { name: 'Requests/s', points: [[13.594958983547151, 1611839862951]], tables: null, dataframes: null }, + ], + }, + A: { + series: [ + { name: 'Requests/s', points: [[13.594958983547151, 1611839862951]], tables: null, dataframes: null }, + ], + }, + }, + }, + }; + + const queries: DataQuery[] = [{ refId: 'A' }, { refId: 'B' }]; + + const ids = (toDataQueryResponse(resp, queries).data as DataFrame[]).map((f) => f.refId); + expect(ids).toEqual(['A', 'B', 'X']); + }); + + test('resultWithError', () => { + // Generated from: + // qdr.Responses[q.GetRefID()] = backend.DataResponse{ + // Error: fmt.Errorf("an Error: %w", fmt.Errorf("another error")), + // Frames: data.Frames{ + // { + // Fields: data.Fields{data.NewField("numbers", nil, []float64{1, 3})}, + // Meta: &data.FrameMeta{ + // Notices: []data.Notice{ + // { + // Severity: data.NoticeSeverityError, + // Text: "Text", + // }, + // }, + // }, + // }, + // }, + // } + const res = toDataQueryResponse(resWithError); + expect(res.error).toMatchInlineSnapshot(` + Object { + "message": "Hello Error", + "refId": "A", + } + `); + + const norm = res.data.map((f) => toDataFrameDTO(f)); + expect(norm).toMatchInlineSnapshot(` + Array [ + Object { + "fields": Array [ + Object { + "config": Object {}, + "labels": undefined, + "name": "numbers", + "type": "number", + "values": Array [ + 1, + 3, + ], + }, + ], + "meta": Object { + "notices": Array [ + Object { + "severity": 2, + "text": "Text", + }, + ], + }, + "name": undefined, + "refId": "A", + }, + ] + `); + }); +}); diff --git a/packages/grafana-runtime/src/utils/queryResponse.ts b/packages/grafana-runtime/src/utils/queryResponse.ts new file mode 100644 index 0000000..83f21d8 --- /dev/null +++ b/packages/grafana-runtime/src/utils/queryResponse.ts @@ -0,0 +1,176 @@ +import { + DataQueryResponse, + arrowTableToDataFrame, + base64StringToArrowTable, + KeyValue, + LoadingState, + DataQueryError, + TimeSeries, + TableData, + toDataFrame, + DataFrame, + MetricFindValue, + FieldType, + DataQuery, +} from '@grafana/data'; + +interface DataResponse { + error?: string; + refId?: string; + dataframes?: string[]; + series?: TimeSeries[]; + tables?: TableData[]; +} + +/** + * Parse the results from /api/ds/query into a DataQueryResponse + * + * @param res - the HTTP response data. + * @param queries - optional DataQuery array that will order the response based on the order of query refId's. + * + * @public + */ +export function toDataQueryResponse(res: any, queries?: DataQuery[]): DataQueryResponse { + const rsp: DataQueryResponse = { data: [], state: LoadingState.Done }; + if (res.data?.results) { + const results: KeyValue = res.data.results; + const resultIDs = Object.keys(results); + const refIDs = queries ? queries.map((q) => q.refId) : resultIDs; + const usedResultIDs = new Set(resultIDs); + const data: DataResponse[] = []; + + for (const refId of refIDs) { + const dr = results[refId] as DataResponse; + if (!dr) { + continue; + } + dr.refId = refId; + usedResultIDs.delete(refId); + data.push(dr); + } + + // Add any refIds that do not match the query targets + if (usedResultIDs.size) { + for (const refId of usedResultIDs) { + const dr = results[refId] as DataResponse; + if (!dr) { + continue; + } + dr.refId = refId; + usedResultIDs.delete(refId); + data.push(dr); + } + } + + for (const dr of data) { + if (dr.error) { + if (!rsp.error) { + rsp.error = { + refId: dr.refId, + message: dr.error, + }; + rsp.state = LoadingState.Error; + } + } + + if (dr.series?.length) { + for (const s of dr.series) { + if (!s.refId) { + s.refId = dr.refId; + } + rsp.data.push(toDataFrame(s)); + } + } + + if (dr.tables?.length) { + for (const s of dr.tables) { + if (!s.refId) { + s.refId = dr.refId; + } + rsp.data.push(toDataFrame(s)); + } + } + + if (dr.dataframes) { + for (const b64 of dr.dataframes) { + try { + const t = base64StringToArrowTable(b64); + const f = arrowTableToDataFrame(t); + if (!f.refId) { + f.refId = dr.refId; + } + rsp.data.push(f); + } catch (err) { + rsp.state = LoadingState.Error; + rsp.error = toDataQueryError(err); + } + } + } + } + } + + // When it is not an OK response, make sure the error gets added + if (res.status && res.status !== 200) { + if (rsp.state !== LoadingState.Error) { + rsp.state = LoadingState.Error; + } + if (!rsp.error) { + rsp.error = toDataQueryError(res); + } + } + + return rsp; +} + +/** + * Convert an object into a DataQueryError -- if this is an HTTP response, + * it will put the correct values in the error field + * + * @public + */ +export function toDataQueryError(err: any): DataQueryError { + const error = (err || {}) as DataQueryError; + + if (!error.message) { + if (typeof err === 'string' || err instanceof String) { + return { message: err } as DataQueryError; + } + + let message = 'Query error'; + if (error.message) { + message = error.message; + } else if (error.data && error.data.message) { + message = error.data.message; + } else if (error.data && error.data.error) { + message = error.data.error; + } else if (error.status) { + message = `Query error: ${error.status} ${error.statusText}`; + } + error.message = message; + } + + return error; +} + +/** + * Return the first string or non-time field as the value + * + * @beta + */ +export function frameToMetricFindValue(frame: DataFrame): MetricFindValue[] { + if (!frame || !frame.length) { + return []; + } + + const values: MetricFindValue[] = []; + let field = frame.fields.find((f) => f.type === FieldType.string); + if (!field) { + field = frame.fields.find((f) => f.type !== FieldType.time); + } + if (field) { + for (let i = 0; i < field.values.length; i++) { + values.push({ text: '' + field.values.get(i) }); + } + } + return values; +} diff --git a/packages/grafana-runtime/tsconfig.build.json b/packages/grafana-runtime/tsconfig.build.json new file mode 100644 index 0000000..9ec189c --- /dev/null +++ b/packages/grafana-runtime/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-runtime/tsconfig.json b/packages/grafana-runtime/tsconfig.json new file mode 100644 index 0000000..f8ab70a --- /dev/null +++ b/packages/grafana-runtime/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declarationDir": "dist", + "outDir": "compiled", + "rootDirs": ["."], + "paths": { + "@grafana/slate-react": ["../grafana-ui/node_modules/@types/slate-react"] + }, + "typeRoots": ["node_modules/@types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": [ + "src/**/*.ts*", + "../../public/app/types/jquery/*.ts", + "../../public/app/types/sanitize-url.d.ts", + "../../public/app/types/svg.d.ts" + ] +} diff --git a/packages/grafana-toolkit/.eslintrc b/packages/grafana-toolkit/.eslintrc new file mode 100644 index 0000000..a465b64 --- /dev/null +++ b/packages/grafana-toolkit/.eslintrc @@ -0,0 +1,14 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime"] }] + }, + "overrides": [ + { + "files": ["./**/*.{ts,tsx}"], + "rules": { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off" + } + } + ] +} diff --git a/packages/grafana-toolkit/CHANGELOG.md b/packages/grafana-toolkit/CHANGELOG.md new file mode 100644 index 0000000..35cb775 --- /dev/null +++ b/packages/grafana-toolkit/CHANGELOG.md @@ -0,0 +1,40 @@ +# 7.3.0-beta1 (2020-10-15) + +### Features / Enhancements +* **grafana/toolkit**: expose Jest maxWorkers arg for plugin test & build tasks. [#27724](https://github.com/grafana/grafana/pull/27724), [@domasx2](https://github.com/domasx2) + +# 7.2.1 (2020-10-08) + +### Features / Enhancements +* **grafana/toolkit**: Add --coverage flag to plugin build command. [#27743](https://github.com/grafana/grafana/pull/27743), [@gassiss](https://github.com/gassiss) + +# 7.2.0 (2020-09-23) + +### Bug Fixes +- **grafana/toolkit**: avoid path.resolve with globby in moveStaticFiles. [#27670](https://github.com/grafana/grafana/pull/27670), [@kennytm](https://github.com/kennytm) + +# 7.0.0 (2020-05-18) + +### Bug Fixes + +- **@grafana/toolkit**: Use process.cwd() instead of PWD to get directory. [#24677](https://github.com/grafana/grafana/pull/24677), [@zoltanbedi](https://github.com/zoltanbedi) + +# 7.0.0-beta.1 (2020-04-28) + +### Features / Enhancements +- **Grafana Toolkit**: Adds template for backend data source. [#23864](https://github.com/grafana/grafana/pull/23864), [@bergquist](https://github.com/bergquist) + +# 6.6.0-beta1 (2020-01-20) + +### Features / Enhancements +- **grafana/toolkit**: Add option to override webpack config. [#20872](https://github.com/grafana/grafana/pull/20872), [@sebimarkgraf](https://github.com/sebimarkgraf) + +# 6.4.0 (2019-10-01) + +# 6.4.0-beta2 (2019-09-25) + +### Features / Enhancements +- **grafana/toolkit**: Add plugin creation task. [#19207](https://github.com/grafana/grafana/pull/19207), [@dprokop](https://github.com/dprokop) + +# 6.4.0-beta1 (2019-09-17) +First release, see [Readme](https://github.com/grafana/grafana/blob/v6.4.0-beta1/packages/grafana-toolkit/README.md) for details. diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md new file mode 100644 index 0000000..d2b85c0 --- /dev/null +++ b/packages/grafana-toolkit/README.md @@ -0,0 +1,324 @@ +> **WARNING: @grafana/toolkit is currently in BETA**. + +# grafana-toolkit + +grafana-toolkit is a CLI that enables efficient development of Grafana plugins. We want to help our community focus on the core value of their plugins rather than all the setup required to develop them. + +## Getting started + +Set up a new plugin with `grafana-toolkit plugin:create` command: + +```sh +npx @grafana/toolkit plugin:create my-grafana-plugin +cd my-grafana-plugin +yarn install +yarn dev +``` + +## Update your plugin to use grafana-toolkit + +Follow the steps below to start using grafana-toolkit in your existing plugin. + +1. Add `@grafana/toolkit` package to your project by running `yarn add @grafana/toolkit` or `npm install @grafana/toolkit`. +2. Create `tsconfig.json` file in the root dir of your plugin and paste the code below: + +```json +{ + "extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["src", "types"], + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "./src", + "typeRoots": ["./node_modules/@types"] + } +} +``` + +3. Create `.prettierrc.js` file in the root dir of your plugin and paste the code below: + +```js +module.exports = { + ...require('./node_modules/@grafana/toolkit/src/config/prettier.plugin.config.json'), +}; +``` + +4. In your `package.json` file add following scripts: + +```json +"scripts": { + "build": "grafana-toolkit plugin:build", + "test": "grafana-toolkit plugin:test", + "dev": "grafana-toolkit plugin:dev", + "watch": "grafana-toolkit plugin:dev --watch" +}, +``` + +## Usage + +With grafana-toolkit, we give you a CLI that addresses common tasks performed when working on Grafana plugin: + +- `grafana-toolkit plugin:create` +- `grafana-toolkit plugin:dev` +- `grafana-toolkit plugin:test` +- `grafana-toolkit plugin:build` +- `grafana-toolkit plugin:sign` + +### Create your plugin + +`grafana-toolkit plugin:create plugin-name` + +This command creates a new Grafana plugin from template. + +If `plugin-name` is provided, then the template is downloaded to `./plugin-name` directory. Otherwise, it will be downloaded to the current directory. + +### Develop your plugin + +`grafana-toolkit plugin:dev` + +This command creates a development build that's easy to play with and debug using common browser tooling. + +Available options: + +- `-w`, `--watch` - run development task in a watch mode + +### Test your plugin + +`grafana-toolkit plugin:test` + +This command runs Jest against your codebase. + +Available options: + +- `--watch` - Runs tests in interactive watch mode. +- `--coverage` - Reports code coverage. +- `-u`, `--updateSnapshot` - Performs snapshots update. +- `--testNamePattern=` - Runs test with names that match provided regex (https://jestjs.io/docs/en/cli#testnamepattern-regex). +- `--testPathPattern=` - Runs test with paths that match provided regex (https://jestjs.io/docs/en/cli#testpathpattern-regex). +- `--maxWorkers=|` - Limit number of Jest workers spawned (https://jestjs.io/docs/en/cli#--maxworkersnumstring) + +### Build your plugin + +`grafana-toolkit plugin:build` + +This command creates a production-ready build of your plugin. + +Available options: + +- `--coverage` - Reports code coverage after the test step of the build. +- `--preserveConsole` - Preserves console statements in the code. + +### Sign your plugin + +`grafana-toolkit plugin:sign` + +This command creates a signed MANIFEST.txt file which Grafana uses to validate the integrity of the plugin. + +Available options: + +- `--signatureType` - The [type of Signature](https://grafana.com/legal/plugins/) you are generating: `private`, `community` or `commercial` +- `--rootUrls` - For private signatures, a list of the Grafana instance URLs that the plugin will be used on + +To generate a signature, you will need to sign up for a free account on https://grafana.com, create an API key with the Plugin Publisher role, and pass that in the `GRAFANA_API_KEY` environment variable. + +## FAQ + +### Which version of grafana-toolkit should I use? + +See [Grafana packages versioning guide](https://github.com/grafana/grafana/blob/master/packages/README.md#versioning). + +### What tools does grafana-toolkit use? + +grafana-toolkit comes with TypeScript, ESLint, Prettier, Jest, CSS and SASS support. + +### How to start using grafana-toolkit in my plugin? + +See [Updating your plugin to use grafana-toolkit](#updating-your-plugin-to-use-grafana-toolkit). + +### Can I use TypeScript to develop Grafana plugins? + +Yes! grafana-toolkit supports TypeScript by default. + +### How can I test my plugin? + +grafana-toolkit comes with Jest as a test runner. + +Internally at Grafana we use Enzyme. If you are developing React plugin and you want to configure Enzyme as a testing utility, then you need to configure `enzyme-adapter-react`. To do so, create `/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React: + +```ts +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +configure({ adapter: new Adapter() }); +``` + +You can also set up Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `/config/jest-shim.ts` + +### Can I provide custom setup for Jest? + +You can provide custom Jest configuration with a `package.json` file. For more details, see [Jest docs](https://jest-bot.github.io/jest/docs/configuration.html). + +Currently we support following Jest configuration properties: + +- [`snapshotSerializers`](https://jest-bot.github.io/jest/docs/configuration.html#snapshotserializers-array-string) +- [`moduleNameMapper`](https://jestjs.io/docs/en/configuration#modulenamemapper-object-string-string) + +### How can I customize Webpack rules or plugins? + +You can provide your own `webpack.config.js` file that exports a `getWebpackConfig` function. We recommend that you extend the standard configuration, but you are free to create your own: + +```js +const CustomPlugin = require('custom-plugin'); + +module.exports.getWebpackConfig = (config, options) => ({ + ...config, + plugins: [...config.plugins, new CustomPlugin()], +}); +``` + +### How can I style my plugin? + +We support pure CSS, SASS, and CSS-in-JS approach (via [Emotion](https://emotion.sh/)). + +#### Single CSS or SASS file + +Create your CSS or SASS file and import it in your plugin entry point (typically `module.ts`): + +```ts +import 'path/to/your/css_or_sass'; +``` + +The styles will be injected via `style` tag during runtime. + +> **Note:** that imported static assets will be inlined as base64 URIs. _This can be subject of change in the future!_ + +#### Theme-specific stylesheets + +If you want to provide different stylesheets for dark/light theme, then create `dark.[css|scss]` and `light.[css|scss]` files in the `src/styles` directory of your plugin. grafana-toolkit generates theme-specific stylesheets that are stored in `dist/styles` directory. + +In order for Grafana to pick up your theme stylesheets, you need to use `loadPluginCss` from `@grafana/runtime` package. Typically you would do that in the entry point of your plugin: + +```ts +import { loadPluginCss } from '@grafana/runtime'; + +loadPluginCss({ + dark: 'plugins//styles/dark.css', + light: 'plugins//styles/light.css', +}); +``` + +You must add `@grafana/runtime` to your plugin dependencies by running `yarn add @grafana/runtime` or `npm install @grafana/runtime`. + +> **Note:** that in this case static files (png, svg, json, html) are all copied to dist directory when the plugin is bundled. Relative paths to those files does not change! + +#### Emotion + +Starting from Grafana 6.2 _our suggested way_ for styling plugins is by using [Emotion](https://emotion.sh). It's a CSS-in-JS library that we use internally at Grafana. The biggest advantage of using Emotion is that you can access Grafana Theme variables. + +To start using Emotion, you first must add it to your plugin dependencies: + +``` + yarn add "emotion"@10.0.27 +``` + +Then, import `css` function from Emotion: + +```ts +import { css } from 'emotion'; +``` + +Now you are ready to implement your styles: + +```tsx +const MyComponent = () => { + return ( +
+ ); +}; +``` + +To learn more about using Grafana theme please refer to [Theme usage guide](https://github.com/grafana/grafana/blob/master/style_guides/themes.md#react) + +> We do not support Emotion's `css` prop. Use className instead! + +### Can I adjust TypeScript configuration to suit my needs? + +Yes! However, it's important that your `tsconfig.json` file contains the following lines: + +```json +{ + "extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["src"], + "compilerOptions": { + "rootDir": "./src", + "typeRoots": ["./node_modules/@types"] + } +} +``` + +### Can I adjust ESLint configuration to suit my needs? + +grafana-toolkit comes with [default config for ESLint](https://github.com/grafana/grafana/blob/master/packages/grafana-toolkit/src/config/eslint.plugin.json). For now, there is no way to customise ESLint config. + +### How is Prettier integrated into grafana-toolkit workflow? + +When building plugin with [`grafana-toolkit plugin:build`](#building-plugin) task, grafana-toolkit performs Prettier check. If the check detects any Prettier issues, the build will not pass. To avoid such situation we suggest developing plugin with [`grafana-toolkit plugin:dev --watch`](#developing-plugin) task running. This task tries to fix Prettier issues automatically. + +### My editor does not respect Prettier config, what should I do? + +In order for your editor to pick up our Prettier config you need to create `.prettierrc.js` file in the root directory of your plugin with following content: + +```js +module.exports = { + ...require('./node_modules/@grafana/toolkit/src/config/prettier.plugin.config.json'), +}; +``` + +### How do I add third-party dependencies that are not npm packages? + +Put them in the `static` directory in the root of your project. The `static` directory is copied when the plugin is built. + +### I am getting this message when I run yarn install: `Request failed \"404 Not Found\"` + +If you are using version `canary`, this error occurs because a `canary` release unpublishes previous versions leaving `yarn.lock` outdated. Remove `yarn.lock` and run `yarn install` again. + +### I am getting this message when I run my plugin: `Unable to dynamically transpile ES module A loader plugin needs to be configured via SystemJS.config({ transpiler: 'transpiler-module' }).` + +This error occurs when you bundle your plugin using the `grafana-toolkit plugin:dev` task and your code comments include ES2016 code. + +There are two issues at play: + +- The `grafana-toolkit plugin:dev` task does not remove comments from your bundled package. +- Grafana does not support [ES modules](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/). + +If your comments include ES2016 code, then SystemJS v0.20.19, which Grafana uses internally to load plugins, interprets your code as an ESM and fails. + +To fix this error, remove the ES2016 code from your comments. + +## Contribute to grafana-toolkit + +You can contribute to grafana-toolkit by helping develop it or by debugging it. + +### Develop grafana-toolkit + +Typically plugins should be developed using the `@grafana/toolkit` installed from npm. However, when working on the toolkit, you might want to use the local version. Follow the steps below to develop with a local version: + +1. Clone [Grafana repository](https://github.com/grafana/grafana). +2. Navigate to the directory you have cloned Grafana repo to and then run `yarn install --pure-lockfile`. +3. Navigate to `/packages/grafana-toolkit` and then run `yarn link`. +4. Navigate to the directory where your plugin code is and then run `npx grafana-toolkit plugin:dev --yarnlink`. This adds all dependencies required by grafana-toolkit to your project, as well as link your local grafana-toolkit version to be used by the plugin. + +### Debug grafana-toolkit + +To debug grafana-toolkit you can use standard [NodeJS debugging methods](https://nodejs.org/de/docs/guides/debugging-getting-started/#enable-inspector) (`node --inspect`, `node --inspect-brk`). + +To run grafana-toolkit in a debugging session use the following command in the toolkit's directory: + +`node --inspect-brk ./bin/grafana-toolkit.js [task]` + +To run [linked](#develop-grafana-toolkit) grafana-toolkit in a debugging session use the following command in the plugin's directory: + +`node --inspect-brk ./node_modules/@grafana/toolkit/bin/grafana-toolkit.js [task]` diff --git a/packages/grafana-toolkit/bin/grafana-toolkit.js b/packages/grafana-toolkit/bin/grafana-toolkit.js new file mode 100755 index 0000000..8776081 --- /dev/null +++ b/packages/grafana-toolkit/bin/grafana-toolkit.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +let includeInternalScripts = false; + +const isLinkedMode = () => { + // In circleci we are in linked mode. Detect by using the circle working directory, + // rather than the present working directory. + const pwd = process.env.CIRCLE_WORKING_DIRECTORY || process.env.PWD || process.cwd(); + + if (path.basename(pwd) === 'grafana-toolkit') { + return true; + } + + try { + const resolvedPath = path.resolve(`${__dirname}/../../../node_modules/@grafana/toolkit`); + return fs.lstatSync(resolvedPath).isSymbolicLink(); + } catch { + return false; + } +}; + +const entrypoint = () => { + const entrypointBase = `${__dirname}/../src/cli/index`; + const resolvedJsDir = path.resolve(`${entrypointBase}.js`); + const resolvedTsDir = path.resolve(`${entrypointBase}.ts`); + + // IF we have a toolkit directory AND linked grafana toolkit AND the toolkit dir is a symbolic lik + // THEN run everything in linked mode + if (isLinkedMode() || !fs.existsSync(resolvedJsDir)) { + console.log('Running in local/linked mode'); + // This bin is used for cli executed internally + var tsProjectPath = path.resolve(__dirname, '../tsconfig.json'); + require('ts-node').register({ + project: tsProjectPath, + transpileOnly: true, + }); + + includeInternalScripts = true; + return resolvedTsDir; + } + + // The default entrypoint must exist, return it now. + return resolvedJsDir; +}; + +require(entrypoint()).run(includeInternalScripts); diff --git a/packages/grafana-toolkit/config/circleci/config.yml b/packages/grafana-toolkit/config/circleci/config.yml new file mode 100644 index 0000000..09c1f3a --- /dev/null +++ b/packages/grafana-toolkit/config/circleci/config.yml @@ -0,0 +1,295 @@ +version: 2.1 + +parameters: + ssh-fingerprint: + type: string + default: ${GITHUB_SSH_FINGERPRINT} + +aliases: + # Workflow filters + - &filter-only-master + branches: + only: master + - &filter-only-release + branches: + only: /^v[1-9]*[0-9]+\.[1-9]*[0-9]+\.x$/ + +workflows: + plugin_workflow: + jobs: + - yarn_install + - build_docs: + requires: + - yarn_install + - build_frontend: + requires: + - yarn_install + - build_backend: + requires: + - yarn_install + - package: + requires: + - build_frontend + - build_backend + - build_docs + - provisioning: + requires: + - build_frontend + - build_backend + - build_docs + - e2e_canary: + requires: + - provisioning + - package + - report: + requires: + - package + - e2e_canary + - approve_release: + type: approval + requires: + - report + filters: *filter-only-release + - publish_github_release: + requires: + - approve_release + filters: *filter-only-release + +executors: + default_exec: # declares a reusable executor + docker: + - image: srclosson/grafana-plugin-ci-alpine:latest + e2e_exec: + docker: + - image: srclosson/grafana-plugin-ci-e2e:latest + +jobs: + yarn_install: + executor: default_exec + steps: + - checkout + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Install dependencies + command: | + mkdir ci + [ -f ~/project/node_modules/.bin/grafana-toolkit ] || yarn install --frozen-lockfile + - save_cache: + name: save node_modules + paths: + - ~/project/node_modules + key: build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - save_cache: + name: save cypress cache + paths: + - ~/.cache/Cypress + key: cypress-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + + build_docs: + executor: default_exec + steps: + - checkout + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Build docs + command: | + ./node_modules/.bin/grafana-toolkit plugin:ci-docs + [ -d "dist" ] || circleci-agent step halt + - persist_to_workspace: + root: . + paths: + - dist + + build_frontend: + executor: default_exec + steps: + - checkout + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Build and test frontend + command: ./node_modules/.bin/grafana-toolkit plugin:ci-build + - persist_to_workspace: + root: . + paths: + - dist + + build_backend: + executor: default_exec + steps: + - checkout + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Exit if no backend + command: | + [ -f "Magefile.go" ] || circleci-agent step halt + - run: + name: Build backend + command: mage -v buildAll + - run: + name: Test backend + command: | + mage -v lint + mage -v coverage + - persist_to_workspace: + root: . + paths: + - dist + + package: + executor: default_exec + steps: + - checkout + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - attach_workspace: + at: . + - run: + name: Move results to ci folder + command: ./node_modules/.bin/grafana-toolkit plugin:ci-build --finish + - run: + name: Package distribution + command: | + ./node_modules/.bin/grafana-toolkit plugin:ci-package + - persist_to_workspace: + root: . + paths: + - ci/jobs/package + - ci/packages + - ci/dist + - ci/grafana-test-env + - store_artifacts: + path: ci + + provisioning: + executor: default_exec + steps: + - run: + name: Continue if this plugin has a provisioning path + command: | + [ -z "${PROVISIONING_REPO_PATH}" ] && circleci-agent step halt + - checkout + - add_ssh_keys: + fingerprints: + - << pipeline.parameters.ssh-fingerprint >> + - run: + name: Checkout provisioning files + command: git clone --depth 1 git@github.com:grafana/plugin-provisioning.git + - run: + name: Prepare task output dir + command: | + mkdir ci # Avoid error if not exists + mkdir ci/jobs # Avoid error if not exists + mkdir ci/jobs/provisioning + mv plugin-provisioning/${PROVISIONING_REPO_PATH}/* ci/jobs/provisioning + - persist_to_workspace: + root: . + paths: + - ci/jobs/provisioning + + e2e_canary: + executor: e2e_exec + steps: + - run: + name: Continue if this plugin has a provisioning path + command: | + [ -z "${PROVISIONING_REPO_PATH}" ] && circleci-agent step halt + - checkout + - attach_workspace: + at: . + - restore_cache: + name: Restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - restore_cache: + name: Restore cypress cache + keys: + - cypress-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Exit if no e2e tests configured + command: | + [ -d cypress ] || circleci-agent step halt + - run: + name: Setup Grafana (local install) + command: | + ginstall canary + /opt/grafana/bin/grafana-server -config ci/grafana-test-env/custom.ini -homepath /opt/grafana & + ## To make sure grafana has started up + while ! $(netstat -tulpn | grep 3000 >/dev/null 2>&1); do sleep 1; done + /opt/grafana/bin/grafana-cli --version + - run: + name: Copy provisioning files + command: cp -r ci/jobs/provisioning provisioning/ + - run: + name: Run e2e tests + command: | + # If the tests fail, but GRAFANACI_STRICT_E2E=0, don't worry about it + ./node_modules/.bin/grafana-e2e run \ + || ( + [ "$GRAFANACI_STRICT_E2E" -eq 0 ] && echo "Bypassing fail. ci-nostrict enabled" + ) + - run: + when: always + name: Prepare task output dir + command: | + # TODO: probably move all of this to `@grafana/toolkit plugin:ci-test` + mkdir ci/jobs/e2e_canary + # only copy if they exist + cp cypress/screenshots/*.* ci/jobs/e2e_canary 2>/dev/null || : + cp cypress/videos/*.* ci/jobs/e2e_canary 2>/dev/null || : + - persist_to_workspace: + root: . + paths: + - ci/jobs/e2e_canary + - store_test_results: + path: ci/jobs/e2e_canary + - store_artifacts: + path: ci/jobs/e2e_canary + + report: + executor: default_exec + steps: + - checkout + - attach_workspace: + at: . + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: Toolkit report + command: | + ./node_modules/.bin/grafana-toolkit plugin:ci-report + - store_artifacts: + path: ci + + publish_github_release: + executor: default_exec + steps: + - checkout + - add_ssh_keys: + fingerprints: + - "dc:60:ab:c7:2d:8c:82:50:2a:2a:97:1a:c0:66:83:14" + - attach_workspace: + at: . + - restore_cache: + name: restore node_modules + keys: + - build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} + - run: + name: "Publish Release on GitHub" + command: | + ./node_modules/.bin/grafana-toolkit plugin:github-publish diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile new file mode 100644 index 0000000..8f515ec --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.13 + +USER root + +COPY scripts scripts +COPY install /usr/local + +WORKDIR scripts + +RUN ./deploy.sh diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md new file mode 100644 index 0000000..bb852b6 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/README.md @@ -0,0 +1,61 @@ +# Using this docker image + +Uploaded to dockerhub as grafana/grafana-plugin-ci:latest-alpine + +Based off of `circleci/node:12-browsers` + +## User +The user will be `circleci` +The home directory will be `/home/circleci` + +## Node +- node 12 is installed +- yarn is installed globally +- npm is installed globally + +## Go +- Go is installed in `/usr/local/bin/go` +- golangci-lint is installed in `/usr/local/bin/golangci-lint` +- mage is installed in `/home/circleci/go/bin/mage` + +All of the above directories are in the path, so there is no need to specify fully qualified paths. + +## Grafana +- Installed in `/home/circleci/src/grafana` +- `yarn install` has been run + +## Integration/Release Testing +There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: +1. 6.6.2 +2. 6.5.3 +3. 6.4.5 +4. 6.3.7 + +To test, your CircleCI config will need a run section with something similar to the following +``` +- run: + name: Setup Grafana (local install) + command: | + sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb + sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini + sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini + sudo service grafana-server start + grafana-cli --version +``` + + +# Building +To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine` +``` +./build.sh +``` + +# Developing/Testing +To test, you should have docker-compose installed. +``` +cd test +./start.sh +``` + +You will be in /home/circleci/test with the buildscripts installed to the local directory. +Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh new file mode 100755 index 0000000..f024e61 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/build.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -eo pipefail + +source ./common.sh + +# +# No longer required, but useful to keep just in case we want to deploy +# changes in toolkit directly to the docker image +# +if [ -n "$INCLUDE_TOOLKIT" ]; then + /bin/rm -rfv install/grafana-toolkit + mkdir -pv install/grafana-toolkit + cp -rv ../../bin install/grafana-toolkit + cp -rv ../../src install/grafana-toolkit + cp -v ../../package.json install/grafana-toolkit + cp -v ../../tsconfig.json install/grafana-toolkit +fi + +docker build -t ${DOCKER_IMAGE_NAME} . +docker push $DOCKER_IMAGE_NAME + +[ -n "$INCLUDE_TOOLKIT" ] && /bin/rm -rfv install/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh new file mode 100755 index 0000000..dc617a8 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## Common variable declarations +## + +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.2.0-alpine" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp new file mode 100755 index 0000000..fd23c3d --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/cp @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ "$1" == "-rn" ]; then + false | busybox cp -i -r "$2" "$3" 2>/dev/null +else + busybox cp $* +fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall new file mode 100755 index 0000000..b82fd3e --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall @@ -0,0 +1,73 @@ +#!/bin/sh +## +# gget +# A script to get and install grafana versions +# for usage information see "show_help" below. +# + +latest=$(wget -O - 'https://raw.githubusercontent.com/grafana/grafana/master/latest.json' | jq -r '.stable') +canary=$(wget -O - "https://grafana.com/api/grafana/versions" | jq ".items[0].version" | tr -d '"') + +show_help() { + echo "Usage: gget " + echo "" + echo "where can be:" + echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" + echo " 2) latest (currently $latest)" + echo " 3) canary (currently $canary)" + echo "" + echo " -h, --help: Display this help message" + echo "" + exit 0 +} + +opts=$(getopt -o h --long help -n 'gget' -- "$@") +[ $? -eq 0 ] || { + show_help +} + +eval set -- "$opts" +while true; do + case "$1" in + -h | --help) + show_help + ;; + --) + shift + break + ;; + *) + break + ;; + esac + shift +done + +[ -z "$1" ] && show_help + +# Make sure the script is being run as root +if [ $EUID -ne 0 ]; then + echo "This script must be run as root" + exit 1 +fi + +## +# MAIN +# +# Enough setup, let's actually do something +# +version=$1 +if [ "$version" == "latest" ]; then + version="$latest" + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +elif [ "$version" == "canary" ]; then + version="$canary" + wget -O - "https://dl.grafana.com/oss/master/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +else + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +fi + +/bin/rm -rf /opt/grafana > /dev/null 2>&1 || true +ln -s /opt/grafana-${version} /opt/grafana + +# nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js new file mode 100644 index 0000000..c6f42f7 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/githubRelease.js @@ -0,0 +1,153 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +var tslib_1 = require('tslib'); +var getPluginId_1 = require('../../config/utils/getPluginId'); +var pluginValidation_1 = require('../../config/utils/pluginValidation'); +var env_1 = require('../../plugins/env'); +var path = require('path'); +var fs = require('fs'); +// @ts-ignore +// import execa = require('execa'); +var githubClient_1 = tslib_1.__importDefault(require('./githubClient')); +var resolveContentType = function (extension) { + if (extension.startsWith('.')) { + extension = extension.substr(1); + } + switch (extension) { + case 'zip': + return 'application/zip'; + case 'json': + return 'application/json'; + case 'sha1': + return 'text/plain'; + default: + return 'application/octet-stream'; + } +}; +var GitHubRelease = /** @class */ (function () { + function GitHubRelease(token, username, repository, releaseNotes, commitHash) { + this.token = token; + this.username = username; + this.repository = repository; + this.releaseNotes = releaseNotes; + this.commitHash = commitHash; + this.git = new githubClient_1.default({ + required: true, + repo: repository, + }); + } + GitHubRelease.prototype.publishAssets = function (srcLocation, destUrl) { + var _this = this; + // Add the assets. Loop through files in the ci/dist folder and upload each asset. + var files = fs.readdirSync(srcLocation); + return files.map(function (file) { + return tslib_1.__awaiter(_this, void 0, void 0, function () { + var fileStat, fileData; + return tslib_1.__generator(this, function (_a) { + fileStat = fs.statSync(srcLocation + '/' + file); + fileData = fs.readFileSync(srcLocation + '/' + file); + return [ + 2 /*return*/, + this.git.client.post(destUrl + '?name=' + file, fileData, { + headers: { + 'Content-Type': resolveContentType(path.extname(file)), + 'Content-Length': fileStat.size, + }, + maxContentLength: fileStat.size * 2 * 1024 * 1024, + }), + ]; + }); + }); + }); + }; + GitHubRelease.prototype.release = function () { + var _a, _b, _c, _d; + return tslib_1.__awaiter(this, void 0, void 0, function () { + var ciDir, + distDir, + distContentDir, + pluginJsonFile, + pluginInfo, + PUBLISH_DIR, + commitHash, + latestRelease, + reason_1, + newReleaseResponse, + publishPromises, + reason_2; + return tslib_1.__generator(this, function (_e) { + switch (_e.label) { + case 0: + ciDir = env_1.getCiFolder(); + distDir = path.resolve(ciDir, 'dist'); + distContentDir = path.resolve(distDir, getPluginId_1.getPluginId()); + pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); + pluginInfo = pluginValidation_1.getPluginJson(pluginJsonFile).info; + PUBLISH_DIR = path.resolve(env_1.getCiFolder(), 'packages'); + commitHash = this.commitHash || ((_a = pluginInfo.build) === null || _a === void 0 ? void 0 : _a.hash); + _e.label = 1; + case 1: + _e.trys.push([1, 5, , 6]); + return [4 /*yield*/, this.git.client.get('releases/tags/v' + pluginInfo.version)]; + case 2: + latestRelease = _e.sent(); + if (!(latestRelease.data.tag_name === 'v' + pluginInfo.version)) { + return [3 /*break*/, 4]; + } + return [4 /*yield*/, this.git.client.delete('releases/' + latestRelease.data.id)]; + case 3: + _e.sent(); + _e.label = 4; + case 4: + return [3 /*break*/, 6]; + case 5: + reason_1 = _e.sent(); + if (reason_1.response.status !== 404) { + // 404 just means no release found. Not an error. Anything else though, re throw the error + throw reason_1; + } + return [3 /*break*/, 6]; + case 6: + _e.trys.push([6, 9, , 10]); + return [ + 4 /*yield*/, + this.git.client.post('releases', { + tag_name: 'v' + pluginInfo.version, + target_commitish: commitHash, + name: 'v' + pluginInfo.version, + body: this.releaseNotes, + draft: false, + prerelease: false, + }), + ]; + case 7: + newReleaseResponse = _e.sent(); + publishPromises = this.publishAssets( + PUBLISH_DIR, + 'https://uploads.github.com/repos/' + + this.username + + '/' + + this.repository + + '/releases/' + + newReleaseResponse.data.id + + '/assets' + ); + return [4 /*yield*/, Promise.all(publishPromises)]; + case 8: + _e.sent(); + return [3 /*break*/, 10]; + case 9: + reason_2 = _e.sent(); + console.log(reason_2); + // Rethrow the error so that we can trigger a non-zero exit code to circle-ci + throw reason_2; + case 10: + return [2 /*return*/]; + } + }); + }); + }; + return GitHubRelease; +})(); +exports.GitHubRelease = GitHubRelease; +//# sourceMappingURL=githubRelease.js.map7027e10521e9 diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh new file mode 100755 index 0000000..9263691 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-common.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +## +# Script to deploy a docker image. Must return exit code 0 +# +do_exit() { + message="$1" + exit_code="$2" + + echo "$message" + exit $exit_code +} + + +## +# Get file, get's a file, validates the SHA +# @param filename +# @param expected sha value +# @returns 0 if successful, -1 of checksum validation failed. +# +get_file () { + [ -n "$1" ] && url=$1 || do_exit "url required" 1 + [ -n "$2" ] && dest=$2 || do_exit "destination required" 2 + sha=$3 + file=$(basename $dest) + + curl -fL "${url}" -o "$dest" + if [ -n "$sha" ]; then + echo "$sha $dest" | sha256sum || do_exit "Checksum validation failed for $file. Exiting" 1 + fi +} + +untar_file () { + [ -n "$1" ] && src=$1 || do_exit "src required" 1 + [ -n "$2" ] && dest=$2 || dest="/usr/local" + + tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" +} + +## +# WIP: Just started this and not finished. +# The intent it to download a release from a git repo, +# compile, and install +get_latest_release () { + tarsrc=$(curl -sL "https://api.github.com/repos/$1/$2/releases/latest" | jq ".tarball_url" | tr -d '"') + curl -fL -o /tmp/autoretrieved.tar.gz "$tarsrc" + origdir=$PWD + reponame=$(tar zxvf autoretrieved.tar.gz | tail -1 | awk -F / '{print $1}') + cd "/tmp/$reponame" + #perform compile + cd $origdir +} diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh new file mode 100755 index 0000000..f8926ec --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy-user.sh @@ -0,0 +1,3 @@ +#!/bin/sh +source "./deploy-common.sh" + diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh new file mode 100755 index 0000000..b924b38 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -eo pipefail + +source "./deploy-common.sh" + +# Make libgcc compatible +mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 + +# Replace cp with something that mocks the one that ci-package needs +rm /bin/cp +mv /usr/local/bin/cp /bin/cp + +apk add --no-cache curl 'nodejs-current=14.5.0-r0' npm yarn build-base openssh git-lfs perl-utils coreutils python3 + +# +# Only relevant for testing, but cypress does not work with musl/alpine. +# +# apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat + +# Install Go +filename="go1.16.1.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "3edc22f8332231c3ba8be246f184b736b8d28f06ce24f08168d8ecf052549769" +untar_file "/tmp/$filename" + +# Install golangci-lint +GOLANGCILINT_VERSION=1.37.1 +filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" +get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ + "/tmp/$filename.tar.gz" \ + "1929425d7733d136b342395c77f171d459aa89b198933465ec4c854aa34c41a2" +untar_file "/tmp/$filename.tar.gz" +ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint +ln -s /usr/local/go/bin/go /usr/local/bin/go +ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt +chmod 755 /usr/local/bin/golangci-lint + +# Install dependencies +apk add --no-cache fontconfig zip jq + +# Install code climate +get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ + "/usr/local/bin/cc-test-reporter" \ + "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" +chmod 755 /usr/local/bin/cc-test-reporter + +curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" + +apk add --no-cache git +# Install Mage +mkdir -pv /tmp/mage $HOME/go/bin +git clone https://github.com/magefile/mage.git /tmp/mage +cd /tmp/mage && go run bootstrap.go +mv $HOME/go/bin/mage /usr/local/bin + +wget -O - -q https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b /usr/local/bin v2.2.0 + +source "/etc/profile" +sh -l -c "go get -u github.com/mgechev/revive" +for file in $(ls $HOME/go/bin); do + mv -v $HOME/go/bin/$file /usr/local/bin/$file +done + +# Install grafana-toolkit deps +current_dir=$PWD +cd /usr/local/grafana-toolkit && yarn install && cd $current_dir +ln -s /usr/local/grafana-toolkit/bin/grafana-toolkit.js /usr/local/bin/grafana-toolkit + +GOOGLE_SDK_VERSION=316.0.0 +GOOGLE_SDK_CHECKSUM=96a0b75474dbfa9f3d46dcdec7a4d68a664cb7d57fade5710fe88b1fdf6babb3 + +curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz +echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status +tar xvzf google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -C /opt +rm google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz +ln -s /opt/google-cloud-sdk/bin/gsutil /usr/bin/gsutil +ln -s /opt/google-cloud-sdk/bin/gcloud /usr/bin/gcloud + +# Cleanup after yourself +/bin/rm -rf /tmp/mage +/bin/rm -rf $HOME/go diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml new file mode 100644 index 0000000..9a3e53f --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' +services: + citest: + image: "amd64/alpine" + user: root + volumes: + - ../scripts:/home/circleci/scripts + - ../install:/home/circleci/install + - ${HOME}/.ssh:/root/.ssh + - ../../..:/home/circleci/grafana-toolkit + cibuilt: + image: "grafana/grafana-plugin-ci:latest-alpine" + user: root + volumes: + - ../scripts:/home/circleci/scripts + - ../install:/home/circleci/install + - ${HOME}/.ssh:/root/.ssh + - ../../..:/home/circleci/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh new file mode 100755 index 0000000..f5551eb --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/test/start.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +function finish { + echo "Exiting and cleaning up docker image" + docker-compose down +} +trap finish EXIT + +# Enter the docker container +if [ "$1" = "built" ]; then + docker-compose run cibuilt sh -c "cd /home/circleci; exec sh --login -i" +else + docker-compose run citest sh -c "cd /home/circleci; exec sh --login -i" +fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile new file mode 100644 index 0000000..0b33685 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/Dockerfile @@ -0,0 +1,9 @@ +FROM debian:testing-20210111-slim + +ENV DEBIAN_FRONTEND=noninteractive + +COPY scripts scripts +COPY install /usr/local + +RUN cd scripts && ./deploy.sh +ENV DEBIAN_FRONTEND=newt diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md new file mode 100644 index 0000000..51f67cf --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/README.md @@ -0,0 +1,61 @@ +# Using this docker image + +Currently tagged and uploaded to dockerhub as srclosson/integrations-ci-build + +Based off of `circleci/node:12-browsers` + +## User +The user will be `circleci` +The home directory will be `/home/circleci` + +## Node +- node 12 is installed +- yarn is installed globally +- npm is installed globally + +## Go +- Go is installed in `/usr/local/bin/go` +- golangci-lint is installed in `/usr/local/bin/golangci-lint` +- mage is installed in `/home/circleci/go/bin/mage` + +All of the above directories are in the path, so there is no need to specify fully qualified paths. + +## Grafana +- Installed in `/home/circleci/src/grafana` +- `yarn install` has been run + +## Integration/Release Testing +There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: +1. 6.6.2 +2. 6.5.3 +3. 6.4.5 +4. 6.3.7 + +To test, your CircleCI config will need a run section with something similar to the following +``` +- run: + name: Setup Grafana (local install) + command: | + sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb + sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini + sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini + sudo service grafana-server start + grafana-cli --version +``` + + +# Building +To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci` +``` +./build.sh +``` + +# Developing/Testing +To test, you should have docker-compose installed. +``` +cd test +./start.sh +``` + +You will be in /home/circleci/test with the buildscripts installed to the local directory. +Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh new file mode 100755 index 0000000..14ca02a --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/build.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -eo pipefail + +source ./common.sh + +# +# No longer required, but useful to keep just in case we want to deploy +# changes in toolkit directly to the docker image +# +if [ -n "$INCLUDE_TOOLKIT" ]; then + /bin/rm -rfv install/grafana-toolkit + mkdir -pv install/grafana-toolkit + cp -rv ../../bin install/grafana-toolkit + cp -rv ../../src install/grafana-toolkit + cp -v ../../package.json install/grafana-toolkit + cp -v ../../tsconfig.json install/grafana-toolkit +fi + +docker build -t ${DOCKER_IMAGE_NAME} . +docker push $DOCKER_IMAGE_NAME +docker tag ${DOCKER_IMAGE_NAME} ${DOCKER_IMAGE_BASE_NAME}:latest +docker push ${DOCKER_IMAGE_BASE_NAME}:latest + +[ -n "$INCLUDE_TOOLKIT" ] && /bin/rm -rfv install/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh new file mode 100755 index 0000000..98d0f02 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +## +## Common variable declarations +## + +DOCKER_IMAGE_BASE_NAME="grafana/grafana-plugin-ci-e2e" +DOCKER_IMAGE_VERSION="1.2.0" +DOCKER_IMAGE_NAME="${DOCKER_IMAGE_BASE_NAME}:${DOCKER_IMAGE_VERSION}" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall new file mode 100755 index 0000000..13a30ff --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall @@ -0,0 +1,73 @@ +#!/bin/bash +## +# gget +# A script to get and install grafana versions +# for usage information see "show_help" below. +# + +latest=$(wget -O - 'https://raw.githubusercontent.com/grafana/grafana/master/latest.json' | jq -r '.stable') +canary=$(wget -O - "https://grafana.com/api/grafana/versions" | jq ".items[0].version" | tr -d '"') + +show_help() { + echo "Usage: gget " + echo "" + echo "where can be:" + echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" + echo " 2) latest (currently $latest)" + echo " 3) canary (currently $canary)" + echo "" + echo " -h, --help: Display this help message" + echo "" + exit 0 +} + +opts=$(getopt -o h --long help -n 'gget' -- "$@") +[ $? -eq 0 ] || { + show_help +} + +eval set -- "$opts" +while true; do + case "$1" in + -h | --help) + show_help + ;; + --) + shift + break + ;; + *) + break + ;; + esac + shift +done + +[ -z "$1" ] && show_help + +# Make sure the script is being run as root +if [ $EUID -ne 0 ]; then + echo "This script must be run as root" + exit 1 +fi + +## +# MAIN +# +# Enough setup, let's actually do something +# +version=$1 +if [ "$version" == "latest" ]; then + version="$latest" + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +elif [ "$version" == "canary" ]; then + version="$canary" + wget -O - "https://dl.grafana.com/oss/master/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +else + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +fi + +/bin/rm -rf /opt/grafana > /dev/null 2>&1 || true +ln -s /opt/grafana-${version} /opt/grafana + +# nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh new file mode 100755 index 0000000..af585d2 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-common.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +## +# Script to deploy a docker image. Must return exit code 0 +# +do_exit() { + message="$1" + exit_code="$2" + + echo "$message" + exit $exit_code +} + +## +# Get file, get's a file, validates the SHA +# @param filename +# @param expected sha value +# @returns 0 if successful, -1 of checksum validation failed. +# +get_file () { + [ -n "$1" ] && url=$1 || do_exit "url required" -1 + [ -n "$2" ] && dest=$2 || do_exit "destination required" -2 + sha=$3 + file=$(basename $dest) + + wget "$url" -O "$dest" + if [ -n "$sha" ]; then + echo "$sha $dest" | sha256sum --check --status || do_exit "Checksum validation failed for $file. Exiting" -1 + fi +} + +untar_file () { + [ -n "$1" ] && src=$1 || do_exit "src required" -1 + [ -n "$2" ] && dest=$2 || dest="/usr/local" + + tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" +} diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh new file mode 100755 index 0000000..9c3c0a3 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-slim.sh @@ -0,0 +1,27 @@ +#!/bin/bash +source "/etc/profile" + +apt-get --allow-insecure-repositories update +apt-get install --allow-unauthenticated -y \ + build-essential \ + wget git sudo adduser \ + libfontconfig1 \ + locate \ + libnss3 libnspr4 \ + libgdk-pixbuf2.0-0 \ + libgtk-3-0 \ + libpangocairo-1.0-0 \ + libpango-1.0-0 \ + libatk1.0-0 \ + libcairo2 \ + libdbus-1-3 \ + libxcomposite1 libxrender1 libxcursor1 libxi6 libxtst6 libxrandr2 libxss1 xauth xvfb \ + libasound2 \ + libatk-bridge2.0-0 \ + libatspi2.0-0 \ + libcups2 \ + jq net-tools git-lfs unzip pkg-config zip \ + libaio1 libaio-dev \ + netcat \ + libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb + diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh new file mode 100755 index 0000000..08f2b7b --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy-user.sh @@ -0,0 +1,3 @@ +#!/bin/bash +source "./deploy-common.sh" + diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh new file mode 100755 index 0000000..aaa628a --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -eo pipefail + +source "/etc/profile" +source "./deploy-slim.sh" +source "./deploy-common.sh" + +NODEVER="v12.19.0-linux-x64" +# Install Node +wget -O - "https://nodejs.org/dist/v12.19.0/node-${NODEVER}.tar.xz" | tar Jvxf - -C "/tmp" + +# Move node to /usr/local so it's in the path +pushd /tmp/node-${NODEVER} +/bin/rm -f CHANGELOG.md README.md LICENSE +/bin/cp -r * /usr/local +popd +/bin/rm -rf /tmp/node-${NODEVER} + +# Resource the profile so we know our path is being honoured +source "/etc/profile" +# Install Yarn. Not in the path yet so fully qualified +npm i -g yarn + +# Install Go +filename="go1.16.1.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "3edc22f8332231c3ba8be246f184b736b8d28f06ce24f08168d8ecf052549769" +untar_file "/tmp/$filename" + +# Install golangci-lint +GOLANGCILINT_VERSION=1.37.1 +filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" +get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ + "/tmp/$filename.tar.gz" \ + "1929425d7733d136b342395c77f171d459aa89b198933465ec4c854aa34c41a2" +untar_file "/tmp/$filename.tar.gz" +ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint +ln -s /usr/local/go/bin/go /usr/local/bin/go +ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt +chmod 755 /usr/local/bin/golangci-lint + +# Install code climate +get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ + "/usr/local/bin/cc-test-reporter" \ + "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" +chmod 755 /usr/local/bin/cc-test-reporter + +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" +chmod +x /usr/local/bin/grabpl + +# Install Mage +mkdir -pv /tmp/mage $HOME/go/bin +git clone https://github.com/magefile/mage.git /tmp/mage +pushd /tmp/mage && go run bootstrap.go && popd +mv $HOME/go/bin/mage /usr/local/bin +# Cleanup after yourself +/bin/rm -rf /tmp/mage +/bin/rm -rf $HOME/go + +# add cypress +yarn global add cypress +# verify cypress install +cypress verify + +# Get the size down +/bin/rm -rf /var/lib/apt/lists diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml new file mode 100644 index 0000000..5d6297c --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' +services: + citest: + image: "debian:buster-slim" + user: root + volumes: + - ../scripts:/root/scripts + - ../install:/root/install + - ${HOME}/.ssh:/root/.ssh + - ../../..:/root/grafana-toolkit + cibuilt: + image: "grafana/grafana-plugin-ci-e2e" + user: root + volumes: + - ../scripts:/root/scripts + - ../install:/root/install + - ${HOME}/.ssh:/root/.ssh + - ../../..:/root/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh new file mode 100755 index 0000000..2552c96 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/test/start.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +function finish { + echo "Exiting and cleaning up docker image" + docker-compose down +} +trap finish EXIT + +# Enter the docker container +if [ "$1" = "built" ]; then + docker-compose run cibuilt sh -c "cd /root; exec bash --login -i" +else + docker-compose run citest sh -c "cd /root; exec bash --login -i" +fi diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile new file mode 100644 index 0000000..4622819 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile @@ -0,0 +1,8 @@ +FROM debian:testing-20210111-slim +USER root +COPY scripts scripts +WORKDIR scripts +RUN apt-get update && \ + apt-get install -y wget && \ + ./deploy.sh +COPY install/gget /usr/local/bin/gget diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md b/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md new file mode 100644 index 0000000..51f67cf --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/README.md @@ -0,0 +1,61 @@ +# Using this docker image + +Currently tagged and uploaded to dockerhub as srclosson/integrations-ci-build + +Based off of `circleci/node:12-browsers` + +## User +The user will be `circleci` +The home directory will be `/home/circleci` + +## Node +- node 12 is installed +- yarn is installed globally +- npm is installed globally + +## Go +- Go is installed in `/usr/local/bin/go` +- golangci-lint is installed in `/usr/local/bin/golangci-lint` +- mage is installed in `/home/circleci/go/bin/mage` + +All of the above directories are in the path, so there is no need to specify fully qualified paths. + +## Grafana +- Installed in `/home/circleci/src/grafana` +- `yarn install` has been run + +## Integration/Release Testing +There are 4 previous versions pre-downloaded to /usr/local/grafana. These versions are: +1. 6.6.2 +2. 6.5.3 +3. 6.4.5 +4. 6.3.7 + +To test, your CircleCI config will need a run section with something similar to the following +``` +- run: + name: Setup Grafana (local install) + command: | + sudo dpkg -i /usr/local/grafana/deb/grafana_6.6.2_amd64.deb + sudo cp ci/grafana-test-env/custom.ini /usr/share/grafana/conf/custom.ini + sudo cp ci/grafana-test-env/custom.ini /etc/grafana/grafana.ini + sudo service grafana-server start + grafana-cli --version +``` + + +# Building +To build, cd to `/packages/grafana-toolkit/docker/grafana-plugin-ci` +``` +./build.sh +``` + +# Developing/Testing +To test, you should have docker-compose installed. +``` +cd test +./start.sh +``` + +You will be in /home/circleci/test with the buildscripts installed to the local directory. +Do your edits/run tests. When saving, your edits will be available in the container immediately. diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh new file mode 100755 index 0000000..3d8d3d7 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -eo pipefail + +source ./common.sh + +docker build -t ${DOCKER_IMAGE_NAME} . +docker push $DOCKER_IMAGE_NAME + diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh new file mode 100755 index 0000000..ebd1e7a --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## Common variable declarations +## + +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.2.0" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget b/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget new file mode 100755 index 0000000..955eb47 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/install/gget @@ -0,0 +1,63 @@ +#!/bin/bash +## +# gget +# A script to get and install grafana versions +# for usage information see "show_help" below. +# + +latest=$(curl -s 'https://raw.githubusercontent.com/grafana/grafana/master/latest.json' | jq -r '.stable') +canary=$(curl -s "https://grafana.com/api/grafana/versions" | jq ".items[0].version" | tr -d '"') + +show_help() { + echo "Usage: gget " + echo "" + echo "where can be:" + echo " 1) A version from https://grafana.com/grafana/download (ex x.y.z)" + echo " 2) latest (currently $latest)" + echo " 3) canary (currently $canary)" + echo "" + echo " -h, --help: Display this help message" + echo "" + exit 0 +} + +opts=$(getopt -o h --long help -n 'gget' -- "$@") +[ $? -eq 0 ] || { + show_help +} + +eval set -- "$opts" +while true; do + case "$1" in + -h | --help) + show_help + ;; + --) + shift + break + ;; + *) + break + ;; + esac + shift +done + +[ -z "$1" ] && show_help + +# Make sure the script is being run as root +if [ $EUID -ne 0 ]; then + echo "This script must be run as root" + exit 1 +fi + +## +# MAIN +# +# Enough setup, let's actually do something +# +version=$1 +[ "$version" == "latest" ] && version="$latest" +[ "$version" == "canary" ] && version="$canary" +wget "https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb" -O "/tmp/grafana_${version}_amd64.deb" +dpkg -i "/tmp/grafana_${version}_amd64.deb" && /bin/rm -rfv "/tmp/grafana_${version}_amd64.deb" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh new file mode 100755 index 0000000..524bb5e --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-common.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +## +# Script to deploy a docker image. Must return exit code 0 +# +do_exit() { + message="$1" + exit_code="$2" + + echo "$message" + exit $exit_code +} + + +## +# Get file, get's a file, validates the SHA +# @param filename +# @param expected sha value +# @returns 0 if successful, -1 of checksum validation failed. +# +get_file () { + [ -n "$1" ] && url=$1 || do_exit "url required" -1 + [ -n "$2" ] && dest=$2 || do_exit "destination required" -2 + sha=$3 + file=$(basename $dest) + + wget "$url" -O "$dest" + if [ -n "$sha" ]; then + echo "$sha $dest" | sha256sum --check --status || do_exit "Checksum validation failed for $file. Exiting" -1 + fi +} + +untar_file () { + [ -n "$1" ] && src=$1 || do_exit "src required" -1 + [ -n "$2" ] && dest=$2 || dest="/usr/local" + + tar -C "$dest" -xf "$src" && /bin/rm -rf "$src" +} \ No newline at end of file diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh new file mode 100755 index 0000000..08f2b7b --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy-user.sh @@ -0,0 +1,3 @@ +#!/bin/bash +source "./deploy-common.sh" + diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh new file mode 100755 index 0000000..25fc988 --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash +source "./deploy-common.sh" + +# Install Go +filename="go1.16.1.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "3edc22f8332231c3ba8be246f184b736b8d28f06ce24f08168d8ecf052549769" +untar_file "/tmp/$filename" + +# Install golangci-lint +GOLANGCILINT_VERSION=1.37.0 +filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" +get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ + "/tmp/$filename.tar.gz" \ + "5fe9852e754b621c5264fb8ac810a75033e7f18e972315a60c5c3f8a37b3cb88" +untar_file "/tmp/$filename.tar.gz" +ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint +ln -s /usr/local/go/bin/go /usr/local/bin/go +ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt +chmod 755 /usr/local/bin/golangci-lint + +# Install dependencies +apt-get update -y && apt-get install -y adduser libfontconfig1 locate && /bin/rm -rf /var/lib/apt/lists/* + +# Install code climate +get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ + "/usr/local/bin/cc-test-reporter" \ + "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" +chmod 755 /usr/local/bin/cc-test-reporter + +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" +chmod +x /usr/local/bin/grabpl + +# Install Mage +mkdir -pv /tmp/mage $HOME/go/bin +git clone https://github.com/magefile/mage.git /tmp/mage +pushd /tmp/mage && go run bootstrap.go && popd +mv $HOME/go/bin/mage /usr/local/bin + +GOOGLE_SDK_VERSION=316.0.0 +GOOGLE_SDK_CHECKSUM=96a0b75474dbfa9f3d46dcdec7a4d68a664cb7d57fade5710fe88b1fdf6babb3 + +curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz +echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status +tar xvzf google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz -C /opt +rm google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz +ln -s /opt/google-cloud-sdk/bin/gsutil /usr/bin/gsutil +ln -s /opt/google-cloud-sdk/bin/gcloud /usr/bin/gcloud + +# Cleanup after yourself +/bin/rm -rf /tmp/mage +/bin/rm -rf $HOME/go + +# Perform user specific initialization +sudo -u circleci ./deploy-user.sh + +# Get the size down +/bin/rm -rf /var/lib/apt/lists diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml new file mode 100644 index 0000000..d734b3a --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3' +services: + citest: + image: "circleci/node:12-browsers" + user: root + volumes: + - ../scripts:/home/circleci/scripts + - ../install:/home/circleci/install + - ${HOME}/.ssh:/root/.ssh + - ../../..:/home/circleci/grafana-toolkit diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh new file mode 100755 index 0000000..af3d91c --- /dev/null +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/test/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +function finish { + echo "Exiting and cleaning up docker image" + docker-compose down +} +trap finish EXIT + +# Enter the docker container +docker-compose run citest bash -c "cd /home/circleci; exec bash --login -i" diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json new file mode 100644 index 0000000..3326e1a --- /dev/null +++ b/packages/grafana-toolkit/package.json @@ -0,0 +1,114 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/toolkit", + "version": "7.5.5", + "description": "Grafana Toolkit", + "keywords": [ + "grafana", + "cli", + "plugins", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-toolkit" + }, + "bin": { + "grafana-toolkit": "./bin/grafana-toolkit.js" + }, + "scripts": { + "build": "grafana-toolkit toolkit:build", + "clean": "rimraf ./dist ./compiled", + "precommit": "npm run lint & npm run typecheck", + "typecheck": "tsc --noEmit" + }, + "main": "src/index.ts", + "dependencies": { + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.0", + "@grafana/data": "7.5.5", + "@grafana/eslint-config": "2.3.0", + "@grafana/tsconfig": "^1.0.0-rc1", + "@grafana/ui": "7.5.5", + "@types/command-exists": "^1.2.0", + "@types/expect-puppeteer": "3.3.1", + "@types/fs-extra": "^8.1.0", + "@types/inquirer": "^6.0.3", + "@types/jest": "26.0.15", + "@types/node": "^12.0.4", + "@types/prettier": "^1.16.4", + "@types/puppeteer-core": "1.9.0", + "@types/react-dev-utils": "^9.0.4", + "@types/rimraf": "^2.0.3", + "@types/semver": "^6.0.0", + "@types/tmp": "^0.1.0", + "@types/webpack": "4.41.7", + "@typescript-eslint/eslint-plugin": "4.15.0", + "@typescript-eslint/parser": "4.15.0", + "axios": "0.21.1", + "babel-jest": "26.6.3", + "babel-loader": "8.1.0", + "babel-plugin-angularjs-annotate": "0.10.0", + "chalk": "^2.4.2", + "command-exists": "^1.2.8", + "commander": "^5.0.0", + "concurrently": "4.1.0", + "copy-webpack-plugin": "5.1.2", + "css-loader": "3.4.2", + "eslint": "7.21.0", + "eslint-config-prettier": "7.2.0", + "eslint-plugin-jsdoc": "31.6.1", + "eslint-plugin-prettier": "3.3.1", + "eslint-plugin-react-hooks": "4.2.0", + "execa": "^2.0.0", + "expect-puppeteer": "4.1.1", + "file-loader": "5.0.2", + "fork-ts-checker-webpack-plugin": "1.0.0", + "fs-extra": "^8.1.0", + "globby": "^10.0.1", + "html-loader": "0.5.5", + "html-webpack-plugin": "^3.2.0", + "inquirer": "^6.3.1", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", + "jest-coverage-badges": "^1.1.2", + "jest-environment-jsdom-fifteen": "^1.0.2", + "jest-junit": "^6.4.0", + "less": "^3.11.1", + "less-loader": "^5.0.0", + "lodash": "4.17.21", + "md5-file": "^4.0.0", + "mini-css-extract-plugin": "^0.7.0", + "optimize-css-assets-webpack-plugin": "^5.0.3", + "ora": "^4.0.3", + "pixelmatch": "^5.1.0", + "pngjs": "^3.4.0", + "postcss-flexbugs-fixes": "4.2.0", + "postcss-loader": "3.0.0", + "postcss-preset-env": "6.7.0", + "prettier": "^2.2.1", + "puppeteer-core": "1.18.1", + "react-dev-utils": "^10.2.1", + "replace-in-file": "^4.1.0", + "replace-in-file-webpack-plugin": "^1.0.6", + "rimraf": "^3.0.0", + "sass": "1.27.0", + "sass-loader": "8.0.2", + "semver": "^7.1.3", + "simple-git": "^1.132.0", + "style-loader": "1.1.3", + "terser-webpack-plugin": "2.3.7", + "ts-jest": "26.4.4", + "ts-loader": "6.2.1", + "ts-node": "9.0.0", + "tslib": "2.0.3", + "typescript": "4.1.2", + "url-loader": "^2.0.1", + "webpack": "4.41.5" + }, + "_moduleAliases": { + "puppeteer": "node_modules/puppeteer-core" + } +} diff --git a/packages/grafana-toolkit/src/cli/index.d.ts b/packages/grafana-toolkit/src/cli/index.d.ts new file mode 100644 index 0000000..5f7aeec --- /dev/null +++ b/packages/grafana-toolkit/src/cli/index.d.ts @@ -0,0 +1 @@ +export type Task = (options: T) => Promise; diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts new file mode 100644 index 0000000..65d6979 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -0,0 +1,293 @@ +// @ts-ignore +import chalk from 'chalk'; +import program from 'commander'; +import { promises as fs } from 'fs'; +import { execTask } from './utils/execTask'; +import { startTask } from './tasks/core.start'; +import { changelogTask } from './tasks/changelog'; +import { cherryPickTask } from './tasks/cherrypick'; +import { templateTask } from './tasks/template'; +import { pluginBuildTask } from './tasks/plugin.build'; +import { toolkitBuildTask } from './tasks/toolkit.build'; +import { pluginTestTask } from './tasks/plugin.tests'; +import { searchTestDataSetupTask } from './tasks/searchTestDataSetup'; +import { closeMilestoneTask } from './tasks/closeMilestone'; +import { pluginDevTask } from './tasks/plugin.dev'; +import { githubPublishTask } from './tasks/plugin.utils'; +import { pluginUpdateTask } from './tasks/plugin.update'; +import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci'; +import { buildPackageTask } from './tasks/package.build'; +import { pluginCreateTask } from './tasks/plugin.create'; +import { pluginSignTask } from './tasks/plugin.sign'; +import { bundleManagedTask } from './tasks/plugin/bundle.managed'; +import { componentCreateTask } from './tasks/component.create'; +import { nodeVersionCheckerTask } from './tasks/nodeVersionChecker'; + +export const run = (includeInternalScripts = false) => { + if (includeInternalScripts) { + program.option('-d, --depreciate ', 'Inform about npm script deprecation', (v) => v.split(',')); + program + .command('core:start') + .option('-h, --hot', 'Run front-end with HRM enabled') + .option('-T, --noTsCheck', 'Run bundler without TS type checking') + .option('-t, --watchTheme', 'Watch for theme changes and regenerate variables.scss files') + .description('Starts Grafana front-end in development mode with watch enabled') + .action(async (cmd) => { + await execTask(startTask)({ + watchThemes: cmd.watchTheme, + noTsCheck: cmd.noTsCheck, + hot: cmd.hot, + }); + }); + + program + .command('package:build') + .option('-s, --scope ', 'packages=[data|runtime|ui|toolkit|e2e|e2e-selectors]') + .description('Builds @grafana/* package to packages/grafana-*/dist') + .action(async (cmd) => { + await execTask(buildPackageTask)({ + scope: cmd.scope, + }); + }); + + program + .command('changelog') + .option('-m, --milestone ', 'Specify milestone') + .description('Builds changelog markdown') + .action(async (cmd) => { + if (!cmd.milestone) { + console.log('Please specify milestone, example: -m '); + return; + } + + await execTask(changelogTask)({ + milestone: cmd.milestone, + silent: true, + }); + }); + + program + .command('cherrypick') + .option('-e, --enterprise', 'Run task for grafana-enterprise') + .description('Helps find commits to cherry pick') + .action(async (cmd) => { + await execTask(cherryPickTask)({ enterprise: !!cmd.enterprise }); + }); + + program + .command('node-version-check') + .description('Verify node version') + .action(async (cmd) => { + await execTask(nodeVersionCheckerTask)({}); + }); + + program + .command('debug:template') + .description('Just testing') + .action(async (cmd) => { + await execTask(templateTask)({}); + }); + + program + .command('toolkit:build') + .description('Prepares grafana/toolkit dist package') + .action(async (cmd) => { + await execTask(toolkitBuildTask)({}); + }); + + program + .command('searchTestData') + .option('-c, --count ', 'Specify number of dashboards') + .description('Setup test data for search') + .action(async (cmd) => { + await execTask(searchTestDataSetupTask)({ count: cmd.count }); + }); + + program + .command('close-milestone') + .option('-m, --milestone ', 'Specify milestone') + .option('--dryRun', 'Only simulate actions') + .description('Helps ends a milestone by removing the cherry-pick label and closing it') + .action(async (cmd) => { + if (!cmd.milestone) { + console.log('Please specify milestone, example: -m '); + return; + } + + await execTask(closeMilestoneTask)({ + milestone: cmd.milestone, + dryRun: !!cmd.dryRun, + }); + }); + + // React generator + program + .command('component:create') + .description( + 'Scaffold React components. Optionally add test, story and .mdx files. The components are created in the same dir the script is run from.' + ) + .action(async () => { + await execTask(componentCreateTask)({}); + }); + } + + program.option('-v, --version', 'Toolkit version').action(async () => { + const pkg = await fs.readFile(`${__dirname}/../../package.json`, 'utf8'); + const { version } = JSON.parse(pkg); + console.log(`v${version}`); + }); + + program + .command('plugin:create [name]') + .description('Creates plugin from template') + .action(async (cmd) => { + await execTask(pluginCreateTask)({ name: cmd, silent: true }); + }); + + program + .command('plugin:build') + .option('--maxJestWorkers |', 'Limit number of Jest workers spawned') + .option('--coverage', 'Run code coverage', false) + .option('--preserveConsole', 'Preserves console calls', false) + .description('Prepares plugin dist package') + .action(async (cmd) => { + await execTask(pluginBuildTask)({ + coverage: cmd.coverage, + silent: true, + maxJestWorkers: cmd.maxJestWorkers, + preserveConsole: cmd.preserveConsole, + }); + }); + + program + .command('plugin:dev') + .option('-w, --watch', 'Run plugin development mode with watch enabled') + .option('--yarnlink', 'symlink this project to the local grafana/toolkit') + .description('Starts plugin dev mode') + .action(async (cmd) => { + await execTask(pluginDevTask)({ + watch: !!cmd.watch, + yarnlink: !!cmd.yarnlink, + silent: true, + }); + }); + + program + .command('plugin:test') + .option('-u, --updateSnapshot', 'Run snapshots update') + .option('--coverage', 'Run code coverage') + .option('--watch', 'Run tests in interactive watch mode') + .option('--testPathPattern ', 'Run only tests with a path that matches the regex') + .option('--testNamePattern ', 'Run only tests with a name that matches the regex') + .option('--maxWorkers |', 'Limit number of workers spawned') + .description('Executes plugin tests') + .action(async (cmd) => { + await execTask(pluginTestTask)({ + updateSnapshot: !!cmd.updateSnapshot, + coverage: !!cmd.coverage, + watch: !!cmd.watch, + testPathPattern: cmd.testPathPattern, + testNamePattern: cmd.testNamePattern, + maxWorkers: cmd.maxWorkers, + silent: true, + }); + }); + + program + .command('plugin:sign') + .option('--signatureType ', 'Signature Type') + .option('--rootUrls ', 'Root URLs') + .description('Create a plugin signature') + .action(async (cmd) => { + await execTask(pluginSignTask)({ + signatureType: cmd.signatureType, + rootUrls: cmd.rootUrls, + silent: true, + }); + }); + + program + .command('plugin:ci-build') + .option('--finish', 'move all results to the jobs folder', false) + .option('--maxJestWorkers |', 'Limit number of Jest workers spawned') + .description('Build the plugin, leaving results in /dist and /coverage') + .action(async (cmd) => { + await execTask(ciBuildPluginTask)({ + finish: cmd.finish, + maxJestWorkers: cmd.maxJestWorkers, + }); + }); + + program + .command('plugin:ci-docs') + .description('Build the HTML docs') + .action(async (cmd) => { + await execTask(ciBuildPluginDocsTask)({}); + }); + + program + .command('plugin:ci-package') + .option('--signatureType ', 'Signature Type') + .option('--rootUrls ', 'Root URLs') + .option('--signing-admin', 'Use the admin API endpoint for signing the manifest. (deprecated)', false) + .description('Create a zip packages for the plugin') + .action(async (cmd) => { + await execTask(ciPackagePluginTask)({ + signatureType: cmd.signatureType, + rootUrls: cmd.rootUrls, + }); + }); + + program + .command('plugin:ci-report') + .description('Build a report for this whole process') + .option('--upload', 'upload packages also') + .action(async (cmd) => { + await execTask(ciPluginReportTask)({ + upload: cmd.upload, + }); + }); + + program + .command('plugin:bundle-managed') + .description('Builds managed plugins') + .action(async (cmd) => { + await execTask(bundleManagedTask)({}); + }); + + program + .command('plugin:github-publish') + .option('--dryrun', 'Do a dry run only', false) + .option('--verbose', 'Print verbose', false) + .option('--commitHash ', 'Specify the commit hash') + .description('Publish to github') + .action(async (cmd) => { + await execTask(githubPublishTask)({ + dryrun: cmd.dryrun, + verbose: cmd.verbose, + commitHash: cmd.commitHash, + }); + }); + + program + .command('plugin:update-circleci') + .description('Update plugin') + .action(async (cmd) => { + await execTask(pluginUpdateTask)({}); + }); + + program.on('command:*', () => { + console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' ')); + process.exit(1); + }); + + program.parse(process.argv); + + if (program.depreciate && program.depreciate.length === 2) { + console.log( + chalk.yellow.bold( + `[NPM script depreciation] ${program.depreciate[0]} is deprecated! Use ${program.depreciate[1]} instead!` + ) + ); + } +}; diff --git a/packages/grafana-toolkit/src/cli/tasks/changelog.ts b/packages/grafana-toolkit/src/cli/tasks/changelog.ts new file mode 100644 index 0000000..7afd435 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/changelog.ts @@ -0,0 +1,148 @@ +// @ts-ignore +import * as _ from 'lodash'; +import { Task } from './task'; +import GithubClient from '../utils/githubClient'; +import difference from 'lodash/difference'; +import chalk from 'chalk'; +import { useSpinner } from '../utils/useSpinner'; + +interface ChangelogOptions { + milestone: string; +} + +const filterBugs = (item: any) => { + if (item.title.match(/fix|fixes/i)) { + return true; + } + if (item.labels.find((label: any) => label.name === 'type/bug')) { + return true; + } + return false; +}; + +const getPackageChangelog = (packageName: string, issues: any[]) => { + if (issues.length === 0) { + return ''; + } + + let markdown = chalk.bold.yellow(`\n\n/*** ${packageName} changelog ***/\n\n`); + const bugs = _.sortBy(issues.filter(filterBugs), 'title'); + const notBugs = _.sortBy(difference(issues, bugs), 'title'); + + if (notBugs.length > 0) { + markdown += '### Features / Enhancements\n'; + for (const item of notBugs) { + markdown += getMarkdownLineForIssue(item); + } + } + + if (bugs.length > 0) { + markdown += '\n### Bug Fixes\n'; + for (const item of bugs) { + markdown += getMarkdownLineForIssue(item); + } + } + + return markdown; +}; + +const changelogTaskRunner = ({ milestone }: ChangelogOptions) => + useSpinner('Generating changelog', async () => { + const githubClient = new GithubClient(); + const client = githubClient.client; + + if (!/^\d+$/.test(milestone)) { + console.log('Use milestone number not title, find number in milestone url'); + return; + } + + let res = await client.get('/issues', { + params: { + state: 'closed', + per_page: 100, + labels: 'add to changelog', + milestone: milestone, + }, + }); + + const data: any[] = res.data; + + while (res.headers.link) { + const links = parseLink(res.headers.link); + if (links.next) { + res = await client.get(links.next); + data.push(...res.data); + } else { + break; + } + } + + const mergedIssues = []; + for (const item of data) { + if (!item.pull_request) { + // it's an issue, not pull request + mergedIssues.push(item); + continue; + } + const isMerged = await client.get(item.pull_request.url + '/merge'); + if (isMerged.status === 204) { + mergedIssues.push(item); + } + } + const issues = _.sortBy(mergedIssues, 'title'); + + const toolkitIssues = issues.filter((item: any) => + item.labels.find((label: any) => label.name === 'area/grafana/toolkit') + ); + const grafanaUiIssues = issues.filter((item: any) => + item.labels.find((label: any) => label.name === 'area/grafana/ui') + ); + + let markdown = ''; + + markdown += getPackageChangelog('Grafana', issues); + markdown += getPackageChangelog('grafana-toolkit', toolkitIssues); + markdown += getPackageChangelog('grafana-ui', grafanaUiIssues); + + console.log(markdown); + }); + +function getMarkdownLineForIssue(item: any) { + const githubGrafanaUrl = 'https://github.com/grafana/grafana'; + let markdown = ''; + let title: string = item.title.replace(/^([^:]*)/, (_match: any, g1: any) => { + return `**${g1}**`; + }); + title = title.trim(); + if (title[title.length - 1] === '.') { + title = title.slice(0, -1); + } + + if (!item.pull_request) { + markdown += '* ' + title + '.'; + markdown += ` [#${item.number}](${githubGrafanaUrl}/issues/${item.number})`; + } else { + markdown += '* ' + title + '.'; + markdown += ` [#${item.number}](${githubGrafanaUrl}/pull/${item.number})`; + markdown += `, [@${item.user.login}](${item.user.html_url})`; + } + + markdown += '\n'; + + return markdown; +} + +function parseLink(s: any) { + const output: any = {}; + const regex = /<([^>]+)>; rel="([^"]+)"/g; + + let m; + while ((m = regex.exec(s))) { + const [, v, k] = m; + output[k] = v; + } + + return output; +} + +export const changelogTask = new Task('Changelog generator task', changelogTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/cherrypick.ts b/packages/grafana-toolkit/src/cli/tasks/cherrypick.ts new file mode 100644 index 0000000..1a446a1 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/cherrypick.ts @@ -0,0 +1,95 @@ +import { Task, TaskRunner } from './task'; +import GithubClient from '../utils/githubClient'; + +interface CherryPickOptions { + enterprise: boolean; +} + +// https://github.com/lisposter/github-pagination/blob/master/lib/octopage.js +const pagingParser = (linkStr: string): { prev?: string; next?: string; last?: string; first?: string } => { + return linkStr + .split(',') + .map((rel) => { + //@ts-ignore + return rel.split(';').map((curr, idx) => { + if (idx === 0) { + //@ts-ignore + return /[^_]page=(\d+)/.exec(curr)[1]; + } + if (idx === 1) { + //@ts-ignore + return /rel="(.+)"/.exec(curr)[1]; + } + }); + }) + .reduce(function (obj, curr, i) { + //@ts-ignore + obj[curr[1]] = curr[0]; + return obj; + }, {}); +}; + +const getIssues = async (client: any, page: string) => { + const result = await client.get('/issues', { + params: { + state: 'closed', + per_page: 100, + labels: 'cherry-pick needed', + sort: 'closed', + direction: 'asc', + page, + }, + }); + + let data = result.data; + if (!result.headers.link) { + return data; + } + + const pages = pagingParser(result.headers.link); + + if (pages.next) { + const nextPage = await getIssues(client, pages.next); + data = data.concat(nextPage); + } + return data; +}; + +const cherryPickRunner: TaskRunner = async ({ enterprise }) => { + const githubClient = new GithubClient({ enterprise }); + const client = githubClient.client; + const results = await getIssues(client, '1'); + + // sort by closed date ASC + results.sort((a: any, b: any) => { + return new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime(); + }); + + let commands = ''; + + console.log('--------------------------------------------------------------------'); + console.log('Printing PRs with cherry-pick-needed, in ASC merge date order'); + console.log('--------------------------------------------------------------------'); + + for (const item of results) { + if (!item.milestone) { + console.log(item.number + ' missing milestone!'); + continue; + } + const issueDetails = await client.get(item.pull_request.url); + + if (!issueDetails.data.merged) { + continue; + } + + console.log(`* ${item.title}, (#${item.number}), merge-sha: ${issueDetails.data.merge_commit_sha}`); + commands += `git cherry-pick -x ${issueDetails.data.merge_commit_sha}\n`; + } + + console.log('--------------------------------------------------------------------'); + console.log('Commands (in order of how they should be executed)'); + console.log('--------------------------------------------------------------------'); + console.log(commands); +}; + +export const cherryPickTask = new Task('Cherry pick task', cherryPickRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts b/packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts new file mode 100644 index 0000000..0ac396a --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts @@ -0,0 +1,95 @@ +import { Task, TaskRunner } from './task'; +import GithubClient from '../utils/githubClient'; + +interface CloseMilestoneOptions { + milestone: string; + dryRun: boolean; +} + +const closeMilestoneTaskRunner: TaskRunner = async ({ milestone, dryRun }) => { + const githubClient = new GithubClient({ required: true }); + + const cherryPickLabel = 'cherry-pick needed'; + const client = githubClient.client; + + if (!/^\d+$/.test(milestone)) { + console.log('Use milestone number not title, find number in milestone url'); + return; + } + + if (dryRun) { + console.log('dry run is enabled'); + } + + const milestoneRes = await client.get(`/milestones/${milestone}`, {}); + + const milestoneState = milestoneRes.data.state; + + if (milestoneState === 'closed') { + console.log('milestone already closed. ✅'); + return; + } + + console.log('fetching issues/PRs of the milestone ⏬'); + + let totalIssues = 0; + + while (true) { + // Get first 100 issues/PRs with the label cherry-pick + // Every pull request is actually an issue + const issuesRes = await client.get('/issues', { + params: { + state: 'closed', + labels: cherryPickLabel, + per_page: 100, + milestone: milestone, + }, + }); + + if (issuesRes.data.length < 1) { + break; + } + + const comparativeStr = totalIssues === 0 ? ' ' : ' more '; + console.log(`found ${issuesRes.data.length}${comparativeStr}issues to remove the cherry-pick label from 🔎`); + totalIssues += issuesRes.data.length; + + for (const issue of issuesRes.data) { + // the reason for using stdout.write is for achieving 'action -> result' on + // the same line + process.stdout.write(`🔧removing label from issue #${issue.number} 🗑...`); + if (!dryRun) { + const resDelete = await client.delete(`/issues/${issue.number}/labels/${cherryPickLabel}`, {}); + if (resDelete.status === 200) { + process.stdout.write('done ✅\n'); + } else { + console.log('failed ❌'); + } + } + } + } + + if (totalIssues === 0) { + console.log('no issues to remove label from'); + } else { + console.log(`cleaned up ${totalIssues} issues/prs ⚡️`); + } + + if (!dryRun) { + const resClose = await client.patch(`/milestones/${milestone}`, { + state: 'closed', + }); + + if (resClose.status === 200) { + console.log('milestone closed 🙌'); + } else { + console.log('failed to close the milestone, response:'); + console.log(resClose); + } + } +}; + +export const closeMilestoneTask = new Task( + 'Close Milestone generator task', + closeMilestoneTaskRunner +); diff --git a/packages/grafana-toolkit/src/cli/tasks/component.create.ts b/packages/grafana-toolkit/src/cli/tasks/component.create.ts new file mode 100644 index 0000000..c730cb1 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/component.create.ts @@ -0,0 +1,86 @@ +import { Task, TaskRunner } from './task'; +import fs from 'fs'; +import _ from 'lodash'; +import { prompt } from 'inquirer'; +import { pascalCase } from '../utils/pascalCase'; +import { promptConfirm, promptInput, promptList } from '../utils/prompt'; +import { componentTpl, docsTpl, storyTpl, testTpl } from '../templates'; + +interface Details { + name?: string; + hasStory: boolean; + group?: string; + isStoryPublic: boolean; + hasTests: boolean; +} + +interface GeneratorOptions { + details: Details; + path: string; +} + +type ComponentGenerator = (options: GeneratorOptions) => Promise; + +const componentGroups = [ + { name: 'General', value: 'General' }, + { name: 'Forms', value: 'Forms' }, + { name: 'Panel', value: 'Panel' }, + { name: 'Visualizations', value: 'Visualizations' }, + { name: 'Others', value: 'Others' }, +]; + +export const promptDetails = () => { + return prompt
([ + promptInput('name', 'Component name', true), + promptConfirm('hasTests', "Generate component's test file?"), + promptConfirm('hasStory', "Generate component's story file?"), + promptConfirm( + 'isStoryPublic', + 'Generate public story? (Selecting "No" will create an internal story)', + true, + ({ hasStory }) => hasStory + ), + promptList( + 'group', + 'Select component group for the story (e.g. Forms, Layout)', + () => componentGroups, + 0, + ({ hasStory }) => hasStory + ), + ]); +}; + +export const generateComponents: ComponentGenerator = async ({ details, path }) => { + const name = pascalCase(details.name); + const getCompiled = (template: string) => { + return _.template(template)({ ...details, name }); + }; + const filePath = `${path}/${name}`; + let paths = []; + + fs.writeFileSync(`${filePath}.tsx`, getCompiled(componentTpl)); + paths.push(`${filePath}.tsx`); + + if (details.hasTests) { + fs.writeFileSync(`${filePath}.test.tsx`, getCompiled(testTpl)); + paths.push(`${filePath}.test.tsx`); + } + + if (details.hasStory) { + const storyExt = details.isStoryPublic ? '.story.tsx' : '.story.internal.tsx'; + fs.writeFileSync(`${filePath}${storyExt}`, getCompiled(storyTpl)); + fs.writeFileSync(`${filePath}.mdx`, getCompiled(docsTpl)); + paths.push(`${filePath}${storyExt}`, `${filePath}.mdx`); + } + + console.log('Generated files:'); + console.log(paths.join('\n')); +}; + +const componentCreateRunner: TaskRunner = async () => { + const destPath = process.cwd(); + const details = await promptDetails(); + await generateComponents({ details, path: destPath }); +}; + +export const componentCreateTask = new Task('component:create', componentCreateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/core.start.ts b/packages/grafana-toolkit/src/cli/tasks/core.start.ts new file mode 100644 index 0000000..b9ca83c --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/core.start.ts @@ -0,0 +1,43 @@ +//@ts-ignore +import concurrently from 'concurrently'; +import { Task, TaskRunner } from './task'; + +interface StartTaskOptions { + watchThemes: boolean; + noTsCheck: boolean; + hot: boolean; +} + +const startTaskRunner: TaskRunner = async ({ watchThemes, noTsCheck, hot }) => { + const noTsCheckArg = noTsCheck ? 1 : 0; + const jobs = [ + watchThemes && { + command: 'nodemon -e ts -w ./packages/grafana-ui/src/themes -x yarn run themes:generate', + name: 'SASS variables generator', + }, + hot + ? { + command: 'webpack-dev-server --progress --colors --config scripts/webpack/webpack.hot.js', + name: 'Dev server', + } + : { + command: `webpack --progress --colors --watch --env.noTsCheck=${noTsCheckArg} --config scripts/webpack/webpack.dev.js`, + name: 'Webpack', + }, + ]; + + try { + await concurrently( + jobs.filter((job) => !!job), + { + killOthers: ['failure', 'failure'], + raw: true, + } + ); + } catch (e) { + console.error(e); + process.exit(1); + } +}; + +export const startTask = new Task('Core startTask', startTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts b/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts new file mode 100644 index 0000000..3e9942b --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/nodeVersionChecker.ts @@ -0,0 +1,76 @@ +import { Task, TaskRunner } from './task'; +import chalk from 'chalk'; +import { coerce, satisfies } from 'semver'; +import { readFileSync } from 'fs'; + +interface FailedVersionCheck { + file: string; + line: string; +} + +interface NodeVersionCheckerOptions {} + +const pattern = /(circleci\/|FROM )node\:([0-9]+(\.[0-9]+){0,2})/gm; +const packageJsonFile = 'package.json'; + +const failures: FailedVersionCheck[] = []; + +export const nodeVersionFiles = [packageJsonFile, 'Dockerfile']; + +const nodeVersionCheckerRunner: TaskRunner = async () => { + // Read version from package json and treat that as the expected version in all other locations + const packageJson = require(`${process.cwd()}/${packageJsonFile}`); + const expectedVersion = packageJson.engines.node; + + console.log(chalk.yellow(`Specified node version in package.json is: ${expectedVersion}`)); + + for (const file of nodeVersionFiles) { + const fileContent = readFileSync(`${process.cwd()}/${file}`); + const matches = fileContent.toString('utf8').match(pattern); + + if (!matches) { + continue; + } + + for (const match of matches) { + const actualVersion = coerce(match); + if (!actualVersion) { + failures.push({ + file, + line: match, + }); + continue; + } + + const satisfied = satisfies(actualVersion, expectedVersion); + if (!satisfied) { + failures.push({ + file, + line: match, + }); + } + } + } + + if (failures.length > 0) { + console.log(chalk.red('--------------------------------------------------------------------')); + console.log(chalk.red(`These entries don't satisfy the engine version in ${packageJsonFile}`)); + console.log(chalk.red('--------------------------------------------------------------------')); + + for (let index = 0; index < failures.length; index++) { + const failure = failures[index]; + console.log(chalk.green(`\tIn ${failure.file} the line ${failure.line} does not satisfy ${expectedVersion}.`)); + } + + throw new Error('Node versions not in sync'); + } + + console.log(chalk.yellow('--------------------------------------------------------------------')); + console.log(chalk.yellow('All node versions seem ok.')); + console.log(chalk.yellow('--------------------------------------------------------------------')); +}; + +export const nodeVersionCheckerTask = new Task( + 'Node Version Checker', + nodeVersionCheckerRunner +); diff --git a/packages/grafana-toolkit/src/cli/tasks/package.build.ts b/packages/grafana-toolkit/src/cli/tasks/package.build.ts new file mode 100644 index 0000000..0d72e4c --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/package.build.ts @@ -0,0 +1,91 @@ +import execa = require('execa'); +import { promises as fs } from 'fs'; +// @ts-ignore +import * as path from 'path'; +import chalk from 'chalk'; +import { useSpinner } from '../utils/useSpinner'; +import { Task, TaskRunner } from './task'; +import { cloneDeep } from 'lodash'; +import globby from 'globby'; + +const clean = (cwd: string) => useSpinner('Cleaning', () => execa('npm', ['run', 'clean'], { cwd })); + +const compile = (cwd: string) => + useSpinner('Compiling sources', () => execa('tsc', ['-p', './tsconfig.build.json'], { cwd })); + +const bundle = (cwd: string) => useSpinner('Bundling', () => execa('npm', ['run', 'bundle'], { cwd })); + +const preparePackage = async (packageDist: string, pkg: any) => { + pkg = cloneDeep(pkg); // avoid mutations + + pkg.main = 'index.js'; + pkg.types = 'index.d.ts'; + + const version: string = pkg.version; + const name: string = pkg.name; + const deps: any = pkg.dependencies; + + // Below we are adding cross-dependencies to Grafana's packages + // with the version being published + if (name.endsWith('/ui')) { + deps['@grafana/data'] = version; + } else if (name.endsWith('/runtime')) { + deps['@grafana/data'] = version; + deps['@grafana/ui'] = version; + } else if (name.endsWith('/toolkit')) { + deps['@grafana/data'] = version; + deps['@grafana/ui'] = version; + } + + await useSpinner('Updating package.json', () => + fs.writeFile(`${packageDist}/package.json`, JSON.stringify(pkg, null, 2)) + ); +}; + +const moveFiles = (fromPath: string, toPath: string) => { + const files = ['README.md', 'CHANGELOG.md', 'index.js']; + + return useSpinner(`Moving ${files.join(', ')} files`, () => { + const promises = files.map((file) => fs.copyFile(`${fromPath}/${file}`, `${toPath}/${file}`)); + return Promise.all(promises); + }); +}; + +const moveStaticFiles = async (packageRoot: string, pkg: any) => { + if (pkg.name.endsWith('/ui')) { + return useSpinner('Moving static files', async () => { + const staticFiles = await globby(`${packageRoot}/src/**/*.{png,svg,gif,jpg}`); + const pathSearch = new RegExp(`^${packageRoot}/src`); + const pathReplace = `${packageRoot}/compiled`; + const promises = staticFiles.map((file) => fs.copyFile(file, file.replace(pathSearch, pathReplace))); + await Promise.all(promises); + }); + } +}; + +interface PackageBuildOptions { + scope: string; +} + +const buildTaskRunner: TaskRunner = async ({ scope }) => { + if (!scope) { + throw new Error('Provide packages with -s, --scope '); + } + + const scopes = scope.split(',').map(async (s) => { + const packageRoot = path.resolve(__dirname, `../../../../grafana-${s}`); + const packageDist = `${packageRoot}/dist`; + const pkg = require(`${packageRoot}/package.json`); + console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); + await clean(packageRoot); + await compile(packageRoot); + await moveStaticFiles(packageRoot, pkg); + await bundle(packageRoot); + await preparePackage(packageDist, pkg); + await moveFiles(packageRoot, packageDist); + }); + + await Promise.all(scopes); +}; + +export const buildPackageTask = new Task('Package build', buildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts new file mode 100644 index 0000000..992a9b0 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts @@ -0,0 +1,139 @@ +import { useSpinner } from '../utils/useSpinner'; +import { testPlugin } from './plugin/tests'; +import { Task, TaskRunner } from './task'; +import rimrafCallback from 'rimraf'; +import { resolve as resolvePath } from 'path'; +import { promisify } from 'util'; +import globby from 'globby'; +import execa from 'execa'; +import { constants as fsConstants, promises as fs } from 'fs'; +import { CLIEngine } from 'eslint'; +import { bundlePlugin as bundleFn, PluginBundleOptions } from './plugin/bundle'; + +const { access, copyFile } = fs; +const { COPYFILE_EXCL } = fsConstants; +const rimraf = promisify(rimrafCallback); + +interface PluginBuildOptions { + coverage: boolean; + maxJestWorkers?: string; + preserveConsole?: boolean; +} + +interface Fixable { + fix?: boolean; +} + +const bundlePlugin = (options: PluginBundleOptions) => useSpinner('Compiling...', () => bundleFn(options)); + +// @ts-ignore +const clean = () => useSpinner('Cleaning', () => rimraf(`${process.cwd()}/dist`)); + +const copyIfNonExistent = (srcPath: string, destPath: string) => + copyFile(srcPath, destPath, COPYFILE_EXCL) + .then(() => console.log(`Created: ${destPath}`)) + .catch((error) => { + if (error.code !== 'EEXIST') { + throw error; + } + }); + +export const prepare = () => + useSpinner('Preparing', () => + Promise.all([ + // Remove local dependencies for @grafana/data/node_modules + // See: https://github.com/grafana/grafana/issues/26748 + rimraf(resolvePath(__dirname, 'node_modules/@grafana/data/node_modules')), + + // Copy only if local tsconfig does not exist. Otherwise this will work, but have odd behavior + copyIfNonExistent( + resolvePath(__dirname, '../../config/tsconfig.plugin.local.json'), + resolvePath(process.cwd(), 'tsconfig.json') + ), + // Copy only if local prettierrc does not exist. Otherwise this will work, but have odd behavior + copyIfNonExistent( + resolvePath(__dirname, '../../config/prettier.plugin.rc.js'), + resolvePath(process.cwd(), '.prettierrc.js') + ), + ]) + ); + +export const versions = async () => { + const nodeVersion = await execa('node', ['--version']); + console.log(`Using Node.js ${nodeVersion}`); + + const toolkitVersion = await execa('grafana-toolkit', ['--version']); + console.log(`Using @grafana/toolkit ${toolkitVersion}`); +}; + +// @ts-ignore +const typecheckPlugin = () => useSpinner('Typechecking', () => execa('tsc', ['--noEmit'])); + +const getTypescriptSources = () => globby(resolvePath(process.cwd(), 'src/**/*.+(ts|tsx)')); + +// @ts-ignore +const getStylesSources = () => globby(resolvePath(process.cwd(), 'src/**/*.+(scss|css)')); + +export const lintPlugin = ({ fix }: Fixable = {}) => + useSpinner('Linting', async () => { + try { + // Show a warning if the tslint file exists + await access(resolvePath(process.cwd(), 'tslint.json')); + console.log('\n'); + console.log('--------------------------------------------------------------'); + console.log('NOTE: @grafana/toolkit has migrated to use eslint'); + console.log('Update your configs to use .eslintrc rather than tslint.json'); + console.log('--------------------------------------------------------------'); + } catch { + // OK: tslint does not exist + } + + // @todo should remove this because the config file could be in a parent dir or within package.json + const configFile = await globby(resolvePath(process.cwd(), '.eslintrc?(.cjs|.js|.json|.yaml|.yml)')).then( + (filePaths) => { + if (filePaths.length > 0) { + return filePaths[0]; + } else { + return resolvePath(__dirname, '../../config/eslint.plugin.json'); + } + } + ); + + const cli = new CLIEngine({ + configFile, + fix, + }); + + const report = cli.executeOnFiles(await getTypescriptSources()); + + if (fix) { + CLIEngine.outputFixes(report); + } + + const { errorCount, results, warningCount } = report; + const formatter = cli.getFormatter(); + + if (errorCount > 0 || warningCount > 0) { + console.log('\n'); + console.log(formatter(results)); + console.log('\n'); + } + + if (errorCount > 0) { + throw new Error(`${errorCount} linting errors found in ${results.length} files`); + } + }); + +export const pluginBuildRunner: TaskRunner = async ({ + coverage, + maxJestWorkers, + preserveConsole, +}) => { + await versions(); + await prepare(); + await lintPlugin({ fix: false }); + await testPlugin({ updateSnapshot: false, coverage, maxWorkers: maxJestWorkers, watch: false }); + await bundlePlugin({ watch: false, production: true, preserveConsole }); +}; + +export const pluginBuildTask = new Task('Build plugin', pluginBuildRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts new file mode 100644 index 0000000..77c9623 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -0,0 +1,275 @@ +import { Task, TaskRunner } from './task'; +import { pluginBuildRunner } from './plugin.build'; +import { getPluginJson } from '../../config/utils/pluginValidation'; +import { getPluginId } from '../../config/utils/getPluginId'; + +// @ts-ignore +import execa = require('execa'); +import path = require('path'); +import fs from 'fs-extra'; +import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils'; +import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest'; +import { + getJobFolder, + writeJobStats, + getCiFolder, + getPluginBuildInfo, + getPullRequestNumber, + getCircleDownloadBaseURL, +} from '../../plugins/env'; +import { agregateWorkflowInfo, agregateCoverageInfo, agregateTestInfo } from '../../plugins/workflow'; +import { PluginPackageDetails, PluginBuildReport } from '../../plugins/types'; +import rimrafCallback from 'rimraf'; +import { promisify } from 'util'; +const rimraf = promisify(rimrafCallback); + +export interface PluginCIOptions { + finish?: boolean; + upload?: boolean; + signatureType?: string; + rootUrls?: string[]; + maxJestWorkers?: string; +} + +/** + * 1. BUILD + * + * when platform exists it is building backend, otherwise frontend + * + * Each build writes data: + * ~/ci/jobs/build_xxx/ + * + * Anything that should be put into the final zip file should be put in: + * ~/ci/jobs/build_xxx/dist + */ +const buildPluginRunner: TaskRunner = async ({ finish, maxJestWorkers }) => { + const start = Date.now(); + + if (finish) { + const workDir = getJobFolder(); + await rimraf(workDir); + fs.mkdirSync(workDir); + + // Move local folders to the scoped job folder + for (const name of ['dist', 'coverage']) { + const dir = path.resolve(process.cwd(), name); + if (fs.existsSync(dir)) { + fs.moveSync(dir, path.resolve(workDir, name)); + } + } + writeJobStats(start, workDir); + } else { + // Do regular build process with coverage + await pluginBuildRunner({ coverage: true, maxJestWorkers }); + } +}; + +export const ciBuildPluginTask = new Task('Build Plugin', buildPluginRunner); + +/** + * 2. Build Docs + * + * Take /docs/* and format it into /ci/docs/HTML site + * + */ +const buildPluginDocsRunner: TaskRunner = async () => { + const docsSrc = path.resolve(process.cwd(), 'docs'); + if (!fs.existsSync(docsSrc)) { + console.log('No docs src'); + return; + } + + const start = Date.now(); + const workDir = getJobFolder(); + await execa('rimraf', [workDir]); + fs.mkdirSync(workDir); + + const docsDest = path.resolve(process.cwd(), 'ci', 'docs'); + fs.mkdirSync(docsDest); + + const exe = await execa('cp', ['-rv', docsSrc + '/.', docsDest]); + console.log(exe.stdout); + + fs.writeFileSync(path.resolve(docsDest, 'index.html'), `TODO... actually build docs`, { encoding: 'utf-8' }); + + writeJobStats(start, workDir); +}; + +export const ciBuildPluginDocsTask = new Task('Build Plugin Docs', buildPluginDocsRunner); + +/** + * 2. Package + * + * Take everything from `~/ci/job/{any}/dist` and + * 1. merge it into: `~/ci/dist` + * 2. zip it into packages in `~/ci/packages` + * 3. prepare grafana environment in: `~/ci/grafana-test-env` + */ +const packagePluginRunner: TaskRunner = async ({ signatureType, rootUrls }) => { + const start = Date.now(); + const ciDir = getCiFolder(); + const packagesDir = path.resolve(ciDir, 'packages'); + const distDir = path.resolve(ciDir, 'dist'); + const docsDir = path.resolve(ciDir, 'docs'); + const jobsDir = path.resolve(ciDir, 'jobs'); + + fs.exists(jobsDir, (jobsDirExists) => { + if (!jobsDirExists) { + throw new Error('You must run plugin:ci-build prior to running plugin:ci-package'); + } + }); + + const grafanaEnvDir = path.resolve(ciDir, 'grafana-test-env'); + await execa('rimraf', [packagesDir, distDir, grafanaEnvDir]); + fs.mkdirSync(packagesDir); + fs.mkdirSync(distDir); + + // Updating the dist dir to have a pluginId named directory in it + // The zip needs to contain the plugin code wrapped in directory with a pluginId name + const distContentDir = path.resolve(distDir, getPluginId()); + fs.mkdirSync(grafanaEnvDir); + + console.log('Build Dist Folder'); + + // 1. Check for a local 'dist' folder + const d = path.resolve(process.cwd(), 'dist'); + if (fs.existsSync(d)) { + await execa('cp', ['-rn', d + '/.', distContentDir]); + } + + // 2. Look for any 'dist' folders under ci/job/XXX/dist + const dirs = fs.readdirSync(path.resolve(ciDir, 'jobs')); + for (const j of dirs) { + const contents = path.resolve(ciDir, 'jobs', j, 'dist'); + if (fs.existsSync(contents)) { + try { + await execa('cp', ['-rn', contents + '/.', distContentDir]); + } catch (er) { + throw new Error('Duplicate files found in dist folders'); + } + } + } + + console.log('Save the source info in plugin.json'); + const pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); + const pluginInfo = getPluginJson(pluginJsonFile); + pluginInfo.info.build = await getPluginBuildInfo(); + fs.writeFileSync(pluginJsonFile, JSON.stringify(pluginInfo, null, 2), { encoding: 'utf-8' }); + + // Write a MANIFEST.txt file in the dist folder + try { + const manifest = await buildManifest(distContentDir); + if (signatureType) { + manifest.signatureType = signatureType; + } + if (rootUrls) { + manifest.rootUrls = rootUrls; + } + const signedManifest = await signManifest(manifest); + await saveManifest(distContentDir, signedManifest); + } catch (err) { + console.warn(`Error signing manifest: ${distContentDir}`, err); + } + + console.log('Building ZIP'); + let zipName = pluginInfo.id + '-' + pluginInfo.info.version + '.zip'; + let zipFile = path.resolve(packagesDir, zipName); + await execa('zip', ['-r', zipFile, '.'], { cwd: distDir }); + + const zipStats = fs.statSync(zipFile); + if (zipStats.size < 100) { + throw new Error('Invalid zip file: ' + zipFile); + } + + // Make a copy so it is easy for report to read + await execa('cp', [pluginJsonFile, distDir]); + + const info: PluginPackageDetails = { + plugin: await getPackageDetails(zipFile, distDir), + }; + + console.log('Setup Grafana Environment'); + let p = path.resolve(grafanaEnvDir, 'plugins', pluginInfo.id); + fs.mkdirSync(p, { recursive: true }); + await execa('unzip', [zipFile, '-d', p]); + + // If docs exist, zip them into packages + if (fs.existsSync(docsDir)) { + console.log('Creating documentation zip'); + zipName = pluginInfo.id + '-' + pluginInfo.info.version + '-docs.zip'; + zipFile = path.resolve(packagesDir, zipName); + await execa('zip', ['-r', zipFile, '.'], { cwd: docsDir }); + + info.docs = await getPackageDetails(zipFile, docsDir); + } + + p = path.resolve(packagesDir, 'info.json'); + fs.writeFileSync(p, JSON.stringify(info, null, 2), { encoding: 'utf-8' }); + + // Write the custom settings + p = path.resolve(grafanaEnvDir, 'custom.ini'); + const customIniBody = + `# Autogenerated by @grafana/toolkit \n` + + `[paths] \n` + + `plugins = ${path.resolve(grafanaEnvDir, 'plugins')}\n` + + `\n`; // empty line + fs.writeFileSync(p, customIniBody, { encoding: 'utf-8' }); + + writeJobStats(start, getJobFolder()); +}; + +export const ciPackagePluginTask = new Task('Bundle Plugin', packagePluginRunner); + +/** + * 4. Report + * + * Create a report from all the previous steps + */ +const pluginReportRunner: TaskRunner = async ({ upload }) => { + const ciDir = path.resolve(process.cwd(), 'ci'); + const packageDir = path.resolve(ciDir, 'packages'); + const packageInfo = require(path.resolve(packageDir, 'info.json')) as PluginPackageDetails; + + const pluginJsonFile = path.resolve(ciDir, 'dist', 'plugin.json'); + console.log('Load info from: ' + pluginJsonFile); + + const pluginMeta = getPluginJson(pluginJsonFile); + const report: PluginBuildReport = { + plugin: pluginMeta, + packages: packageInfo, + workflow: agregateWorkflowInfo(), + coverage: agregateCoverageInfo(), + tests: agregateTestInfo(), + artifactsBaseURL: await getCircleDownloadBaseURL(), + grafanaVersion: getGrafanaVersions(), + git: await readGitLog(), + }; + const pr = getPullRequestNumber(); + if (pr) { + report.pullRequest = pr; + } + + // Save the report to disk + const file = path.resolve(ciDir, 'report.json'); + fs.writeFileSync(file, JSON.stringify(report, null, 2), { encoding: 'utf-8' }); + + const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY; + if (!GRAFANA_API_KEY) { + console.log('Enter a GRAFANA_API_KEY to upload the plugin report'); + return; + } + const url = `https://grafana.com/api/plugins/${report.plugin.id}/ci`; + + console.log('Sending report to:', url); + const axios = require('axios'); + const info = await axios.post(url, report, { + headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY }, + }); + if (info.status === 200) { + console.log('OK: ', info.data); + } else { + console.warn('Error: ', info); + } +}; + +export const ciPluginReportTask = new Task('Generate Plugin Report', pluginReportRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts new file mode 100644 index 0000000..0eb2647 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts @@ -0,0 +1,50 @@ +import { prompt } from 'inquirer'; +import path from 'path'; +import { promptConfirm } from '../utils/prompt'; +import { + fetchTemplate, + formatPluginDetails, + getPluginIdFromName, + prepareJsonFiles, + printGrafanaTutorialsDetails, + promptPluginDetails, + promptPluginType, + removeGitFiles, + verifyGitExists, +} from './plugin/create'; +import { Task, TaskRunner } from './task'; + +interface PluginCreateOptions { + name?: string; +} + +const pluginCreateRunner: TaskRunner = async ({ name }) => { + const destPath = path.resolve(process.cwd(), getPluginIdFromName(name || '')); + let pluginDetails; + + // 1. Verifying if git exists in user's env as templates are cloned from git templates + await verifyGitExists(); + + // 2. Prompt plugin template + const { type } = await promptPluginType(); + + // 3. Fetch plugin template from GitHub + await fetchTemplate({ type, dest: destPath }); + + // 4. Prompt plugin details + do { + pluginDetails = await promptPluginDetails(name); + formatPluginDetails(pluginDetails); + } while ((await prompt<{ confirm: boolean }>(promptConfirm('confirm', 'Is that ok?'))).confirm === false); + + // 5. Update json files (package.json, src/plugin.json) + await prepareJsonFiles({ type: type, pluginDetails, pluginPath: destPath }); + + // 6. Remove cloned repository .git dir + await removeGitFiles(destPath); + + // 7. Promote Grafana Tutorials :) + printGrafanaTutorialsDetails(type); +}; + +export const pluginCreateTask = new Task('plugin:create task', pluginCreateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.dev.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.dev.ts new file mode 100644 index 0000000..34eeb0b --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.dev.ts @@ -0,0 +1,50 @@ +import { Task, TaskRunner } from './task'; +import { bundlePlugin as bundleFn, PluginBundleOptions } from './plugin/bundle'; +import { useSpinner } from '../utils/useSpinner'; +import { lintPlugin } from './plugin.build'; + +// @ts-ignore +import execa = require('execa'); +import path = require('path'); + +const bundlePlugin = (options: PluginBundleOptions) => + useSpinner('Bundling plugin in dev mode', () => bundleFn(options)); + +const yarnlink = () => + useSpinner('Linking local toolkit', async () => { + try { + // Make sure we are not using package.json defined toolkit + await execa('yarn', ['remove', '@grafana/toolkit']); + } catch (e) { + console.log('\n', e.message, '\n'); + } + await execa('yarn', ['link', '@grafana/toolkit']); + + // Add all the same dependencies as toolkit + const args: string[] = ['add']; + const packages = require(path.resolve(__dirname, '../../../package.json')); + for (const [key, value] of Object.entries(packages.dependencies)) { + args.push(`${key}@${value}`); + } + await execa('yarn', args); + + console.log('Added dependencies required by local @grafana/toolkit. Do not checkin this package.json!'); + }); + +const pluginDevRunner: TaskRunner = async (options) => { + if (options.yarnlink) { + return yarnlink(); + } + + if (options.watch) { + await bundleFn(options); + } else { + // Always fix lint in dev mode + await lintPlugin({ fix: true }); + + const result = await bundlePlugin(options); + return result; + } +}; + +export const pluginDevTask = new Task('Dev plugin', pluginDevRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts new file mode 100644 index 0000000..1a9d2a5 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts @@ -0,0 +1,38 @@ +import path from 'path'; +import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest'; +import { Task, TaskRunner } from './task'; + +interface PluginSignOptions { + signatureType?: string; + rootUrls?: string[]; +} + +const pluginSignRunner: TaskRunner = async ({ signatureType, rootUrls }) => { + const distContentDir = path.resolve('dist'); + + try { + console.log('Building manifest...'); + const manifest = await buildManifest(distContentDir); + // console.log(manifest); + + console.log('Signing manifest...'); + if (signatureType) { + manifest.signatureType = signatureType; + } + if (rootUrls) { + manifest.rootUrls = rootUrls; + } + + const signedManifest = await signManifest(manifest); + // console.log(signedManifest); + + console.log('Saving signed manifest...'); + await saveManifest(distContentDir, signedManifest); + + console.log('Signed successfully'); + } catch (err) { + console.warn(err); + } +}; + +export const pluginSignTask = new Task('plugin:sign task', pluginSignRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.tests.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.tests.ts new file mode 100644 index 0000000..f85c3f1 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.tests.ts @@ -0,0 +1,8 @@ +import { Task, TaskRunner } from './task'; +import { testPlugin, PluginTestOptions } from './plugin/tests'; + +const pluginTestRunner: TaskRunner = async (options) => { + await testPlugin(options); +}; + +export const pluginTestTask = new Task('Test plugin', pluginTestRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts new file mode 100644 index 0000000..bf998a3 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.update.ts @@ -0,0 +1,22 @@ +import { Task, TaskRunner } from './task'; +import { useSpinner } from '../utils/useSpinner'; +import fs = require('fs'); +import path = require('path'); + +interface UpdatePluginTask {} + +const updateCiConfig = () => + useSpinner('Updating CircleCI config', async () => { + const ciConfigPath = path.join(process.cwd(), '.circleci'); + if (!fs.existsSync(ciConfigPath)) { + fs.mkdirSync(ciConfigPath); + } + + const sourceFile = path.join('node_modules/@grafana/toolkit/config/circleci', 'config.yml'); + const destFile = path.join(ciConfigPath, 'config.yml'); + fs.copyFileSync(sourceFile, destFile); + }); + +const pluginUpdateRunner: TaskRunner = () => updateCiConfig(); + +export const pluginUpdateTask = new Task('Update Plugin', pluginUpdateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.utils.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.utils.ts new file mode 100755 index 0000000..9294014 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.utils.ts @@ -0,0 +1,214 @@ +import { Task, TaskRunner } from './task'; +import { getPluginJson } from '../../config/utils/pluginValidation'; +import { GitHubRelease } from '../utils/githubRelease'; +import { getPluginId } from '../../config/utils/getPluginId'; +import { getCiFolder } from '../../plugins/env'; +import { useSpinner } from '../utils/useSpinner'; +import path = require('path'); + +// @ts-ignore +import execa = require('execa'); + +interface Command extends Array {} +const DEFAULT_EMAIL_ADDRESS = 'eng@grafana.com'; +const DEFAULT_USERNAME = 'CircleCI Automation'; + +const releaseNotes = async (): Promise => { + const { stdout } = await execa(`awk 'BEGIN {FS="##"; RS="##"} FNR==3 {print "##" $1; exit}' CHANGELOG.md`, { + shell: true, + }); + return stdout; +}; + +const checkoutBranch = async (branchName: string): Promise => { + const currentBranch = await execa(`git rev-parse --abbrev-ref HEAD`, { shell: true }); + const branchesAvailable = await execa( + `(git branch -a | grep "${branchName}$" | grep -v remote) || echo 'No release found'`, + { shell: true } + ); + + if (currentBranch.stdout !== branchName) { + console.log('available', branchesAvailable.stdout.trim()); + if (branchesAvailable.stdout.trim() === branchName) { + return ['git', ['checkout', branchName]]; + } else { + return ['git', ['checkout', '-b', branchName]]; + } + } + return []; +}; + +const gitUrlParse = (url: string): { owner: string; name: string } => { + let matchResult: RegExpMatchArray | null = []; + + if (url.match(/^git@github.com/)) { + // We have an ssh style url. + matchResult = url.match(/^git@github.com:(.*?)\/(.*?)\.git/); + } + + if (url.match(/^https:\/\/github.com\//)) { + // We have an https style url + matchResult = url.match(/^https:\/\/github.com\/(.*?)\/(.*?)\/.git/); + } + + if (matchResult && matchResult.length > 2) { + return { + owner: matchResult[1], + name: matchResult[2], + }; + } + + throw `Could not find a suitable git repository. Received [${url}]`; +}; + +const prepareRelease = ({ dryrun, verbose }: any) => + useSpinner('Preparing release', async () => { + const ciDir = getCiFolder(); + const distDir = path.resolve(ciDir, 'dist'); + const distContentDir = path.resolve(distDir, getPluginId()); + const pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); + const pluginJson = getPluginJson(pluginJsonFile); + + const githubPublishScript: Command = [ + ['git', ['config', 'user.email', DEFAULT_EMAIL_ADDRESS]], + ['git', ['config', 'user.name', DEFAULT_USERNAME]], + await checkoutBranch(`release-${pluginJson.info.version}`), + ['/bin/rm', ['-rf', 'dist'], { dryrun }], + ['mv', ['-v', distContentDir, 'dist']], + ['git', ['add', '--force', 'dist'], { dryrun }], + ['/bin/rm', ['-rf', 'src'], { enterprise: true }], + ['git', ['rm', '-rf', 'src'], { enterprise: true }], + [ + 'git', + ['commit', '-m', `automated release ${pluginJson.info.version} [skip ci]`], + { + dryrun, + okOnError: [/nothing to commit/g, /nothing added to commit/g, /no changes added to commit/g], + }, + ], + ['git', ['push', '-f', 'origin', `release-${pluginJson.info.version}`], { dryrun }], + ['git', ['tag', '-f', `v${pluginJson.info.version}`]], + ['git', ['push', '-f', 'origin', `v${pluginJson.info.version}`]], + ]; + + for (let line of githubPublishScript) { + const opts = line.length === 3 ? line[2] : {}; + const command = line[0]; + const args = line[1]; + + try { + if (verbose) { + console.log('executing >>', line); + } + + if (line.length > 0 && line[0].length > 0) { + if (opts['dryrun']) { + line[1].push('--dry-run'); + } + + // Exit if the plugin is NOT an enterprise plugin + if (pluginJson.enterprise && !opts['enterprise']) { + continue; + } + + const { stdout } = await execa(command, args); + if (verbose) { + console.log(stdout); + } + } else { + if (verbose) { + console.log('skipping empty line'); + } + } + } catch (ex) { + const err: string = ex.message; + if (opts['okOnError'] && Array.isArray(opts['okOnError'])) { + let trueError = true; + for (let regex of opts['okOnError']) { + if (err.match(regex)) { + trueError = false; + break; + } + } + + if (!trueError) { + // This is not an error + continue; + } + } + console.error(err); + process.exit(-1); + } + } + }); + +interface GithubPublishReleaseOptions { + commitHash?: string; + githubToken: string; + githubUser: string; + gitRepoName: string; +} + +const createRelease = ({ commitHash, githubUser, githubToken, gitRepoName }: GithubPublishReleaseOptions) => + useSpinner('Creating release', async () => { + const gitRelease = new GitHubRelease(githubToken, githubUser, gitRepoName, await releaseNotes(), commitHash); + return gitRelease.release(); + }); + +export interface GithubPublishOptions { + dryrun?: boolean; + verbose?: boolean; + commitHash?: string; + dev?: boolean; +} + +const githubPublishRunner: TaskRunner = async ({ dryrun, verbose, commitHash }) => { + let repoUrl: string | undefined = process.env.DRONE_REPO_LINK || process.env.CIRCLE_REPOSITORY_URL; + if (!repoUrl) { + // Try and figure it out + const repo = await execa('git', ['config', '--local', 'remote.origin.url']); + if (repo && repo.stdout) { + repoUrl = repo.stdout; + } else { + throw new Error( + 'The release plugin requires you specify the repository url as environment variable DRONE_REPO_LINK or ' + + 'CIRCLE_REPOSITORY_URL' + ); + } + } + + if (!process.env['GITHUB_ACCESS_TOKEN']) { + // Try to use GITHUB_TOKEN, which may be set. + if (process.env['GITHUB_TOKEN']) { + process.env['GITHUB_ACCESS_TOKEN'] = process.env['GITHUB_TOKEN']; + } else { + throw new Error( + `GitHub publish requires that you set the environment variable GITHUB_ACCESS_TOKEN to a valid github api token. + See: https://github.com/settings/tokens for more details.` + ); + } + } + + if (!process.env['GITHUB_USERNAME']) { + // We can default this one + process.env['GITHUB_USERNAME'] = DEFAULT_EMAIL_ADDRESS; + } + + const parsedUrl = gitUrlParse(repoUrl); + const githubToken = process.env['GITHUB_ACCESS_TOKEN']; + const githubUser = parsedUrl.owner; + + await prepareRelease({ + dryrun, + verbose, + }); + + await createRelease({ + commitHash, + githubUser, + githubToken, + gitRepoName: parsedUrl.name, + }); +}; + +export const githubPublishTask = new Task('GitHub Publish', githubPublishRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts new file mode 100644 index 0000000..2dafe87 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts @@ -0,0 +1,33 @@ +import { promises as fs } from 'fs'; +import { Task, TaskRunner } from '../task'; +import execa = require('execa'); + +interface BundeManagedOptions {} + +const MANAGED_PLUGINS_PATH = `${process.cwd()}/plugins-bundled`; +const MANAGED_PLUGINS_SCOPES = ['internal', 'external']; + +const bundleManagedPluginsRunner: TaskRunner = async () => { + await Promise.all( + MANAGED_PLUGINS_SCOPES.map(async (scope) => { + try { + const plugins = await fs.readdir(`${MANAGED_PLUGINS_PATH}/${scope}`); + if (plugins.length > 0) { + for (const plugin of plugins) { + try { + console.log(`[${scope}]: ${plugin} building...`); + await execa('yarn', ['build'], { cwd: `${MANAGED_PLUGINS_PATH}/${scope}/${plugin}` }); + console.log(`[${scope}]: ${plugin} bundled`); + } catch (e) { + console.log(e.stdout); + } + } + } + } catch (e) { + console.log(e); + } + }) + ); +}; + +export const bundleManagedTask = new Task('Bundle managed plugins', bundleManagedPluginsRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts new file mode 100644 index 0000000..7fca119 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -0,0 +1,78 @@ +import webpack = require('webpack'); +import formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); +import clearConsole = require('react-dev-utils/clearConsole'); +import { loadWebpackConfig } from '../../../config/webpack.plugin.config'; + +export interface PluginBundleOptions { + watch: boolean; + production?: boolean; + yarnlink?: boolean; + preserveConsole?: boolean; +} + +// export const bundlePlugin = ({ watch, production }: PluginBundleOptions) => useSpinner('Bundle plugin', async () => { +export const bundlePlugin = async ({ watch, production, preserveConsole }: PluginBundleOptions) => { + const compiler = webpack( + await loadWebpackConfig({ + watch, + production, + preserveConsole, + }) + ); + + const webpackPromise = new Promise((resolve, reject) => { + if (watch) { + console.log('Started watching plugin for changes...'); + compiler.watch({}, (err, stats) => {}); + + compiler.hooks.invalid.tap('invalid', () => { + clearConsole(); + console.log('Compiling...'); + }); + + compiler.hooks.done.tap('done', (stats: webpack.Stats) => { + clearConsole(); + const json: any = stats.toJson(); // different @types/webpack between react-dev-utils and grafana-toolkit + const output = formatWebpackMessages(json); + + if (!output.errors.length && !output.warnings.length) { + console.log('Compiled successfully!\n'); + console.log(stats.toString({ colors: true })); + } + + if (output.errors.length) { + console.log('Compilation failed!'); + output.errors.forEach((e) => console.log(e)); + + if (output.warnings.length) { + console.log('Warnings:'); + output.warnings.forEach((w) => console.log(w)); + } + } + if (output.errors.length === 0 && output.warnings.length) { + console.log('Compiled with warnings!'); + output.warnings.forEach((w) => console.log(w)); + } + }); + } else { + compiler.run((err: Error, stats: webpack.Stats) => { + if (err) { + reject(err.message); + + return; + } + if (stats.hasErrors()) { + stats.compilation.errors.forEach((e) => { + console.log(e.message); + }); + + reject('Build failed'); + } + console.log('\n', stats.toString({ colors: true }), '\n'); + resolve(); + }); + } + }); + + return webpackPromise; +}; diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts new file mode 100644 index 0000000..9a93b7a --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts @@ -0,0 +1,190 @@ +import chalk from 'chalk'; +import commandExists from 'command-exists'; +import { promises as fs, readFileSync } from 'fs'; +import { prompt } from 'inquirer'; +import kebabCase from 'lodash/kebabCase'; +import path from 'path'; +import gitPromise from 'simple-git/promise'; +import { promptConfirm, promptInput } from '../../utils/prompt'; +import { rmdir } from '../../utils/rmdir'; +import { useSpinner } from '../../utils/useSpinner'; + +const simpleGit = gitPromise(process.cwd()); + +interface PluginDetails { + name: string; + org: string; + description: string; + author: boolean | string; + url: string; + keywords: string; +} + +type PluginType = 'panel-plugin' | 'datasource-plugin' | 'backend-datasource-plugin'; + +const PluginNames: Record = { + 'panel-plugin': 'Grafana Panel Plugin', + 'datasource-plugin': 'Grafana Data Source Plugin', + 'backend-datasource-plugin': 'Grafana Backend Datasource Plugin', +}; +const RepositoriesPaths: Record = { + 'panel-plugin': 'https://github.com/grafana/simple-react-panel.git', + 'datasource-plugin': 'https://github.com/grafana/simple-datasource.git', + 'backend-datasource-plugin': 'https://github.com/grafana/simple-datasource-backend.git', +}; +const TutorialPaths: Record = { + 'panel-plugin': 'https://grafana.com/tutorials/build-a-panel-plugin', + 'datasource-plugin': 'https://grafana.com/tutorials/build-a-data-source-plugin', + 'backend-datasource-plugin': 'TODO', +}; + +export const getGitUsername = async () => { + const name = await simpleGit.raw(['config', '--global', 'user.name']); + return name || ''; +}; +export const getPluginIdFromName = (name: string) => kebabCase(name); +export const getPluginId = (pluginDetails: PluginDetails) => + `${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`; + +export const getPluginKeywords = (pluginDetails: PluginDetails) => + pluginDetails.keywords + .split(',') + .map((k) => k.trim()) + .filter((k) => k !== ''); + +export const verifyGitExists = async () => { + return new Promise((resolve, reject) => { + commandExists('git', (err, exists) => { + if (exists) { + resolve(true); + } + reject(new Error('git is not installed')); + }); + }); +}; + +export const promptPluginType = async () => + prompt<{ type: PluginType }>([ + { + type: 'list', + message: 'Select plugin type', + name: 'type', + choices: [ + { name: 'Panel Plugin', value: 'panel-plugin' }, + { name: 'Datasource Plugin', value: 'datasource-plugin' }, + { name: 'Backend Datasource Plugin', value: 'backend-datasource-plugin' }, + ], + }, + ]); + +export const promptPluginDetails = async (name?: string) => { + const username = (await getGitUsername()).trim(); + const responses = await prompt([ + promptInput('name', 'Plugin name', true, name), + promptInput('org', 'Organization (used as part of plugin ID)', true), + promptInput('description', 'Description'), + promptInput('keywords', 'Keywords (separated by comma)'), + // Try using git specified username + promptConfirm('author', `Author (${username})`, username, username !== ''), + // Prompt for manual author entry if no git user.name specified + promptInput('author', `Author`, true, undefined, (answers: any) => !answers.author || username === ''), + promptInput('url', 'Your URL (i.e. organisation url)'), + ]); + + return { + ...responses, + author: responses.author === true ? username : responses.author, + }; +}; + +export const fetchTemplate = ({ type, dest }: { type: PluginType; dest: string }) => + useSpinner('Fetching plugin template...', async () => { + const url = RepositoriesPaths[type]; + if (!url) { + throw new Error('Unknown plugin type'); + } + + await simpleGit.clone(url, dest); + }); + +export const prepareJsonFiles = ({ + type, + pluginDetails, + pluginPath, +}: { + type: PluginType; + pluginDetails: PluginDetails; + pluginPath: string; +}) => + useSpinner('Saving package.json and plugin.json files', async () => { + const packageJsonPath = path.resolve(pluginPath, 'package.json'); + const pluginJsonPath = path.resolve(pluginPath, 'src/plugin.json'); + const packageJson: any = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const pluginJson: any = JSON.parse(readFileSync(pluginJsonPath, 'utf8')); + + const pluginId = `${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`; + packageJson.name = pluginId; + packageJson.author = pluginDetails.author; + packageJson.description = pluginDetails.description; + + pluginJson.name = pluginDetails.name; + pluginJson.id = pluginId; + + if (type === 'backend-datasource-plugin') { + pluginJson.backend = true; + pluginJson.executable = 'gpx_' + pluginDetails.name; + } + + pluginJson.info = { + ...pluginJson.info, + description: pluginDetails.description, + author: { + name: pluginDetails.author, + url: pluginDetails.url, + }, + keywords: getPluginKeywords(pluginDetails), + }; + + await Promise.all( + [packageJson, pluginJson].map((f, i) => { + const filePath = i === 0 ? packageJsonPath : pluginJsonPath; + return fs.writeFile(filePath, JSON.stringify(f, null, 2)); + }) + ); + }); + +export const removeGitFiles = (pluginPath: string) => + useSpinner('Cleaning', async () => rmdir(`${path.resolve(pluginPath, '.git')}`)); + +/* eslint-disable no-console */ +export const formatPluginDetails = (details: PluginDetails) => { + console.group(); + console.log(); + console.log(chalk.bold.yellow('Your plugin details')); + console.log('---'); + console.log(chalk.bold('Name: '), details.name); + console.log(chalk.bold('ID: '), getPluginId(details)); + console.log(chalk.bold('Description: '), details.description); + console.log(chalk.bold('Keywords: '), getPluginKeywords(details)); + console.log(chalk.bold('Author: '), details.author); + console.log(chalk.bold('Organisation: '), details.org); + console.log(chalk.bold('Website: '), details.url); + console.log(); + console.groupEnd(); +}; + +export const printGrafanaTutorialsDetails = (type: PluginType) => { + console.group(); + console.log(); + console.log(chalk.bold.yellow(`Congrats! You have just created ${PluginNames[type]}.`)); + console.log(); + if (type !== 'backend-datasource-plugin') { + console.log(`${PluginNames[type]} tutorial: ${TutorialPaths[type]}`); + } + console.log( + 'Learn more about Grafana Plugins at https://grafana.com/docs/grafana/latest/plugins/developing/development/' + ); + console.log(); + console.groupEnd(); +}; +/* eslint-enable no-console */ diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/test_exclude_image_suffix_in_manifest.png b/packages/grafana-toolkit/src/cli/tasks/plugin/test_exclude_image_suffix_in_manifest.png new file mode 100644 index 0000000..bb07b07 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/test_exclude_image_suffix_in_manifest.png @@ -0,0 +1 @@ +NOTE: not a real image, but should be excluded from hashed files \ No newline at end of file diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts new file mode 100644 index 0000000..dd47ba0 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts @@ -0,0 +1,49 @@ +import { runCLI } from '@jest/core'; +import { useSpinner } from '../../utils/useSpinner'; +import { loadJestPluginConfig } from '../../../config/jest.plugin.config'; + +export interface PluginTestOptions { + updateSnapshot: boolean; + coverage: boolean; + watch: boolean; + testPathPattern?: string; + testNamePattern?: string; + maxWorkers?: string; +} + +export const testPlugin = ({ + updateSnapshot, + coverage, + watch, + testPathPattern, + testNamePattern, + maxWorkers, +}: PluginTestOptions) => + useSpinner('Running tests', async () => { + const testConfig = loadJestPluginConfig(); + + const cliConfig = { + config: JSON.stringify(testConfig), + updateSnapshot, + coverage, + watch, + testPathPattern: testPathPattern ? [testPathPattern] : [], + testNamePattern: testNamePattern ? [testNamePattern] : [], + passWithNoTests: true, + maxWorkers, + }; + + // @ts-ignore + const runJest = () => runCLI(cliConfig, [process.cwd()]); + + if (watch) { + runJest(); + } else { + // @ts-ignore + const results = await runJest(); + + if (results.results.numFailedTests > 0 || results.results.numFailedTestSuites > 0) { + throw new Error('Tests failed'); + } + } + }); diff --git a/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts new file mode 100644 index 0000000..84c0ac0 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts @@ -0,0 +1,116 @@ +import axios from 'axios'; +import { Task, TaskRunner } from './task'; + +interface SearchTestDataSetupOptions { + count: number; +} + +const client = axios.create({ + baseURL: 'http://localhost:3000/api', + auth: { + username: 'admin', + password: 'admin2', + }, +}); + +export async function getUser(user: any): Promise { + console.log('Creating user ' + user.name); + const search = await client.get('/users/search', { + params: { query: user.login }, + }); + + if (search.data.totalCount === 1) { + user.id = search.data.users[0].id; + return user; + } + + const rsp = await client.post('/admin/users', user); + user.id = rsp.data.id; + return user; +} + +export async function getTeam(team: any): Promise { + // delete if exists + const teams = await client.get('/teams/search'); + for (const existing of teams.data.teams) { + if (existing.name === team.name) { + console.log('Team exists, deleting'); + await client.delete('/teams/' + existing.id); + } + } + + console.log('Creating team ' + team.name); + const teamRsp = await client.post(`/teams`, team); + team.id = teamRsp.data.teamId; + return team; +} + +export async function addToTeam(team: any, user: any): Promise { + console.log(`Adding user ${user.name} to team ${team.name}`); + await client.post(`/teams/${team.id}/members`, { userId: user.id }); +} + +export async function setDashboardAcl(dashboardId: any, aclList: any) { + console.log('Setting Dashboard ACL ' + dashboardId); + await client.post(`/dashboards/id/${dashboardId}/permissions`, { items: aclList }); +} + +const searchTestDataSetupRunner: TaskRunner = async ({ count }) => { + const user1 = await getUser({ + name: 'searchTestUser1', + email: 'searchTestUser@team.com', + login: 'searchTestUser1', + password: '12345', + }); + + const team1 = await getTeam({ name: 'searchTestTeam1', email: 'searchtestdata@team.com' }); + addToTeam(team1, user1); + + // create or update folder + const folder: any = { + uid: 'search-test-data', + title: 'Search test data folder', + version: 1, + }; + + try { + await client.delete(`/folders/${folder.uid}`); + } catch (err) {} + + console.log('Creating folder'); + + const rsp = await client.post(`/folders`, folder); + folder.id = rsp.data.id; + folder.url = rsp.data.url; + + await setDashboardAcl(folder.id, []); + + console.log('Creating dashboards'); + + const dashboards: any = []; + + for (let i = 0; i < count; i++) { + const dashboard: any = { + uid: 'search-test-dash-' + i.toString().padStart(5, '0'), + title: 'Search test dash ' + i.toString().padStart(5, '0'), + }; + + const rsp = await client.post(`/dashboards/db`, { + dashboard: dashboard, + folderId: folder.id, + overwrite: true, + }); + + dashboard.id = rsp.data.id; + dashboard.url = rsp.data.url; + + console.log('Created dashboard ' + dashboard.title); + dashboards.push(dashboard); + await setDashboardAcl(dashboard.id, [{ userId: 0, teamId: team1.id, permission: 4 }]); + } +}; + +export const searchTestDataSetupTask = new Task( + 'Search test data setup', + searchTestDataSetupRunner +); diff --git a/packages/grafana-toolkit/src/cli/tasks/task.ts b/packages/grafana-toolkit/src/cli/tasks/task.ts new file mode 100644 index 0000000..4524508 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/task.ts @@ -0,0 +1,22 @@ +export type TaskRunner = (options: T) => Promise; + +export class Task { + options: TOptions = {} as any; + + constructor(public name: string, public runner: TaskRunner) {} + setName = (name: string) => { + this.name = name; + }; + + setRunner = (runner: TaskRunner) => { + this.runner = runner; + }; + + setOptions = (options: TOptions) => { + this.options = options; + }; + + exec = () => { + return this.runner(this.options); + }; +} diff --git a/packages/grafana-toolkit/src/cli/tasks/template.ts b/packages/grafana-toolkit/src/cli/tasks/template.ts new file mode 100644 index 0000000..4a965e0 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/template.ts @@ -0,0 +1,9 @@ +import { Task, TaskRunner } from './task'; + +interface TemplateOptions {} + +const templateRunner: TaskRunner = async () => { + console.log('Template task'); +}; + +export const templateTask = new Task('Template task', templateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts new file mode 100644 index 0000000..194f23b --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts @@ -0,0 +1,121 @@ +import execa = require('execa'); +import * as fs from 'fs'; +import chalk from 'chalk'; +import { useSpinner } from '../utils/useSpinner'; +import { Task, TaskRunner } from './task'; + +const path = require('path'); + +let distDir: string, cwd: string; + +const clean = () => useSpinner('Cleaning', () => execa('npm', ['run', 'clean'])); + +const compile = () => + useSpinner('Compiling sources', async () => { + try { + await execa('tsc', ['-p', './tsconfig.json']); + } catch (e) { + console.log(e); + throw e; + } + }); + +const savePackage = ({ path, pkg }: { path: string; pkg: {} }) => + useSpinner('Updating package.json', async () => { + new Promise((resolve, reject) => { + fs.writeFile(path, JSON.stringify(pkg, null, 2), (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }); + +const preparePackage = async (pkg: any) => { + pkg.bin = { + 'grafana-toolkit': './bin/grafana-toolkit.js', + }; + + await savePackage({ + path: `${cwd}/dist/package.json`, + pkg, + }); +}; + +const copyFiles = () => { + const files = [ + 'README.md', + 'CHANGELOG.md', + 'config/circleci/config.yml', + 'bin/grafana-toolkit.js', + 'src/config/prettier.plugin.config.json', + 'src/config/prettier.plugin.rc.js', + 'src/config/tsconfig.plugin.json', + 'src/config/tsconfig.plugin.local.json', + 'src/config/eslint.plugin.json', + 'src/config/styles.mock.js', + 'src/config/jest.plugin.config.local.js', + ]; + + return useSpinner(`Moving ${files.join(', ')} files`, async () => { + const promises = files.map((file) => { + return new Promise((resolve, reject) => { + const basedir = path.dirname(`${distDir}/${file}`); + if (!fs.existsSync(basedir)) { + fs.mkdirSync(basedir, { recursive: true }); + } + fs.copyFile(`${cwd}/${file}`, `${distDir}/${file}`, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }); + + await Promise.all(promises); + }); +}; + +const copySassFiles = () => { + const files = ['_variables.generated.scss', '_variables.dark.generated.scss', '_variables.light.generated.scss']; + return useSpinner(`Copy scss files ${files.join(', ')} files`, async () => { + const sassDir = path.resolve(cwd, '../../public/sass/'); + const promises = files.map((file) => { + return new Promise((resolve, reject) => { + const name = file.replace('.generated', ''); + fs.copyFile(`${sassDir}/${file}`, `${distDir}/sass/${name}`, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }); + + await Promise.all(promises); + }); +}; + +interface ToolkitBuildOptions {} + +const toolkitBuildTaskRunner: TaskRunner = async () => { + cwd = path.resolve(__dirname, '../../../'); + distDir = `${cwd}/dist`; + const pkg = require(`${cwd}/package.json`); + console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); + + await clean(); + await compile(); + await preparePackage(pkg); + fs.mkdirSync('./dist/bin'); + fs.mkdirSync('./dist/sass'); + await copyFiles(); + await copySassFiles(); +}; + +export const toolkitBuildTask = new Task('@grafana/toolkit build', toolkitBuildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts new file mode 100644 index 0000000..b6737fe --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts @@ -0,0 +1,12 @@ +export const testTpl = ` +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { <%= name %> } from './<%= name %>'; + + +describe('<%= name %>', () => { + it.skip('should render', () => { + + }); +}); +`; diff --git a/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts new file mode 100644 index 0000000..7345eb5 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts @@ -0,0 +1,10 @@ +export const componentTpl = `import React, { FC } from 'react'; + +export interface Props {}; + +export const <%= name %>: FC = (props) => { + return ( +
Hello world!
+ ) +}; +`; diff --git a/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts b/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts new file mode 100644 index 0000000..b605f75 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts @@ -0,0 +1,16 @@ +export const docsTpl = `import { ArgsTable } from '@storybook/addon-docs/blocks'; +import { <%= name %> } from './<%= name %>'; + +# <%= name %> + +### Usage + +\`\`\`jsx +import { <%= name %> } from '@grafana/ui'; + +<<%= name %> /> +\`\`\` + +### Props +} /> +`; diff --git a/packages/grafana-toolkit/src/cli/templates/index.ts b/packages/grafana-toolkit/src/cli/templates/index.ts new file mode 100644 index 0000000..57a7319 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/index.ts @@ -0,0 +1,4 @@ +export { componentTpl } from './component.tsx.template'; +export { storyTpl } from './story.tsx.template'; +export { docsTpl } from './docs.mdx.template'; +export { testTpl } from './component.test.tsx.template'; diff --git a/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts new file mode 100644 index 0000000..21c87a1 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts @@ -0,0 +1,22 @@ +export const storyTpl = ` +import React from 'react'; +import { <%= name %> } from './<%= name %>'; +import { withCenteredStory } from '@grafana/ui/src/utils/storybook/withCenteredStory'; +import mdx from './<%= name %>.mdx'; + + +export default { + title: '<%= group %>/<%= name %>', + component: <%= name %>, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const Basic = () => { + return <<%= name %> />; +}; +`; diff --git a/packages/grafana-toolkit/src/cli/tsconfig.json b/packages/grafana-toolkit/src/cli/tsconfig.json new file mode 100644 index 0000000..36f0eda --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "module": "commonjs" + }, + "extends": "@grafana/tsconfig" +} diff --git a/packages/grafana-toolkit/src/cli/utils/execTask.ts b/packages/grafana-toolkit/src/cli/utils/execTask.ts new file mode 100644 index 0000000..03d0a88 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/execTask.ts @@ -0,0 +1,23 @@ +/* eslint-disable no-console */ +import { Task } from '../tasks/task'; +import chalk from 'chalk'; + +interface TaskBasicOptions { + // Don't print task details when running + silent?: boolean; +} + +export const execTask = (task: Task) => async (options: TOptions & TaskBasicOptions) => { + if (!options.silent) { + console.log(chalk.yellow(`Running ${chalk.bold(task.name)} task`)); + } + task.setOptions(options); + try { + console.group(); + await task.exec(); + console.groupEnd(); + } catch (e) { + console.trace(e); + process.exit(1); + } +}; diff --git a/packages/grafana-toolkit/src/cli/utils/githubClient.test.ts b/packages/grafana-toolkit/src/cli/utils/githubClient.test.ts new file mode 100644 index 0000000..ff6cfb0 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/githubClient.test.ts @@ -0,0 +1,107 @@ +import GithubClient from './githubClient'; + +const fakeClient = jest.fn(); + +beforeEach(() => { + delete process.env.GITHUB_USERNAME; + delete process.env.GITHUB_ACCESS_TOKEN; +}); + +afterEach(() => { + delete process.env.GITHUB_USERNAME; + delete process.env.GITHUB_ACCESS_TOKEN; +}); + +describe('GithubClient', () => { + it('should initialise a GithubClient', () => { + const github = new GithubClient(); + const githubEnterprise = new GithubClient({ enterprise: true }); + expect(github).toBeInstanceOf(GithubClient); + expect(githubEnterprise).toBeInstanceOf(GithubClient); + }); + + describe('#client', () => { + it('it should contain a grafana client', () => { + // @ts-ignore + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient(); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana', + timeout: 10000, + }); + expect(client).toEqual(fakeClient); + }); + + it('it should contain a grafana enterprise client', () => { + // @ts-ignore + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient({ enterprise: true }); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana-enterprise', + timeout: 10000, + }); + expect(client).toEqual(fakeClient); + }); + + describe('when the credentials are required', () => { + it('should create the client when the credentials are defined', () => { + const username = 'grafana'; + const token = 'averysecureaccesstoken'; + + process.env.GITHUB_USERNAME = username; + process.env.GITHUB_ACCESS_TOKEN = token; + + // @ts-ignore + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient({ required: true }); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana', + timeout: 10000, + auth: { username, password: token }, + }); + + expect(client).toEqual(fakeClient); + }); + + it('should create the enterprise client when the credentials are defined', () => { + const username = 'grafana'; + const token = 'averysecureaccesstoken'; + + process.env.GITHUB_USERNAME = username; + process.env.GITHUB_ACCESS_TOKEN = token; + + // @ts-ignore + const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); + + const github = new GithubClient({ required: true, enterprise: true }); + const client = github.client; + + expect(spy).toHaveBeenCalledWith({ + baseURL: 'https://api.github.com/repos/grafana/grafana-enterprise', + timeout: 10000, + auth: { username, password: token }, + }); + + expect(client).toEqual(fakeClient); + }); + + describe('when the credentials are not defined', () => { + it('should throw an error', () => { + expect(() => { + // eslint-disable-next-line + new GithubClient({ required: true }); + }).toThrow(/operation needs a GITHUB_USERNAME and GITHUB_ACCESS_TOKEN environment variables/); + }); + }); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/cli/utils/githubClient.ts b/packages/grafana-toolkit/src/cli/utils/githubClient.ts new file mode 100644 index 0000000..9643c16 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/githubClient.ts @@ -0,0 +1,49 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; + +const grafanaURL = (owner: string, repo: string) => `https://api.github.com/repos/${owner}/${repo}`; +const enterpriseURL = 'https://api.github.com/repos/grafana/grafana-enterprise'; + +// Encapsulates the creation of a client for the GitHub API +// +// Two key things: +// 1. You can specify whenever you want the credentials to be required or not when imported. +// 2. If the credentials are available as part of the environment, even if +// they're not required - the library will use them. This allows us to overcome +// any API rate limiting imposed without authentication. + +interface GithubClientProps { + required?: boolean; + enterprise?: boolean; + owner?: string; + repo?: string; +} + +class GithubClient { + client: AxiosInstance; + + constructor({ required = false, enterprise = false, owner = 'grafana', repo = 'grafana' }: GithubClientProps = {}) { + const username = process.env.GITHUB_USERNAME; + const token = process.env.GITHUB_ACCESS_TOKEN; + + const clientConfig: AxiosRequestConfig = { + baseURL: enterprise ? enterpriseURL : grafanaURL(owner, repo), + timeout: 10000, + }; + + if (required && !username && !token) { + throw new Error('operation needs a GITHUB_USERNAME and GITHUB_ACCESS_TOKEN environment variables'); + } + + if (username && token) { + clientConfig.auth = { username: username, password: token }; + } + + this.client = this.createClient(clientConfig); + } + + private createClient(clientConfig: AxiosRequestConfig) { + return axios.create(clientConfig); + } +} + +export default GithubClient; diff --git a/packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts b/packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts new file mode 100644 index 0000000..74fedc2 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts @@ -0,0 +1,10 @@ +import { GitHubRelease } from './githubRelease'; + +describe('GithubRelease', () => { + it('should initialise a GithubRelease', () => { + process.env.GITHUB_ACCESS_TOKEN = '12345'; + process.env.GITHUB_USERNAME = 'test@grafana.com'; + const github = new GitHubRelease('A token', 'A username', 'A repo', 'Some release notes'); + expect(github).toBeInstanceOf(GitHubRelease); + }); +}); diff --git a/packages/grafana-toolkit/src/cli/utils/githubRelease.ts b/packages/grafana-toolkit/src/cli/utils/githubRelease.ts new file mode 100644 index 0000000..54df0ff --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/githubRelease.ts @@ -0,0 +1,113 @@ +import { getPluginId } from '../../config/utils/getPluginId'; +import { getPluginJson } from '../../config/utils/pluginValidation'; +import { getCiFolder } from '../../plugins/env'; +import path = require('path'); +import fs = require('fs'); +// @ts-ignore +// import execa = require('execa'); +import GithubClient from './githubClient'; +import { AxiosResponse } from 'axios'; + +const resolveContentType = (extension: string): string => { + if (extension.startsWith('.')) { + extension = extension.substr(1); + } + switch (extension) { + case 'zip': + return 'application/zip'; + case 'json': + return 'application/json'; + case 'sha1': + return 'text/plain'; + default: + return 'application/octet-stream'; + } +}; + +class GitHubRelease { + token: string; + username: string; + repository: string; + releaseNotes: string; + commitHash?: string; + git: GithubClient; + + constructor(token: string, username: string, repository: string, releaseNotes: string, commitHash?: string) { + this.token = token; + this.username = username; + this.repository = repository; + this.releaseNotes = releaseNotes; + this.commitHash = commitHash; + + this.git = new GithubClient({ + required: true, + owner: username, + repo: repository, + }); + } + + publishAssets(srcLocation: string, destUrl: string) { + // Add the assets. Loop through files in the ci/dist folder and upload each asset. + const files = fs.readdirSync(srcLocation); + + return files.map(async (file: string) => { + const fileStat = fs.statSync(`${srcLocation}/${file}`); + const fileData = fs.readFileSync(`${srcLocation}/${file}`); + return this.git.client.post(`${destUrl}?name=${file}`, fileData, { + headers: { + 'Content-Type': resolveContentType(path.extname(file)), + 'Content-Length': fileStat.size, + }, + maxContentLength: fileStat.size * 2 * 1024 * 1024, + }); + }); + } + + async release() { + const ciDir = getCiFolder(); + const distDir = path.resolve(ciDir, 'dist'); + const distContentDir = path.resolve(distDir, getPluginId()); + const pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); + const pluginInfo = getPluginJson(pluginJsonFile).info; + const PUBLISH_DIR = path.resolve(getCiFolder(), 'packages'); + const commitHash = this.commitHash || pluginInfo.build?.hash; + + try { + const latestRelease: AxiosResponse = await this.git.client.get(`releases/tags/v${pluginInfo.version}`); + + // Re-release if the version is the same as an existing release + if (latestRelease.data.tag_name === `v${pluginInfo.version}`) { + await this.git.client.delete(`releases/${latestRelease.data.id}`); + } + } catch (reason) { + if (reason.response.status !== 404) { + // 404 just means no release found. Not an error. Anything else though, re throw the error + throw reason; + } + } + + try { + // Now make the release + const newReleaseResponse = await this.git.client.post('releases', { + tag_name: `v${pluginInfo.version}`, + target_commitish: commitHash, + name: `v${pluginInfo.version}`, + body: this.releaseNotes, + draft: false, + prerelease: false, + }); + + const publishPromises = this.publishAssets( + PUBLISH_DIR, + `https://uploads.github.com/repos/${this.username}/${this.repository}/releases/${newReleaseResponse.data.id}/assets` + ); + await Promise.all(publishPromises); + } catch (reason) { + console.error(reason.data?.message ?? reason.response.data ?? reason); + // Rethrow the error so that we can trigger a non-zero exit code to circle-ci + throw reason; + } + } +} + +export { GitHubRelease }; diff --git a/packages/grafana-toolkit/src/cli/utils/pascalCase.ts b/packages/grafana-toolkit/src/cli/utils/pascalCase.ts new file mode 100644 index 0000000..e842a71 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/pascalCase.ts @@ -0,0 +1,3 @@ +import _ from 'lodash'; + +export const pascalCase = _.flow(_.camelCase, _.upperFirst); diff --git a/packages/grafana-toolkit/src/cli/utils/prompt.ts b/packages/grafana-toolkit/src/cli/utils/prompt.ts new file mode 100644 index 0000000..668f64c --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/prompt.ts @@ -0,0 +1,79 @@ +import { + Question, + InputQuestion, + CheckboxQuestion, + NumberQuestion, + PasswordQuestion, + EditorQuestion, + ConfirmQuestion, + ListQuestion, + ChoiceOptions, +} from 'inquirer'; + +type QuestionWithValidation = + | InputQuestion + | CheckboxQuestion + | NumberQuestion + | PasswordQuestion + | EditorQuestion; + +export const answerRequired = (question: QuestionWithValidation): Question => { + return { + ...question, + validate: (answer: any) => answer.trim() !== '' || `${question.name} is required`, + }; +}; + +export const promptInput = ( + name: string, + message: string | ((answers: A) => string), + required = false, + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: InputQuestion = { + type: 'input', + name, + message, + default: def, + when, + }; + + return required ? answerRequired(model) : model; +}; + +export const promptList = ( + name: string, + message: string | ((answers: A) => string), + choices: () => ChoiceOptions[], + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: ListQuestion = { + type: 'list', + name, + message, + choices, + default: def, + when, + }; + + return model; +}; + +export const promptConfirm = ( + name: string, + message: string | ((answers: A) => string), + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: ConfirmQuestion = { + type: 'confirm', + name, + message, + default: def, + when, + }; + + return model; +}; diff --git a/packages/grafana-toolkit/src/cli/utils/rmdir.ts b/packages/grafana-toolkit/src/cli/utils/rmdir.ts new file mode 100644 index 0000000..f106562 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/rmdir.ts @@ -0,0 +1,23 @@ +import fs = require('fs'); +import path = require('path'); + +/** + * Remove directory recursively + * Ref https://stackoverflow.com/a/42505874 + */ +export const rmdir = (dirPath: string) => { + if (!fs.existsSync(dirPath)) { + return; + } + + fs.readdirSync(dirPath).forEach((entry) => { + const entryPath = path.join(dirPath, entry); + if (fs.lstatSync(entryPath).isDirectory()) { + rmdir(entryPath); + } else { + fs.unlinkSync(entryPath); + } + }); + + fs.rmdirSync(dirPath); +}; diff --git a/packages/grafana-toolkit/src/cli/utils/useSpinner.ts b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts new file mode 100644 index 0000000..8f430cb --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts @@ -0,0 +1,16 @@ +import ora from 'ora'; + +export const useSpinner = async (label: string, fn: () => Promise, killProcess = true) => { + const spinner = ora(label); + spinner.start(); + try { + await fn(); + spinner.succeed(); + } catch (e) { + console.trace(e); // eslint-disable-line no-console + spinner.fail(e.message || e); + if (killProcess) { + process.exit(1); + } + } +}; diff --git a/packages/grafana-toolkit/src/config/eslint.plugin.json b/packages/grafana-toolkit/src/config/eslint.plugin.json new file mode 100644 index 0000000..83c484b --- /dev/null +++ b/packages/grafana-toolkit/src/config/eslint.plugin.json @@ -0,0 +1,6 @@ +{ + "extends": ["@grafana/eslint-config"], + "rules": { + "react/prop-types": "off" + } +} diff --git a/packages/grafana-toolkit/src/config/index.ts b/packages/grafana-toolkit/src/config/index.ts new file mode 100644 index 0000000..ccb2e12 --- /dev/null +++ b/packages/grafana-toolkit/src/config/index.ts @@ -0,0 +1 @@ +export { CustomWebpackConfigurationGetter, WebpackConfigurationOptions } from './webpack.plugin.config'; diff --git a/packages/grafana-toolkit/src/config/jest.plugin.config.local.js b/packages/grafana-toolkit/src/config/jest.plugin.config.local.js new file mode 100644 index 0000000..bcf17c9 --- /dev/null +++ b/packages/grafana-toolkit/src/config/jest.plugin.config.local.js @@ -0,0 +1,8 @@ +// This file is needed because it is used by vscode and other tools that +// call `jest` directly. However, unless you are doing anything special +// do not edit this file + +const standard = require('@grafana/toolkit/src/config/jest.plugin.config'); + +// This process will use the same config that `yarn test` is using +module.exports = standard.jestConfig(); diff --git a/packages/grafana-toolkit/src/config/jest.plugin.config.test.ts b/packages/grafana-toolkit/src/config/jest.plugin.config.test.ts new file mode 100644 index 0000000..794a9fc --- /dev/null +++ b/packages/grafana-toolkit/src/config/jest.plugin.config.test.ts @@ -0,0 +1,34 @@ +import { jestConfig, allowedJestConfigOverrides } from './jest.plugin.config'; + +describe('Jest config', () => { + it('should throw if not supported overrides provided', () => { + const getConfig = () => jestConfig(`${__dirname}/mocks/jestSetup/unsupportedOverrides`); + + expect(getConfig).toThrow('Provided Jest config is not supported'); + }); + + it(`should allow ${allowedJestConfigOverrides} settings overrides`, () => { + const config = jestConfig(`${__dirname}/mocks/jestSetup/overrides`); + const configKeys = Object.keys(config); + + for (const whitelistedOption of allowedJestConfigOverrides) { + expect(configKeys).toContain(whitelistedOption); + } + }); + + describe('stylesheets support', () => { + it('should provide module name mapper for stylesheets by default', () => { + const config = jestConfig(`${__dirname}/mocks/jestSetup/noOverrides`); + expect(config.moduleNameMapper).toBeDefined(); + expect(Object.keys(config.moduleNameMapper)).toContain('\\.(css|sass|scss)$'); + }); + + it('should preserve mapping for stylesheets when moduleNameMapper overrides provided', () => { + const config = jestConfig(`${__dirname}/mocks/jestSetup/overrides`); + expect(config.moduleNameMapper).toBeDefined(); + expect(Object.keys(config.moduleNameMapper)).toHaveLength(2); + expect(Object.keys(config.moduleNameMapper)).toContain('\\.(css|sass|scss)$'); + expect(Object.keys(config.moduleNameMapper)).toContain('someOverride'); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/config/jest.plugin.config.ts b/packages/grafana-toolkit/src/config/jest.plugin.config.ts new file mode 100644 index 0000000..313770a --- /dev/null +++ b/packages/grafana-toolkit/src/config/jest.plugin.config.ts @@ -0,0 +1,110 @@ +import path = require('path'); +import fs from 'fs'; + +export const allowedJestConfigOverrides = [ + 'snapshotSerializers', + 'moduleNameMapper', + 'globalSetup', + 'globalTeardown', + 'testEnvironment', +]; + +interface EnabledJestConfigOverrides { + snapshotSerializers: string[]; + moduleNameMapper: { [key: string]: string }; +} + +const getSetupFile = (filePath: string) => { + if (fs.existsSync(`${filePath}.js`)) { + return `${filePath}.js`; + } + if (fs.existsSync(`${filePath}.ts`)) { + return `${filePath}.ts`; + } + return undefined; +}; + +export const jestConfig = (baseDir: string = process.cwd()) => { + const jestConfigOverrides = (require(path.resolve(baseDir, 'package.json')).jest || {}) as EnabledJestConfigOverrides; + + const deniedOverrides = jestConfigOverrides + ? Object.keys(jestConfigOverrides).filter((override) => allowedJestConfigOverrides.indexOf(override) === -1) + : []; + + if (deniedOverrides.length > 0) { + console.error("\ngrafana-toolkit doesn't support following Jest options: ", deniedOverrides); + console.log('Supported Jest options are: ', JSON.stringify(allowedJestConfigOverrides)); + throw new Error('Provided Jest config is not supported'); + } + + const shimsFilePath = path.resolve(baseDir, 'config/jest-shim'); + const setupFilePath = path.resolve(baseDir, 'config/jest-setup'); + + // Mock css imports for tests. Otherwise Jest will have troubles understanding SASS/CSS imports + const { moduleNameMapper, ...otherOverrides } = jestConfigOverrides; + const moduleNameMapperConfig = { + '\\.(css|sass|scss)$': `${__dirname}/styles.mock.js`, + ...moduleNameMapper, + }; + + const setupFile = getSetupFile(setupFilePath); + const shimsFile = getSetupFile(shimsFilePath); + + const setupFiles = [setupFile, shimsFile, 'jest-canvas-mock'].filter((f) => f); + const defaultJestConfig = { + preset: 'ts-jest', + verbose: false, + moduleDirectories: ['node_modules', 'src'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + setupFiles, + globals: { + 'ts-jest': { + isolatedModules: true, + tsConfig: path.resolve(baseDir, 'tsconfig.json'), + }, + }, + coverageReporters: ['json-summary', 'text', 'lcov'], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!**/node_modules/**', '!**/vendor/**'], + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'coverage', + }, + ], + ], + testEnvironment: 'jest-environment-jsdom-fifteen', + testMatch: [ + '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', + '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', + '/spec/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', + ], + transform: { + '^.+\\.js$': 'babel-jest', + }, + transformIgnorePatterns: [ + '[/\\\\\\\\]node_modules[/\\\\\\\\].+\\\\.(js|jsx|ts|tsx)$', + '^.+\\\\.module\\\\.(css|sass|scss)$', + ], + moduleNameMapper: moduleNameMapperConfig, + }; + + return { + ...defaultJestConfig, + ...otherOverrides, + }; +}; + +/** + * This will load the existing just setup, or use the default if it exists + */ +export const loadJestPluginConfig = (baseDir: string = process.cwd()) => { + const cfgpath = path.resolve(baseDir, 'jest.config.js'); + if (!fs.existsSync(cfgpath)) { + const src = path.resolve(baseDir, 'node_modules/@grafana/toolkit/src/config/jest.plugin.config.local.js'); + fs.copyFileSync(src, cfgpath); + console.log('Using standard jest plugin config', src); + } + return require(cfgpath); +}; diff --git a/packages/grafana-toolkit/src/config/mocks/jestSetup/noOverrides/package.json b/packages/grafana-toolkit/src/config/mocks/jestSetup/noOverrides/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/jestSetup/noOverrides/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/grafana-toolkit/src/config/mocks/jestSetup/overrides/package.json b/packages/grafana-toolkit/src/config/mocks/jestSetup/overrides/package.json new file mode 100644 index 0000000..0b7f4df --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/jestSetup/overrides/package.json @@ -0,0 +1,11 @@ +{ + "jest": { + "moduleNameMapper": { + "someOverride": "somePath" + }, + "snapshotSerializers": "serializers", + "globalSetup": "path", + "globalTeardown": "path", + "testEnvironment": "node" + } +} diff --git a/packages/grafana-toolkit/src/config/mocks/jestSetup/unsupportedOverrides/package.json b/packages/grafana-toolkit/src/config/mocks/jestSetup/unsupportedOverrides/package.json new file mode 100644 index 0000000..8450f88 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/jestSetup/unsupportedOverrides/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "runner": "some-runner" + } +} diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.scss b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/dark.scss new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/light.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/duplicates/src/styles/light.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/missing-theme-file/src/styles/light.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/missing-theme-file/src/styles/light.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/no-theme-files/src/styles/whatever.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/no-theme-files/src/styles/whatever.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/dark.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/dark.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/light.css b/packages/grafana-toolkit/src/config/mocks/stylesheetsSupport/ok/src/styles/light.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json new file mode 100644 index 0000000..4efb0bb --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/noOverride/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json new file mode 100644 index 0000000..4efb0bb --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.js b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.js new file mode 100644 index 0000000..e96ceee --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overrides/webpack.config.js @@ -0,0 +1,10 @@ +'use strict'; +const { cloneDeep } = require('lodash'); + +const overrideWebpackConfig = (originalConfig, options) => { + const config = cloneDeep(originalConfig); + config.name = 'customConfig'; + return config; +}; + +module.exports = overrideWebpackConfig; diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json new file mode 100644 index 0000000..4efb0bb --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.js b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.js new file mode 100644 index 0000000..d2e086e --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/overridesNamedExport/webpack.config.js @@ -0,0 +1,8 @@ +'use strict'; +const { cloneDeep } = require('lodash'); + +module.exports.getWebpackConfig = (originalConfig, options) => { + const config = cloneDeep(originalConfig); + config.name = 'customConfig'; + return config; +}; diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json new file mode 100644 index 0000000..4efb0bb --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/package.json @@ -0,0 +1,3 @@ +{ + "version": "Testversion" +} diff --git a/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.js b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.js new file mode 100644 index 0000000..f62e5e8 --- /dev/null +++ b/packages/grafana-toolkit/src/config/mocks/webpack/unsupportedOverride/webpack.config.js @@ -0,0 +1,5 @@ +/* WRONG CONFIG ON PURPOSE - DO NOT COPY THIS */ + +module.exports.config = { + name: 'test', +}; diff --git a/packages/grafana-toolkit/src/config/prettier.plugin.config.json b/packages/grafana-toolkit/src/config/prettier.plugin.config.json new file mode 100644 index 0000000..49bc44f --- /dev/null +++ b/packages/grafana-toolkit/src/config/prettier.plugin.config.json @@ -0,0 +1,5 @@ +{ + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120 +} diff --git a/packages/grafana-toolkit/src/config/prettier.plugin.rc.js b/packages/grafana-toolkit/src/config/prettier.plugin.rc.js new file mode 100644 index 0000000..c14684b --- /dev/null +++ b/packages/grafana-toolkit/src/config/prettier.plugin.rc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('@grafana/toolkit/src/config/prettier.plugin.config.json'), +}; diff --git a/packages/grafana-toolkit/src/config/styles.mock.js b/packages/grafana-toolkit/src/config/styles.mock.js new file mode 100644 index 0000000..ebea82f --- /dev/null +++ b/packages/grafana-toolkit/src/config/styles.mock.js @@ -0,0 +1,4 @@ +// Mock for handling stylesheet file imports in tests +// https://jestjs.io/docs/en/webpack.html#handling-static-assets + +module.exports = {}; diff --git a/packages/grafana-toolkit/src/config/tsconfig.plugin.json b/packages/grafana-toolkit/src/config/tsconfig.plugin.json new file mode 100644 index 0000000..e2948cc --- /dev/null +++ b/packages/grafana-toolkit/src/config/tsconfig.plugin.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "declaration": false + }, + "extends": "@grafana/tsconfig" +} diff --git a/packages/grafana-toolkit/src/config/tsconfig.plugin.local.json b/packages/grafana-toolkit/src/config/tsconfig.plugin.local.json new file mode 100644 index 0000000..52ac908 --- /dev/null +++ b/packages/grafana-toolkit/src/config/tsconfig.plugin.local.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "./src", + "typeRoots": ["./node_modules/@types"], + "jsx": "react" + }, + "extends": "@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["src", "types"] +} diff --git a/packages/grafana-toolkit/src/config/utils/getPluginId.ts b/packages/grafana-toolkit/src/config/utils/getPluginId.ts new file mode 100644 index 0000000..5acf809 --- /dev/null +++ b/packages/grafana-toolkit/src/config/utils/getPluginId.ts @@ -0,0 +1,11 @@ +import path from 'path'; + +let PLUGIN_ID: string; + +export const getPluginId = () => { + if (!PLUGIN_ID) { + const pluginJson = require(path.resolve(process.cwd(), 'src/plugin.json')); + PLUGIN_ID = pluginJson.id; + } + return PLUGIN_ID; +}; diff --git a/packages/grafana-toolkit/src/config/utils/pluginValidation.test.ts b/packages/grafana-toolkit/src/config/utils/pluginValidation.test.ts new file mode 100644 index 0000000..3370571 --- /dev/null +++ b/packages/grafana-toolkit/src/config/utils/pluginValidation.test.ts @@ -0,0 +1,15 @@ +import { getPluginJson, validatePluginJson } from './pluginValidation'; + +describe('pluginValidation', () => { + describe('plugin.json', () => { + test('missing plugin.json file', () => { + expect(() => getPluginJson(`${__dirname}/mocks/missing-plugin.json`)).toThrowError(); + }); + }); + + describe('validatePluginJson', () => { + test('missing plugin.json file', () => { + expect(() => validatePluginJson({})).toThrow('Plugin id is missing in plugin.json'); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/config/utils/pluginValidation.ts b/packages/grafana-toolkit/src/config/utils/pluginValidation.ts new file mode 100644 index 0000000..f2ff2cd --- /dev/null +++ b/packages/grafana-toolkit/src/config/utils/pluginValidation.ts @@ -0,0 +1,38 @@ +import { PluginMeta } from '@grafana/data'; + +export const validatePluginJson = (pluginJson: any) => { + if (!pluginJson.id) { + throw new Error('Plugin id is missing in plugin.json'); + } + + if (!pluginJson.info) { + throw new Error('Plugin info node is missing in plugin.json'); + } + + if (!pluginJson.info.version) { + throw new Error('Plugin info.version is missing in plugin.json'); + } + + const types = ['panel', 'datasource', 'app']; + const type = pluginJson.type; + if (!types.includes(type)) { + throw new Error('Invalid plugin type in plugin.json: ' + type); + } + + if (!pluginJson.id.endsWith('-' + type)) { + throw new Error('[plugin.json] id should end with: -' + type); + } +}; + +export const getPluginJson = (path: string): PluginMeta => { + let pluginJson; + try { + pluginJson = require(path); + } catch (e) { + throw new Error('Unable to find: ' + path); + } + + validatePluginJson(pluginJson); + + return pluginJson as PluginMeta; +}; diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts new file mode 100644 index 0000000..7d89383 --- /dev/null +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts @@ -0,0 +1,76 @@ +import { findModuleFiles, loadWebpackConfig } from './webpack.plugin.config'; +import fs from 'fs'; +// eslint-disable-next-line no-duplicate-imports +import * as webpackConfig from './webpack.plugin.config'; + +jest.mock('./webpack/loaders', () => ({ + getFileLoaders: (): Array<{}> => [], + getStylesheetEntries: () => ({}), + getStyleLoaders: (): Array<{}> => [], +})); + +const modulePathsMock = [ + 'some/path/module.ts', + 'some/path/module.ts.whatever', + 'some/path/module.tsx', + 'some/path/module.tsx.whatever', + 'some/path/anotherFile.ts', + 'some/path/anotherFile.tsx', +]; + +describe('Plugin webpack config', () => { + describe('findModuleTs', () => { + beforeAll(() => { + jest.spyOn(fs, 'statSync').mockReturnValue({ + isDirectory: () => false, + } as any); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('finds module.ts and module.tsx files', async () => { + const moduleFiles = await findModuleFiles('/', modulePathsMock); + expect(moduleFiles.length).toBe(2); + // normalize windows path - \\ -> / + expect(moduleFiles.map((p) => p.replace(/\\/g, '/'))).toEqual(['/some/path/module.ts', '/some/path/module.tsx']); + }); + }); + + describe('loadWebpackConfig', () => { + beforeAll(() => { + jest.spyOn(webpackConfig, 'findModuleFiles').mockReturnValue(new Promise((res, _) => res([]))); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('uses default config if no override exists', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/noOverride/`); + await loadWebpackConfig({}); + }); + + it('calls customConfig if it exists', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/overrides/`); + const config = await loadWebpackConfig({}); + expect(config.name).toBe('customConfig'); + }); + + it('loads export named getWebpackConfiguration', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/overridesNamedExport/`); + const config = await loadWebpackConfig({}); + expect(config.name).toBe('customConfig'); + }); + + it('throws an error if module does not export function', async () => { + const spy = jest.spyOn(process, 'cwd'); + spy.mockReturnValue(`${__dirname}/mocks/webpack/unsupportedOverride/`); + await expect(loadWebpackConfig({})).rejects.toThrowError(); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts new file mode 100644 index 0000000..106eb62 --- /dev/null +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -0,0 +1,282 @@ +const fs = require('fs'); +const util = require('util'); +const path = require('path'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); + +const readdirPromise = util.promisify(fs.readdir); +const accessPromise = util.promisify(fs.access); + +import * as webpack from 'webpack'; +import { getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders'; + +export interface WebpackConfigurationOptions { + watch?: boolean; + production?: boolean; + preserveConsole?: boolean; +} + +type WebpackConfigurationGetter = (options: WebpackConfigurationOptions) => Promise; + +export type CustomWebpackConfigurationGetter = ( + originalConfig: webpack.Configuration, + options: WebpackConfigurationOptions +) => webpack.Configuration; + +export const findModuleFiles = async (base: string, files?: string[], result?: string[]) => { + files = files || (await readdirPromise(base)); + result = result || []; + + if (files) { + await Promise.all( + files.map(async (file) => { + const newbase = path.join(base, file); + if (fs.statSync(newbase).isDirectory()) { + result = await findModuleFiles(newbase, await readdirPromise(newbase), result); + } else { + const filename = path.basename(file); + if (/^module.(t|j)sx?$/.exec(filename)) { + // @ts-ignore + result.push(newbase); + } + } + }) + ); + } + return result; +}; + +const getModuleFiles = () => { + return findModuleFiles(path.resolve(process.cwd(), 'src')); +}; + +const getManualChunk = (id: string) => { + if (id.endsWith('module.ts') || id.endsWith('module.js') || id.endsWith('module.tsx')) { + const idx = id.lastIndexOf(path.sep + 'src' + path.sep); + if (idx > 0) { + const name = id.substring(idx + 5, id.lastIndexOf('.')); + + return { + name, + module: id, + }; + } + } + return null; +}; + +const getEntries = async () => { + const entries: { [key: string]: string } = {}; + const modules = await getModuleFiles(); + + modules.forEach((modFile) => { + const mod = getManualChunk(modFile); + // @ts-ignore + entries[mod.name] = mod.module; + }); + return { + ...entries, + ...getStylesheetEntries(), + }; +}; + +const getCommonPlugins = (options: WebpackConfigurationOptions) => { + const hasREADME = fs.existsSync(path.resolve(process.cwd(), 'src', 'README.md')); + const packageJson = require(path.resolve(process.cwd(), 'package.json')); + return [ + new MiniCssExtractPlugin({ + // both options are optional + filename: 'styles/[name].css', + }), + new webpack.optimize.OccurrenceOrderPlugin(true), + new CopyWebpackPlugin( + [ + // If src/README.md exists use it; otherwise the root README + { from: hasREADME ? 'README.md' : '../README.md', to: '.', force: true }, + { from: 'plugin.json', to: '.' }, + { from: '../LICENSE', to: '.' }, + { from: '../CHANGELOG.md', to: '.', force: true }, + { from: '**/*.json', to: '.' }, + { from: '**/*.svg', to: '.' }, + { from: '**/*.png', to: '.' }, + { from: '**/*.html', to: '.' }, + { from: 'img/**/*', to: '.' }, + { from: 'libs/**/*', to: '.' }, + { from: 'static/**/*', to: '.' }, + ], + { logLevel: options.watch ? 'silent' : 'warn' } + ), + + new ReplaceInFileWebpackPlugin([ + { + dir: 'dist', + files: ['plugin.json', 'README.md'], + rules: [ + { + search: '%VERSION%', + replace: packageJson.version, + }, + { + search: '%TODAY%', + replace: new Date().toISOString().substring(0, 10), + }, + ], + }, + ]), + new ForkTsCheckerWebpackPlugin({ + tsconfig: path.join(process.cwd(), 'tsconfig.json'), + // Only report problems in detected in plugin's code + reportFiles: ['**/*.{ts,tsx}'], + }), + ]; +}; + +const getBaseWebpackConfig: WebpackConfigurationGetter = async (options) => { + const plugins = getCommonPlugins(options); + const optimization: { [key: string]: any } = {}; + + if (options.production) { + const compressOptions = { drop_console: !options.preserveConsole, drop_debugger: true }; + optimization.minimizer = [ + new TerserPlugin({ sourceMap: true, terserOptions: { compress: compressOptions } }), + new OptimizeCssAssetsPlugin(), + ]; + } else if (options.watch) { + plugins.push(new HtmlWebpackPlugin()); + } + + return { + mode: options.production ? 'production' : 'development', + target: 'web', + node: { + fs: 'empty', + net: 'empty', + tls: 'empty', + }, + context: path.join(process.cwd(), 'src'), + devtool: 'source-map', + entry: await getEntries(), + output: { + filename: '[name].js', + path: path.join(process.cwd(), 'dist'), + libraryTarget: 'amd', + publicPath: '/', + }, + + performance: { hints: false }, + externals: [ + 'lodash', + 'jquery', + 'moment', + 'slate', + 'emotion', + 'prismjs', + 'slate-plain-serializer', + '@grafana/slate-react', + 'react', + 'react-dom', + 'react-redux', + 'redux', + 'rxjs', + 'd3', + 'angular', + '@grafana/ui', + '@grafana/runtime', + '@grafana/data', + 'monaco-editor', + 'react-monaco-editor', + // @ts-ignore + (context, request, callback) => { + const prefix = 'grafana/'; + if (request.indexOf(prefix) === 0) { + return callback(null, request.substr(prefix.length)); + } + + // @ts-ignore + callback(); + }, + ], + plugins, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loaders: [ + { + loader: 'babel-loader', + options: { + presets: [['@babel/preset-env', { modules: false }]], + plugins: ['angularjs-annotate'], + sourceMaps: true, + }, + }, + { + loader: 'ts-loader', + options: { + onlyCompileBundledFiles: true, + transpileOnly: true, + }, + }, + ], + exclude: /(node_modules)/, + }, + { + test: /\.jsx?$/, + loaders: [ + { + loader: 'babel-loader', + options: { + presets: [['@babel/preset-env', { modules: false }]], + plugins: ['angularjs-annotate'], + sourceMaps: true, + }, + }, + ], + exclude: /(node_modules)/, + }, + ...getStyleLoaders(), + { + test: /\.html$/, + exclude: [/node_modules/], + use: { + loader: 'html-loader', + }, + }, + ...getFileLoaders(), + ], + }, + optimization, + }; +}; + +export const loadWebpackConfig: WebpackConfigurationGetter = async (options) => { + const baseConfig = await getBaseWebpackConfig(options); + const customWebpackPath = path.resolve(process.cwd(), 'webpack.config.js'); + + try { + await accessPromise(customWebpackPath); + const customConfig = require(customWebpackPath); + const configGetter = customConfig.getWebpackConfig || customConfig; + if (typeof configGetter !== 'function') { + throw Error( + 'Custom webpack config needs to export a function implementing CustomWebpackConfigurationGetter. Function needs to be ' + + 'module export or named "getWebpackConfig"' + ); + } + return (configGetter as CustomWebpackConfigurationGetter)(baseConfig, options); + } catch (err) { + if (err.code === 'ENOENT') { + return baseConfig; + } + throw err; + } +}; diff --git a/packages/grafana-toolkit/src/config/webpack/loaders.test.ts b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts new file mode 100644 index 0000000..e06d631 --- /dev/null +++ b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts @@ -0,0 +1,46 @@ +import { getStylesheetEntries, hasThemeStylesheets } from './loaders'; + +describe('Loaders', () => { + describe('stylesheet helpers', () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + + afterAll(() => { + logSpy.mockRestore(); + logSpy.mockRestore(); + }); + + describe('getStylesheetEntries', () => { + it('returns entries for dark and light theme', () => { + const result = getStylesheetEntries(`${__dirname}/../mocks/stylesheetsSupport/ok`); + expect(Object.keys(result)).toHaveLength(2); + }); + it('throws on theme files duplicates', () => { + const result = () => { + getStylesheetEntries(`${__dirname}/../mocks/stylesheetsSupport/duplicates`); + }; + expect(result).toThrow(); + }); + }); + + describe('hasThemeStylesheets', () => { + it('throws when only one theme file is defined', () => { + const result = () => { + hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/missing-theme-file`); + }; + expect(result).toThrow(); + }); + + it('returns false when no theme files present', () => { + const result = hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/no-theme-files`); + + expect(result).toBeFalsy(); + }); + + it('returns true when theme files present', () => { + const result = hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/ok`); + + expect(result).toBeTruthy(); + }); + }); + }); +}); diff --git a/packages/grafana-toolkit/src/config/webpack/loaders.ts b/packages/grafana-toolkit/src/config/webpack/loaders.ts new file mode 100644 index 0000000..0409623 --- /dev/null +++ b/packages/grafana-toolkit/src/config/webpack/loaders.ts @@ -0,0 +1,169 @@ +import fs from 'fs'; +import path from 'path'; +import { getPluginId } from '../utils/getPluginId'; + +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + +const supportedExtensions = ['css', 'scss', 'less', 'sass']; + +const getStylesheetPaths = (root: string = process.cwd()) => { + return [`${root}/src/styles/light`, `${root}/src/styles/dark`]; +}; + +export const getStylesheetEntries = (root: string = process.cwd()) => { + const stylesheetsPaths = getStylesheetPaths(root); + const entries: { [key: string]: string } = {}; + supportedExtensions.forEach((e) => { + stylesheetsPaths.forEach((p) => { + const entryName = p.split('/').slice(-1)[0]; + if (fs.existsSync(`${p}.${e}`)) { + if (entries[entryName]) { + console.log(`\nSeems like you have multiple files for ${entryName} theme:`); + console.log(entries[entryName]); + console.log(`${p}.${e}`); + throw new Error('Duplicated stylesheet'); + } else { + entries[entryName] = `${p}.${e}`; + } + } + }); + }); + + return entries; +}; + +export const hasThemeStylesheets = (root: string = process.cwd()) => { + const stylesheetsPaths = getStylesheetPaths(root); + const stylesheetsSummary: boolean[] = []; + + const result = stylesheetsPaths.reduce((acc, current) => { + if (fs.existsSync(`${current}.css`) || fs.existsSync(`${current}.scss`)) { + stylesheetsSummary.push(true); + return acc && true; + } else { + stylesheetsSummary.push(false); + return false; + } + }, true); + + const hasMissingStylesheets = stylesheetsSummary.filter((s) => s).length === 1; + + // seems like there is one theme file defined only + if (result === false && hasMissingStylesheets) { + console.error('\nWe think you want to specify theme stylesheet, but it seems like there is something missing...'); + stylesheetsSummary.forEach((s, i) => { + if (s) { + console.log(stylesheetsPaths[i], 'discovered'); + } else { + console.log(stylesheetsPaths[i], 'missing'); + } + }); + + throw new Error('Stylesheet missing!'); + } + + return result; +}; + +export const getStyleLoaders = () => { + const extractionLoader = { + loader: MiniCssExtractPlugin.loader, + options: { + publicPath: '../', + }, + }; + + const cssLoaders = [ + { + loader: 'css-loader', + options: { + importLoaders: 1, + sourceMap: true, + }, + }, + { + loader: 'postcss-loader', + options: { + plugins: () => [ + require('postcss-flexbugs-fixes'), + require('postcss-preset-env')({ + autoprefixer: { flexbox: 'no-2009', grid: true }, + }), + ], + }, + }, + ]; + + const styleDir = path.resolve(process.cwd(), 'src', 'styles') + path.sep; + const rules = [ + { + test: /(dark|light)\.css$/, + use: [extractionLoader, ...cssLoaders], + }, + { + test: /(dark|light)\.scss$/, + use: [extractionLoader, ...cssLoaders, 'sass-loader'], + }, + { + test: /\.css$/, + use: ['style-loader', ...cssLoaders, 'sass-loader'], + exclude: [`${styleDir}light.css`, `${styleDir}dark.css`], + }, + { + test: /\.s[ac]ss$/, + use: ['style-loader', ...cssLoaders, 'sass-loader'], + exclude: [`${styleDir}light.scss`, `${styleDir}dark.scss`], + }, + { + test: /\.less$/, + use: [ + { + loader: 'style-loader', + }, + ...cssLoaders, + { + loader: 'less-loader', + options: { + javascriptEnabled: true, + }, + }, + ], + exclude: [`${styleDir}light.less`, `${styleDir}dark.less`], + }, + ]; + + return rules; +}; + +export const getFileLoaders = () => { + const shouldExtractCss = hasThemeStylesheets(); + + return [ + { + test: /\.(png|jpe?g|gif|svg)$/, + use: [ + shouldExtractCss + ? { + loader: 'file-loader', + options: { + outputPath: '/', + name: '[path][name].[ext]', + }, + } + : // When using single css import images are inlined as base64 URIs in the result bundle + { + loader: 'url-loader', + }, + ], + }, + { + test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, + loader: 'file-loader', + options: { + publicPath: `/public/plugins/${getPluginId()}/fonts`, + outputPath: 'fonts', + name: '[name].[ext]', + }, + }, + ]; +}; diff --git a/packages/grafana-toolkit/src/plugins/env.ts b/packages/grafana-toolkit/src/plugins/env.ts new file mode 100644 index 0000000..06b659c --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/env.ts @@ -0,0 +1,145 @@ +import execa from 'execa'; +import path from 'path'; +import fs from 'fs'; +import { PluginBuildInfo } from '@grafana/data'; +import { JobInfo } from './types'; + +const getJobFromProcessArgv = () => { + const arg = process.argv[2]; + if (arg && arg.startsWith('plugin:ci-')) { + const task = arg.substring('plugin:ci-'.length); + if ('build' === task) { + if ('--backend' === process.argv[3] && process.argv[4]) { + return task + '_' + process.argv[4]; + } + return 'build_plugin'; + } + return task; + } + return 'unknown_job'; +}; + +export const job = + (process.env.DRONE_STEP_NAME ? process.env.DRONE_STEP_NAME : process.env.CIRCLE_JOB) || getJobFromProcessArgv(); + +export const getPluginBuildInfo = async (): Promise => { + if (process.env.CI === 'true') { + let repo: string | undefined; + let branch: string | undefined; + let hash: string | undefined; + let build: number | undefined; + let pr: number | undefined; + if (process.env.DRONE === 'true') { + repo = process.env.DRONE_REPO_LINK; + branch = process.env.DRONE_BRANCH; + hash = process.env.DRONE_COMMIT_SHA; + build = parseInt(process.env.DRONE_BUILD_NUMBER || '', 10); + pr = parseInt(process.env.DRONE_PULL_REQUEST || '', 10); + } else if (process.env.CIRCLECI === 'true') { + repo = process.env.CIRCLE_REPOSITORY_URL; + branch = process.env.CIRCLE_BRANCH; + hash = process.env.CIRCLE_SHA1; + build = parseInt(process.env.CIRCLE_BUILD_NUM || '', 10); + const url = process.env.CIRCLE_PULL_REQUEST || ''; + const idx = url.lastIndexOf('/') + 1; + pr = parseInt(url.substring(idx), 10); + } + + const info: PluginBuildInfo = { + time: Date.now(), + repo, + branch, + hash, + }; + if (pr) { + info.pr = pr; + } + if (build) { + info.number = build; + } + return info; + } + + const branch = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + const hash = await execa('git', ['rev-parse', 'HEAD']); + return { + time: Date.now(), + branch: branch.stdout, + hash: hash.stdout, + }; +}; + +export const getBuildNumber = (): number | undefined => { + if (process.env.DRONE === 'true') { + return parseInt(process.env.DRONE_BUILD_NUMBER || '', 10); + } else if (process.env.CIRCLECI === 'true') { + return parseInt(process.env.CIRCLE_BUILD_NUM || '', 10); + } + + return undefined; +}; + +export const getPullRequestNumber = (): number | undefined => { + if (process.env.DRONE === 'true') { + return parseInt(process.env.DRONE_PULL_REQUEST || '', 10); + } else if (process.env.CIRCLECI === 'true') { + const url = process.env.CIRCLE_PULL_REQUEST || ''; + const idx = url.lastIndexOf('/') + 1; + return parseInt(url.substring(idx), 10); + } + + return undefined; +}; + +export const getJobFolder = () => { + const dir = path.resolve(process.cwd(), 'ci', 'jobs', job); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +}; + +export const getCiFolder = () => { + const dir = path.resolve(process.cwd(), 'ci'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +}; + +export const writeJobStats = (startTime: number, workDir: string) => { + const endTime = Date.now(); + const stats: JobInfo = { + job, + startTime, + endTime, + elapsed: endTime - startTime, + buildNumber: getBuildNumber(), + }; + const f = path.resolve(workDir, 'job.json'); + fs.writeFile(f, JSON.stringify(stats, null, 2), (err) => { + if (err) { + throw new Error('Unable to stats: ' + f); + } + }); +}; + +// https://circleci.com/api/v1.1/project/github/NatelEnergy/grafana-discrete-panel/latest/artifacts +export async function getCircleDownloadBaseURL(): Promise { + try { + const axios = require('axios'); + const repo = process.env.CIRCLE_PROJECT_REPONAME; + const user = process.env.CIRCLE_PROJECT_USERNAME; + let url = `https://circleci.com/api/v1.1/project/github/${user}/${repo}/latest/artifacts`; + const rsp = await axios.get(url); + for (const s of rsp.data) { + const { path, url } = s; + if (url && path && path.endsWith('report.json')) { + return url.substring(url.length - 'report.json'.length); + } + } + } catch (e) { + console.log('Error reading CircleCI artifact URL', e); + } + return undefined; +} diff --git a/packages/grafana-toolkit/src/plugins/index.ts b/packages/grafana-toolkit/src/plugins/index.ts new file mode 100644 index 0000000..c302e90 --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/index.ts @@ -0,0 +1,4 @@ +export * from './env'; +export * from './utils'; +export * from './workflow'; +export * from './types'; diff --git a/packages/grafana-toolkit/src/plugins/manifest.ts b/packages/grafana-toolkit/src/plugins/manifest.ts new file mode 100644 index 0000000..66c5bff --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/manifest.ts @@ -0,0 +1,90 @@ +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import { ManifestInfo } from './types'; + +const MANIFEST_FILE = 'MANIFEST.txt'; + +async function* walk(dir: string, baseDir: string): AsyncGenerator { + for await (const d of await (fs.promises as any).opendir(dir)) { + const entry = path.posix.join(dir, d.name); + if (d.isDirectory()) { + yield* await walk(entry, baseDir); + } else if (d.isFile()) { + yield path.posix.relative(baseDir, entry); + } else if (d.isSymbolicLink()) { + const realPath = await (fs.promises as any).realpath(entry); + if (!realPath.startsWith(baseDir)) { + throw new Error( + `symbolic link ${path.posix.relative( + baseDir, + entry + )} targets a file outside of the base directory: ${baseDir}` + ); + } + // if resolved symlink target is a file include it in the manifest + const stats = await (fs.promises as any).stat(realPath); + if (stats.isFile()) { + yield path.posix.relative(baseDir, entry); + } + } + } +} + +export async function buildManifest(dir: string): Promise { + const pluginJson = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), { encoding: 'utf8' })); + + const manifest = { + plugin: pluginJson.id, + version: pluginJson.info.version, + files: {}, + } as ManifestInfo; + + for await (const p of await walk(dir, dir)) { + if (p === MANIFEST_FILE) { + continue; + } + + manifest.files[p] = crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(dir, p))) + .digest('hex'); + } + + return manifest; +} + +export async function signManifest(manifest: ManifestInfo): Promise { + const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY; + if (!GRAFANA_API_KEY) { + throw new Error('You must enter a GRAFANA_API_KEY to sign the plugin manifest'); + } + + const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api'; + const url = GRAFANA_COM_URL + '/plugins/ci/sign'; + + const axios = require('axios'); + + try { + const info = await axios.post(url, manifest, { + headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY }, + }); + if (info.status !== 200) { + console.warn('Error: ', info); + throw new Error('Error signing manifest'); + } + + return info.data; + } catch (err) { + if ((err.response && err.response.data) || err.response.data.message) { + throw new Error('Error signing manifest: ' + err.response.data.message); + } + + throw new Error('Error signing manifest: ' + err.message); + } +} + +export async function saveManifest(dir: string, signedManifest: string): Promise { + fs.writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest); + return true; +} diff --git a/packages/grafana-toolkit/src/plugins/types.ts b/packages/grafana-toolkit/src/plugins/types.ts new file mode 100644 index 0000000..792fd09 --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/types.ts @@ -0,0 +1,101 @@ +import { PluginMeta, KeyValue } from '@grafana/data'; + +export interface PluginPackageDetails { + plugin: ZipFileInfo; + docs?: ZipFileInfo; +} + +export interface PluginBuildReport { + plugin: PluginMeta; + packages: PluginPackageDetails; + workflow: WorkflowInfo; + coverage: CoverageInfo[]; + tests: TestResultsInfo[]; + git?: GitLogInfo; + pullRequest?: number; + artifactsBaseURL?: string; + grafanaVersion?: KeyValue; +} + +export interface JobInfo { + job?: string; + startTime: number; + endTime: number; + elapsed: number; + status?: string; + buildNumber?: number; +} + +export interface WorkflowInfo extends JobInfo { + workflowId?: string; + jobs: JobInfo[]; + user?: string; + repo?: string; +} + +export interface CoverageDetails { + total: number; + covered: number; + skipped: number; + pct: number; +} + +export interface CoverageInfo { + job: string; + summary: { [key: string]: CoverageDetails }; + report?: string; // path to report +} + +export interface TestResultsInfo { + job: string; + grafana?: any; + error?: string; + passed: number; + failed: number; + screenshots: string[]; +} + +export interface CountAndSize { + count: number; + bytes: number; +} + +export interface ExtensionSize { + [key: string]: CountAndSize; +} + +export interface ZipFileInfo { + name: string; + size: number; + contents: ExtensionSize; + sha1?: string; + md5?: string; +} + +interface UserInfo { + name: string; + email: string; + time?: number; +} + +export interface GitLogInfo { + commit: string; + tree: string; + subject: string; + body?: string; + notes?: string; + author: UserInfo; + commiter: UserInfo; +} + +export interface ManifestInfo { + // time: number; << filled in by the server + // keyId: string; << filled in by the server + // signedByOrg: string; << filled in by the server + // signedByOrgName: string; << filled in by the server + signatureType?: string; // filled in by the server if not specified + rootUrls?: string[]; // for private signatures + plugin: string; + version: string; + files: Record; +} diff --git a/packages/grafana-toolkit/src/plugins/utils.ts b/packages/grafana-toolkit/src/plugins/utils.ts new file mode 100644 index 0000000..739c837 --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/utils.ts @@ -0,0 +1,130 @@ +import execa from 'execa'; +import path from 'path'; +import fs from 'fs'; +import { KeyValue } from '@grafana/data'; +import { ExtensionSize, ZipFileInfo, GitLogInfo } from './types'; + +const md5File = require('md5-file'); + +export function getGrafanaVersions(): KeyValue { + const dir = path.resolve(process.cwd(), 'node_modules', '@grafana'); + const versions: KeyValue = {}; + try { + fs.readdirSync(dir).forEach((file) => { + const json = require(path.resolve(dir, file, 'package.json')); + versions[file] = json.version; + }); + } catch (err) { + console.warn('Error reading toolkit versions', err); + } + return versions; +} + +export function getFileSizeReportInFolder(dir: string, info?: ExtensionSize): ExtensionSize { + const acc: ExtensionSize = info ? info : {}; + + const files = fs.readdirSync(dir); + if (files) { + files.forEach((file) => { + const newbase = path.join(dir, file); + const stat = fs.statSync(newbase); + if (stat.isDirectory()) { + getFileSizeReportInFolder(newbase, info); + } else { + let ext = '_none_'; + const idx = file.lastIndexOf('.'); + if (idx > 0) { + ext = file.substring(idx + 1).toLowerCase(); + } + const current = acc[ext]; + if (current) { + current.count += 1; + current.bytes += stat.size; + } else { + acc[ext] = { bytes: stat.size, count: 1 }; + } + } + }); + } + return acc; +} + +export async function getPackageDetails(zipFile: string, zipSrc: string, writeChecksum = true): Promise { + const zipStats = fs.statSync(zipFile); + if (zipStats.size < 100) { + throw new Error('Invalid zip file: ' + zipFile); + } + const info: ZipFileInfo = { + name: path.basename(zipFile), + size: zipStats.size, + contents: getFileSizeReportInFolder(zipSrc), + }; + try { + const exe = await execa('shasum', [zipFile]); + const idx = exe.stdout.indexOf(' '); + const sha1 = exe.stdout.substring(0, idx); + if (writeChecksum) { + fs.writeFile(zipFile + '.sha1', sha1, (err) => {}); + } + info.sha1 = sha1; + } catch { + console.warn('Unable to read SHA1 Checksum'); + } + try { + info.md5 = md5File.sync(zipFile); + } catch { + console.warn('Unable to read MD5 Checksum'); + } + return info; +} + +export function findImagesInFolder(dir: string, prefix = '', append?: string[]): string[] { + const imgs = append || []; + + const files = fs.readdirSync(dir); + if (files) { + files.forEach((file) => { + if (file.endsWith('.png')) { + imgs.push(file); + } + }); + } + + return imgs; +} + +export async function readGitLog(): Promise { + try { + let exe = await execa('git', [ + 'log', + '-1', // last line + '--pretty=format:{%n "commit": "%H",%n "tree": "%T",%n "subject": "%s",%n "author": {%n "name": "%aN",%n "email": "%aE",%n "time":"%at" },%n "commiter": {%n "name": "%cN",%n "email": "%cE",%n "time":"%ct" }%n}', + ]); + const info = JSON.parse(exe.stdout) as GitLogInfo; + + // Read the body + exe = await execa('git', [ + 'log', + '-1', // last line + '--pretty=format:%b', // Just the body (with newlines!) + ]); + if (exe.stdout && exe.stdout.length) { + info.body = exe.stdout.trim(); + } + + // Read any commit notes + exe = await execa('git', [ + 'log', + '-1', // last line + '--pretty=format:%N', // commit notes (with newlines!) + ]); + if (exe.stdout && exe.stdout.length) { + info.notes = exe.stdout.trim(); + } + + return info; + } catch (err) { + console.warn('Error REading Git log info', err); + } + return undefined; +} diff --git a/packages/grafana-toolkit/src/plugins/workflow.ts b/packages/grafana-toolkit/src/plugins/workflow.ts new file mode 100644 index 0000000..770afe4 --- /dev/null +++ b/packages/grafana-toolkit/src/plugins/workflow.ts @@ -0,0 +1,99 @@ +import path from 'path'; +import fs from 'fs'; +import { JobInfo, WorkflowInfo, CoverageInfo, TestResultsInfo } from './types'; +import { getBuildNumber, getCiFolder } from './env'; + +export const agregateWorkflowInfo = (): WorkflowInfo => { + const now = Date.now(); + const workflow: WorkflowInfo = { + jobs: [], + startTime: now, + endTime: now, + workflowId: process.env.CIRCLE_WORKFLOW_ID, + repo: process.env.CIRCLE_PROJECT_REPONAME, + user: process.env.CIRCLE_PROJECT_USERNAME, + buildNumber: getBuildNumber(), + elapsed: 0, + }; + + const jobsFolder = path.resolve(getCiFolder(), 'jobs'); + if (fs.existsSync(jobsFolder)) { + const files = fs.readdirSync(jobsFolder); + if (files && files.length) { + files.forEach((file) => { + const p = path.resolve(jobsFolder, file, 'job.json'); + if (fs.existsSync(p)) { + const job = require(p) as JobInfo; + workflow.jobs.push(job); + if (job.startTime < workflow.startTime) { + workflow.startTime = job.startTime; + } + if (job.endTime > workflow.endTime) { + workflow.endTime = job.endTime; + } + } else { + console.log('Missing Job info: ', p); + } + }); + } else { + console.log('NO JOBS IN: ', jobsFolder); + } + } + + workflow.elapsed = workflow.endTime - workflow.startTime; + return workflow; +}; + +export const agregateCoverageInfo = (): CoverageInfo[] => { + const coverage: CoverageInfo[] = []; + const ciDir = getCiFolder(); + const jobsFolder = path.resolve(ciDir, 'jobs'); + if (fs.existsSync(jobsFolder)) { + const files = fs.readdirSync(jobsFolder); + if (files && files.length) { + files.forEach((file) => { + const dir = path.resolve(jobsFolder, file, 'coverage'); + if (fs.existsSync(dir)) { + const s = path.resolve(dir, 'coverage-summary.json'); + const r = path.resolve(dir, 'lcov-report', 'index.html'); + if (fs.existsSync(s)) { + const raw = require(s); + const info: CoverageInfo = { + job: file, + summary: raw.total, + }; + if (fs.existsSync(r)) { + info.report = r.substring(ciDir.length); + } + coverage.push(info); + } + } + }); + } else { + console.log('NO JOBS IN: ', jobsFolder); + } + } + return coverage; +}; + +export const agregateTestInfo = (): TestResultsInfo[] => { + const tests: TestResultsInfo[] = []; + const ciDir = getCiFolder(); + const jobsFolder = path.resolve(ciDir, 'jobs'); + if (fs.existsSync(jobsFolder)) { + const files = fs.readdirSync(jobsFolder); + if (files && files.length) { + files.forEach((file) => { + if (file.startsWith('test')) { + const summary = path.resolve(jobsFolder, file, 'results.json'); + if (fs.existsSync(summary)) { + tests.push(require(summary) as TestResultsInfo); + } + } + }); + } else { + console.log('NO Jobs IN: ', jobsFolder); + } + } + return tests; +}; diff --git a/packages/grafana-toolkit/tsconfig.json b/packages/grafana-toolkit/tsconfig.json new file mode 100644 index 0000000..b6c80bd --- /dev/null +++ b/packages/grafana-toolkit/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "declarationDir": "dist/src", + "module": "commonjs", + "outDir": "dist/src", + "rootDirs": ["."], + "typeRoots": ["node_modules/@types"] + }, + "exclude": ["dist", "node_modules"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts", "../../public/app/types/jquery/*.ts", "../../public/app/types/sanitize-url.d.ts"] +} diff --git a/packages/grafana-ui/.eslintrc b/packages/grafana-ui/.eslintrc new file mode 100644 index 0000000..fa09290 --- /dev/null +++ b/packages/grafana-ui/.eslintrc @@ -0,0 +1,14 @@ +{ + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@grafana/runtime", "@grafana/data/*", "@grafana/ui", "@grafana/e2e"] }] + }, + "overrides": [ + { + "files": ["**/*.{test,story}.{ts,tsx}"], + "rules": { + "no-restricted-imports": "off", + "react/prop-types": "off" + } + } + ] +} diff --git a/packages/grafana-ui/.storybook/main.ts b/packages/grafana-ui/.storybook/main.ts new file mode 100644 index 0000000..a7b4217 --- /dev/null +++ b/packages/grafana-ui/.storybook/main.ts @@ -0,0 +1,138 @@ +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); +const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const FilterWarningsPlugin = require('webpack-filter-warnings-plugin'); + +const stories = ['../src/**/*.story.{js,jsx,ts,tsx,mdx}']; + +if (process.env.NODE_ENV !== 'production') { + stories.push('../src/**/*.story.internal.{js,jsx,ts,tsx,mdx}'); +} + +module.exports = { + stories: stories, + addons: [ + '@storybook/addon-docs', + '@storybook/addon-controls', + '@storybook/addon-knobs', + '@storybook/addon-actions', + 'storybook-dark-mode/register', + '@storybook/addon-storysource', + ], + reactOptions: { + fastRefresh: true, + }, + typescript: { + check: true, + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + tsconfigPath: path.resolve(__dirname, 'tsconfig.json'), + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop: any) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true), + }, + }, + webpackFinal: async (config: any, { configType }: any) => { + const isProductionBuild = configType === 'PRODUCTION'; + config.module.rules = [ + ...(config.module.rules || []), + { + test: /\.tsx?$/, + use: [ + { + loader: require.resolve('ts-loader'), + options: { + transpileOnly: true, + configFile: path.resolve(__dirname, 'tsconfig.json'), + }, + }, + ], + }, + { + test: /\.scss$/, + use: [ + { + loader: 'style-loader', + options: { injectType: 'lazyStyleTag' }, + }, + { + loader: 'css-loader', + options: { + importLoaders: 2, + }, + }, + { + loader: 'postcss-loader', + options: { + sourceMap: false, + config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' }, + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + }, + }, + ], + }, + { + test: require.resolve('jquery'), + use: [ + { + loader: 'expose-loader', + query: 'jQuery', + }, + { + loader: 'expose-loader', + query: '$', + }, + ], + }, + ]; + + config.optimization = { + nodeEnv: 'production', + moduleIds: 'hashed', + runtimeChunk: 'single', + splitChunks: { + chunks: 'all', + minChunks: 1, + cacheGroups: { + vendors: { + test: /[\\/]node_modules[\\/].*[jt]sx?$/, + chunks: 'initial', + priority: -10, + reuseExistingChunk: true, + enforce: true, + }, + default: { + priority: -20, + chunks: 'all', + test: /.*[jt]sx?$/, + reuseExistingChunk: true, + }, + }, + }, + minimize: isProductionBuild, + minimizer: isProductionBuild + ? [ + new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco/ }), + new OptimizeCSSAssetsPlugin({}), + ] + : [], + }; + + config.resolve.alias['@grafana/ui'] = path.resolve(__dirname, '..'); + + // Silence "export not found" webpack warnings with transpileOnly + // https://github.com/TypeStrong/ts-loader#transpileonly + config.plugins.push( + new FilterWarningsPlugin({ + exclude: /export .* was not found in/, + }) + ); + + return config; + }, +}; diff --git a/packages/grafana-ui/.storybook/manager-head.html b/packages/grafana-ui/.storybook/manager-head.html new file mode 100644 index 0000000..0496908 --- /dev/null +++ b/packages/grafana-ui/.storybook/manager-head.html @@ -0,0 +1 @@ + diff --git a/packages/grafana-ui/.storybook/preview.ts b/packages/grafana-ui/.storybook/preview.ts new file mode 100644 index 0000000..9e40a52 --- /dev/null +++ b/packages/grafana-ui/.storybook/preview.ts @@ -0,0 +1,61 @@ +import 'jquery'; +import '../../../public/vendor/flot/jquery.flot.js'; +import '../../../public/vendor/flot/jquery.flot.selection'; +import '../../../public/vendor/flot/jquery.flot.time'; +import '../../../public/vendor/flot/jquery.flot.stack'; +import '../../../public/vendor/flot/jquery.flot.stackpercent'; +import '../../../public/vendor/flot/jquery.flot.fillbelow'; +import '../../../public/vendor/flot/jquery.flot.crosshair'; +import '../../../public/vendor/flot/jquery.flot.dashes'; +import '../../../public/vendor/flot/jquery.flot.gauge'; +import { withTheme } from '../src/utils/storybook/withTheme'; +import { withPaddedStory } from '../src/utils/storybook/withPaddedStory'; +// @ts-ignore +import lightTheme from '../../../public/sass/grafana.light.scss'; +// @ts-ignore +import darkTheme from '../../../public/sass/grafana.dark.scss'; +import { GrafanaLight, GrafanaDark } from './storybookTheme'; +import addons from '@storybook/addons'; + +const handleThemeChange = (theme: any) => { + if (theme !== 'light') { + lightTheme.unuse(); + darkTheme.use(); + } else { + darkTheme.unuse(); + lightTheme.use(); + } +}; + +addons.setConfig({ + showRoots: false, + theme: GrafanaDark, +}); + +export const decorators = [withTheme(handleThemeChange), withPaddedStory]; + +export const parameters = { + info: {}, + docs: { + theme: GrafanaDark, + }, + darkMode: { + dark: GrafanaDark, + light: GrafanaLight, + }, + options: { + showPanel: true, + panelPosition: 'right', + showNav: true, + isFullscreen: false, + isToolshown: true, + storySort: { + method: 'alphabetical', + // Order Docs Overview and Docs Overview/Intro story first + order: ['Docs Overview', ['Intro']], + }, + }, + knobs: { + escapeHTML: false, + }, +}; diff --git a/packages/grafana-ui/.storybook/static/fav32.png b/packages/grafana-ui/.storybook/static/fav32.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d841f0c80a5df8d4ec11f90434877220d9f587 GIT binary patch literal 3606 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 literal 0 HcmV?d00001 diff --git a/packages/grafana-ui/.storybook/static/grafana_icon.svg b/packages/grafana-ui/.storybook/static/grafana_icon.svg new file mode 100644 index 0000000..e91f3ab --- /dev/null +++ b/packages/grafana-ui/.storybook/static/grafana_icon.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/packages/grafana-ui/.storybook/storybookTheme.ts b/packages/grafana-ui/.storybook/storybookTheme.ts new file mode 100644 index 0000000..2188e82 --- /dev/null +++ b/packages/grafana-ui/.storybook/storybookTheme.ts @@ -0,0 +1,49 @@ +//@ts-ignore +import { create } from '@storybook/theming/create'; +import lightTheme from '../src/themes/light'; +import darkTheme from '../src/themes/dark'; +import ThemeCommons from '../src/themes/default'; +import { GrafanaTheme } from '@grafana/data'; + +const createTheme = (theme: GrafanaTheme) => { + return create({ + base: theme.name.includes('Light') ? 'light' : 'dark', + + colorPrimary: theme.palette.brandPrimary, + colorSecondary: theme.palette.brandPrimary, + + // UI + appBg: theme.colors.bodyBg, + appContentBg: theme.colors.bodyBg, + appBorderColor: theme.colors.pageHeaderBorder, + appBorderRadius: 4, + + // Typography + fontBase: ThemeCommons.typography.fontFamily.sansSerif, + fontCode: ThemeCommons.typography.fontFamily.monospace, + + // Text colors + textColor: theme.colors.text, + textInverseColor: 'rgba(255,255,255,0.9)', + + // Toolbar default and active colors + barTextColor: theme.colors.formInputBorderActive, + barSelectedColor: theme.palette.brandPrimary, + barBg: theme.colors.pageHeaderBg, + + // Form colors + inputBg: theme.colors.formInputBg, + inputBorder: theme.colors.formInputBorder, + inputTextColor: theme.colors.formInputText, + inputBorderRadius: 4, + + brandTitle: 'Grafana UI', + brandUrl: './', + brandImage: './grafana_icon.svg', + }); +}; + +const GrafanaLight = createTheme(lightTheme); +const GrafanaDark = createTheme(darkTheme); + +export { GrafanaLight, GrafanaDark }; diff --git a/packages/grafana-ui/.storybook/tsconfig.json b/packages/grafana-ui/.storybook/tsconfig.json new file mode 100644 index 0000000..7a8f86d --- /dev/null +++ b/packages/grafana-ui/.storybook/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declarationDir": "dist", + "noUnusedLocals": false, + "outDir": "compiled" + }, + "exclude": ["../dist", "../node_modules"], + "extends": "../tsconfig.json", + "include": [ + "../src/**/*.ts", + "../src/**/*.tsx", + "../../../public/app/types/sanitize-url.d.ts", + "../../../public/app/types/svg.d.ts" + ] +} diff --git a/packages/grafana-ui/CHANGELOG.md b/packages/grafana-ui/CHANGELOG.md new file mode 100644 index 0000000..f894f1d --- /dev/null +++ b/packages/grafana-ui/CHANGELOG.md @@ -0,0 +1,169 @@ +# 7.3.0-beta1 (2020-10-15) + +### Features / Enhancements +* **NamedColors**: Named colors refactors. [#28235](https://github.com/grafana/grafana/pull/28235), [@torkelo](https://github.com/torkelo) + +# 7.2.0 (2020-09-23) + +### Features / Enhancements +- **grafana/ui**: Do not bundle jQuery. [#27667](https://github.com/grafana/grafana/pull/27667), [@kennytm](https://github.com/kennytm) + +# 7.1.0-beta1 (2020-07-01) + +### Features / Enhancements +- **Grafana-UI**: Add FileUpload. [#25835](https://github.com/grafana/grafana/pull/25835), [@Clarity-89](https://github.com/Clarity-89) +- **Switch**: Deprecate checked prop in favor of value. [#25862](https://github.com/grafana/grafana/pull/25862), [@tskarhed](https://github.com/tskarhed) + + +# 7.0.4 (2020-06-25) + +### Features / Enhancements +- **Slider**: Update rc-slider dependency to 9.3.1. [#25617](https://github.com/grafana/grafana/pull/25617), [@torkelo](https://github.com/torkelo) + +# 7.0.0 (2020-05-18) + +### Bug Fixes + +- **Explore**: Fixes loading more logs in logs context view. [#24135](https://github.com/grafana/grafana/pull/24135), [@Estrax](https://github.com/Estrax) + +# 7.0.0-beta3 (2020-05-08) + +### Features / Enhancements + +- **Forms**: Remove Forms namespace [BREAKING]. Will cause all `Forms` imports to stop working. See migration guide below. [#24378](https://github.com/grafana/grafana/pull/24378), [@tskarhed](https://github.com/tskarhed) + +# 7.0.0-beta.2 (2020-05-07) + +### Bug Fixes + +- **Dashboard**: Fix for folder picker menu not being visible outside modal when saving dashboard. [#24296](https://github.com/grafana/grafana/pull/24296), [@tskarhed](https://github.com/tskarhed) +- **Select**: Fixes so component loses focus on selecting value or pressing outside of input. [#24008](https://github.com/grafana/grafana/pull/24008), [@mckn](https://github.com/mckn) + +# 7.0.0-beta.1 (2020-04-28) + +## Breaking changes + +### @grafana/ui forms migration notice + +In Grafana 7 we have migrated from our old form components to `LegacyForms` namespace. The new components were previously available under the `Forms` namespace. + +All the following components were moved to the LegacyForms namespace, and some replaced with the new form components: + +- `SecretFormField` +- `FormField` +- `Select` +- `AsyncSelect` +- `IndicatorsContainer` +- `NoOptionsMessage` +- `ButtonSelect` +- `Input` +- `Switch` + +One exception to this is `FormLabel`, which has been renamed to `InlineFormLabel`. + +If you were previously using the legacy form styles and your plugin is breaking, change from this: + +```jsx +import { Switch } from '@grafana/ui'; +… + +``` + +To this: + +```jsx +import { LegacyForms } from '@grafana/ui'; +… + +``` + +If you were previously using the new form styles under the `Forms` namespace, change from this: + +```jsx +import { Forms } from '@grafana/ui'; +… + +``` + +To this: + +```jsx +import { Switch} from '@grafana/ui'; +… + +``` + +To see the new form components visit [our Storybook](https://developers.grafana.com/ui) + +### Create custom value with Select + +Previously the only thing you had to do to enable creating a custom value with Select was to add the `allowCustomValue` prop. Now you also have to add a `onCreateOption` handler. + +Before: + +```jsx +import { Select } from '@grafana/ui'; +... + { + // Do things with the customValue +}} +/> + +``` + +### Features / Enhancements + +- **@grafana/ui**: Create Icon component and replace icons. [#23402](https://github.com/grafana/grafana/pull/23402), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Create slider component. [#22275](https://github.com/grafana/grafana/pull/22275), [@ivanahuckova](https://github.com/ivanahuckova) +- **@grafana/ui**: Remove ColorPalette component. [#23592](https://github.com/grafana/grafana/pull/23592), [@ivanahuckova](https://github.com/ivanahuckova) +- **Components**: IconButton. [#23510](https://github.com/grafana/grafana/pull/23510), [@torkelo](https://github.com/torkelo) +- **Docs**: Adding API reference documentation support for the packages libraries. [#21931](https://github.com/grafana/grafana/pull/21931), [@mckn](https://github.com/mckn) +- **Migration**: Add old Input to legacy namespace. [#23286](https://github.com/grafana/grafana/pull/23286), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Final components to LegacyForms. [#23707](https://github.com/grafana/grafana/pull/23707), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move Switch from Forms namespace. [#23386](https://github.com/grafana/grafana/pull/23386), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Move last components from Forms namespace. [#23556](https://github.com/grafana/grafana/pull/23556), [@tskarhed](https://github.com/tskarhed) +- **Migration**: Remove Button from Forms namespace. [#23105](https://github.com/grafana/grafana/pull/23105), [@tskarhed](https://github.com/tskarhed) +- **Migration**: TextArea from Forms namespace. [#23436](https://github.com/grafana/grafana/pull/23436), [@tskarhed](https://github.com/tskarhed) +- **grafana/ui**: Add basic horizontal and vertical layout components. [#22303](https://github.com/grafana/grafana/pull/22303), [@dprokop](https://github.com/dprokop) + +### Bug Fixes + +- **@grafana/ui**: Fix time range when only partial datetime is provided. [#23122](https://github.com/grafana/grafana/pull/23122), [@ivanahuckova](https://github.com/ivanahuckova) + +# 6.6.0-beta.1.0 (2020-01-20) + +### Features / Enhancements + +- **Forms**: introduce RadioButtonGroup. [#20828](https://github.com/grafana/grafana/pull/20828), [@dprokop](https://github.com/dprokop) +- **grafana/ui**: ConfirmModal component. [#20965](https://github.com/grafana/grafana/pull/20965), [@alexanderzobnin](https://github.com/alexanderzobnin) +- **grafana/ui**: Create Tabs component. [#21328](https://github.com/grafana/grafana/pull/21328), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New table component. [#20991](https://github.com/grafana/grafana/pull/20991), [@peterholmberg](https://github.com/peterholmberg) +- **grafana/ui**: New updated time picker. [#20931](https://github.com/grafana/grafana/pull/20931), [@mckn](https://github.com/mckn) + +### Bug Fixes + +- **API**: Optionally list expired API keys. [#20468](https://github.com/grafana/grafana/pull/20468), [@papagian](https://github.com/papagian) +- **grafana/ui**: Do not build grafana/ui in strict mode as it depends on non-strict libs. [#21319](https://github.com/grafana/grafana/pull/21319), [@dprokop](https://github.com/dprokop) + +# 6.0.0-alpha.0 (2019-02-22) + +Version update to 6.0.0 to keep @grafana/ui version in sync with [Grafana](https://github.com/grafana/grafana) + +# 1.0.0-alpha.0 (2019-02-21) + +First public release diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md new file mode 100644 index 0000000..59d7ed7 --- /dev/null +++ b/packages/grafana-ui/README.md @@ -0,0 +1,23 @@ +# Grafana UI components library + +> **@grafana/ui is currently in BETA**. + +@grafana/ui is a collection of components used by [Grafana](https://github.com/grafana/grafana) + +Our goal is to deliver Grafana's common UI elements for plugins developers and contributors. + +See [package source](https://github.com/grafana/grafana/tree/master/packages/grafana-ui) for more details. + +## Installation + +`yarn add @grafana/ui` + +`npm install @grafana/ui` + +## Development + +For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead. + +### Storybook 6.x migration + +We've upgraded Storybook to version 6 and with that we will convert to using [controls](https://storybook.js.org/docs/react/essentials/controls) instead of knobs for manipulating components. Controls will not require as much coding as knobs do. Please refer to the [storybook style-guide](https://github.com/grafana/grafana/blob/master/contribute/style-guides/storybook.md#contrls) for further information. diff --git a/packages/grafana-ui/api-extractor.json b/packages/grafana-ui/api-extractor.json new file mode 100644 index 0000000..5e96b3b --- /dev/null +++ b/packages/grafana-ui/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/grafana-ui/index.js b/packages/grafana-ui/index.js new file mode 100644 index 0000000..5d1c925 --- /dev/null +++ b/packages/grafana-ui/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./index.production.js'); +} else { + module.exports = require('./index.development.js'); +} diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json new file mode 100644 index 0000000..d24160f --- /dev/null +++ b/packages/grafana-ui/package.json @@ -0,0 +1,125 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/ui", + "version": "7.5.5", + "description": "Grafana Components Library", + "keywords": [ + "grafana", + "react", + "react-component", + "typescript" + ], + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-ui" + }, + "main": "src/index.ts", + "scripts": { + "build": "grafana-toolkit package:build --scope=ui", + "bundle": "rollup -c rollup.config.ts", + "clean": "rimraf ./dist ./compiled", + "docsExtract": "mkdir -p ../../reports/docs && api-extractor run 2>&1 | tee ../../reports/docs/$(basename $(pwd)).log", + "storybook": "start-storybook -p 9001 -c .storybook -s .storybook/static", + "storybook:build": "build-storybook -o ./dist/storybook -c .storybook -s .storybook/static", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/core": "10.0.27", + "@grafana/aws-sdk": "0.0.3", + "@grafana/data": "7.5.5", + "@grafana/e2e-selectors": "7.5.5", + "@grafana/slate-react": "0.22.9-grafana", + "@grafana/tsconfig": "^1.0.0-rc1", + "@iconscout/react-unicons": "1.1.4", + "@popperjs/core": "2.5.4", + "@sentry/browser": "5.25.0", + "@testing-library/jest-dom": "5.11.9", + "@torkelo/react-select": "3.0.8", + "@types/hoist-non-react-statics": "3.3.1", + "@types/react-beautiful-dnd": "12.1.2", + "@types/react-color": "3.0.1", + "@types/react-select": "3.0.8", + "@types/react-table": "7.0.12", + "@types/slate": "0.47.1", + "@types/slate-react": "0.22.5", + "@visx/event": "1.3.0", + "@visx/gradient": "1.0.0", + "@visx/scale": "1.4.0", + "@visx/shape": "1.4.0", + "@visx/tooltip": "1.3.0", + "classnames": "2.2.6", + "d3": "5.15.0", + "emotion": "10.0.27", + "hoist-non-react-statics": "3.3.2", + "immutable": "3.8.2", + "jquery": "3.5.1", + "lodash": "4.17.21", + "moment": "2.24.0", + "monaco-editor": "0.20.0", + "papaparse": "5.3.0", + "rc-cascader": "1.0.1", + "rc-drawer": "3.1.3", + "rc-slider": "9.6.4", + "rc-time-picker": "^3.7.3", + "react": "17.0.1", + "react-beautiful-dnd": "13.0.0", + "react-calendar": "2.19.2", + "react-color": "2.18.0", + "react-custom-scrollbars": "4.2.1", + "react-dom": "17.0.1", + "react-highlight-words": "0.16.0", + "react-hook-form": "5.1.3", + "react-monaco-editor": "0.36.0", + "react-popper": "2.2.4", + "react-storybook-addon-props-combinations": "1.1.0", + "react-table": "7.0.0", + "react-transition-group": "4.4.1", + "slate": "0.47.8", + "tinycolor2": "1.4.1", + "uplot": "1.6.8" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "16.0.0", + "@rollup/plugin-image": "2.0.5", + "@rollup/plugin-node-resolve": "10.0.0", + "@storybook/addon-controls": "6.1.15", + "@storybook/addon-essentials": "6.1.15", + "@storybook/addon-knobs": "6.1.15", + "@storybook/addon-storysource": "6.1.15", + "@storybook/react": "6.1.15", + "@storybook/theming": "6.1.15", + "@types/classnames": "2.2.7", + "@types/common-tags": "^1.8.0", + "@types/d3": "5.7.2", + "@types/jest": "26.0.15", + "@types/jquery": "3.3.38", + "@types/lodash": "4.14.123", + "@types/mock-raf": "1.0.2", + "@types/node": "10.14.1", + "@types/papaparse": "5.2.0", + "@types/react": "16.9.9", + "@types/react-custom-scrollbars": "4.0.5", + "@types/react-test-renderer": "16.9.2", + "@types/react-transition-group": "4.4.0", + "@types/rollup-plugin-visualizer": "2.6.0", + "@types/tinycolor2": "1.4.1", + "common-tags": "^1.8.0", + "mock-raf": "1.0.1", + "pretty-format": "25.1.0", + "react-docgen-typescript-loader": "3.7.2", + "react-is": "16.8.0", + "react-test-renderer": "16.13.1", + "rollup": "2.33.3", + "rollup-plugin-sourcemaps": "0.6.3", + "rollup-plugin-terser": "7.0.2", + "rollup-plugin-typescript2": "0.29.0", + "rollup-plugin-visualizer": "4.2.0", + "storybook-dark-mode": "1.0.4", + "ts-loader": "8.0.11", + "typescript": "4.1.2", + "webpack-filter-warnings-plugin": "1.2.1" + }, + "types": "src/index.ts" +} diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts new file mode 100644 index 0000000..354d21e --- /dev/null +++ b/packages/grafana-ui/rollup.config.ts @@ -0,0 +1,50 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import image from '@rollup/plugin-image'; +import { terser } from 'rollup-plugin-terser'; + +const pkg = require('./package.json'); + +const libraryName = pkg.name; + +const buildCjsPackage = ({ env }) => { + return { + input: `compiled/index.js`, + output: [ + { + dir: 'dist', + name: libraryName, + format: 'cjs', + sourcemap: true, + strict: false, + exports: 'named', + chunkFileNames: `[name].${env}.js`, + globals: { + react: 'React', + 'prop-types': 'PropTypes', + }, + }, + ], + external: [ + 'react', + 'react-dom', + '@grafana/aws-sdk', + '@grafana/data', + '@grafana/e2e-selectors', + 'moment', + 'monaco-editor', // Monaco should not be used directly + 'monaco-editor/esm/vs/editor/editor.api', // Monaco should not be used directly + 'react-monaco-editor', + 'jquery', // required to use jquery.plot, which is assigned externally + ], + plugins: [ + commonjs({ + include: /node_modules/, + }), + resolve(), + image(), + env === 'production' && terser(), + ], + }; +}; +export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })]; diff --git a/packages/grafana-ui/src/Intro.story.mdx b/packages/grafana-ui/src/Intro.story.mdx new file mode 100644 index 0000000..6b9be9f --- /dev/null +++ b/packages/grafana-ui/src/Intro.story.mdx @@ -0,0 +1,47 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Grafana design system + +> **@grafana/ui is currently in BETA**. + +With the design system @grafana/ui, we want to democratize development. This library of reusable [Grafana](https://github.com/grafana/grafana) components and guidelines helps you with contribution and plugin development. + +## Our vision +Grafana Labs started @grafana/ui to make contributing to Grafana as easy as possible for Grafanistas and community members of all fields. We want to create a component library that results in: +- Understanding of how each component works and how you can use it to create a great user experience. +- Short development times and consistent code quality. +- A beautiful, visually consistent Grafana experience. +- Transparency about how we work and what we do. + +### Maintained by Grafana Labs and you +Grafana Labs has a task force that helps create and maintain components. We make sure that components are documented and easy to use. The current status of the @grafana/ui development is available on [GitHub](https://github.com/grafana/grafana/projects/26). Feel free to contribute! + +### How to get involved +When we notice that we need to change something, we determine together what the change should be, then we put the change in place and communicate it publicly. Developers and designers create and improve @grafana/ui together. Throughout the process, we strive to involve you and meet your needs. We are looking forward to discussing your design and improvement ideas on [GitHub](https://github.com/grafana/grafana/projects/26). + +## Get started + +- **Explore UI components** + Click on any of the component in the sidebar to see how they look, and how to configure them. Each component pages contains documentation of its properties, as well as code examples for how to use it. +- **Try them out** + - Experiment with different properties, by clicking **Canvas** at the top of each component page. + - Change the properties under **Knobs** in the bottom panel. +- **Use them** + Once you've found the right component for your use case, click the **Story** tab to see the code implementation, or look at examples under **Docs**. + + For more details, refer to the [package source](https://github.com/grafana/grafana/tree/master/packages/grafana-ui). + +## Installation + +`yarn add @grafana/ui` + +`npm install @grafana/ui` + +## Related links + +- [Build a panel plugin tutorial](https://grafana.com/tutorials/build-a-panel-plugin/#0) +- [Plugin Developer guide](https://grafana.com/docs/grafana/latest/plugins/developing/development/) +- [Boilerplate Panel Plugin](https://github.com/grafana/simple-react-panel) +- [Grafana Tutorials](https://grafana.com/tutorials/) diff --git a/packages/grafana-ui/src/components/Alert/Alert.mdx b/packages/grafana-ui/src/components/Alert/Alert.mdx new file mode 100644 index 0000000..9f8ecba --- /dev/null +++ b/packages/grafana-ui/src/components/Alert/Alert.mdx @@ -0,0 +1,17 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { Alert } from './Alert'; + + + +# Alert + +An alert displays an important message in a way that attracts the user's attention without interrupting the user's task. +`onRemove` handler can be used to enable manually dismissing the alert. + +# Usage + +```jsx + +``` + + diff --git a/packages/grafana-ui/src/components/Alert/Alert.story.tsx b/packages/grafana-ui/src/components/Alert/Alert.story.tsx new file mode 100644 index 0000000..4b2c202 --- /dev/null +++ b/packages/grafana-ui/src/components/Alert/Alert.story.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { Alert, AlertVariant, VerticalGroup } from '@grafana/ui'; +import { Props } from './Alert'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from '../Alert/Alert.mdx'; +import { StoryExample } from '../../utils/storybook/StoryExample'; + +const severities: AlertVariant[] = ['error', 'warning', 'info', 'success']; + +export default { + title: 'Overlays/Alert', + component: Alert, + decorators: [withCenteredStory, withHorizontallyCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + argTypes: { + severity: { control: { type: 'select', options: severities } }, + }, +}; + +export const Examples: Story = ({ severity, title, buttonContent }) => { + return ( + + + {buttonContent}} + onRemove={action('Remove button clicked')} + > + Child content that includes some alert details, like maybe what actually happened. + + + + + + + + {severities.map((severity) => ( + + ))} + + + + ); +}; + +Examples.args = { + severity: 'error', + title: 'Some very important message', + buttonContent: 'Close', +}; diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx new file mode 100644 index 0000000..df5566e --- /dev/null +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -0,0 +1,123 @@ +import React, { FC, HTMLAttributes, ReactNode } from 'react'; +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { useTheme } from '../../themes'; +import { Icon } from '../Icon/Icon'; +import { IconName } from '../../types/icon'; +import { getColorsFromSeverity } from '../../utils/colors'; + +export type AlertVariant = 'success' | 'warning' | 'error' | 'info'; + +export interface Props extends HTMLAttributes { + title: string; + /** On click handler for alert button, mostly used for dismissing the alert */ + onRemove?: (event: React.MouseEvent) => void; + severity?: AlertVariant; + children?: ReactNode; + /** Custom component or text for alert button */ + buttonContent?: ReactNode | string; + /** @deprecated */ + /** Deprecated use onRemove instead */ + onButtonClick?: (event: React.MouseEvent) => void; + /** @deprecated */ + /** Deprecated use buttonContent instead */ + buttonText?: string; +} + +function getIconFromSeverity(severity: AlertVariant): string { + switch (severity) { + case 'error': + case 'warning': + return 'exclamation-triangle'; + case 'info': + return 'info-circle'; + case 'success': + return 'check'; + default: + return ''; + } +} + +export const Alert: FC = React.forwardRef( + ({ title, buttonText, onButtonClick, onRemove, children, buttonContent, severity = 'error', ...restProps }, ref) => { + const theme = useTheme(); + const styles = getStyles(theme, severity, !!buttonContent); + + return ( +
+
+ +
+
+
{title}
+ {children &&
{children}
} +
+ {/* If onRemove is specified, giving preference to onRemove */} + {onRemove ? ( + + ) : onButtonClick ? ( + + ) : null} +
+ ); + } +); + +Alert.displayName = 'Alert'; + +const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean) => { + const { white } = theme.palette; + const severityColors = getColorsFromSeverity(severity, theme); + const background = css` + background: linear-gradient(90deg, ${severityColors[0]}, ${severityColors[0]}); + `; + + return { + alert: css` + flex-grow: 1; + padding: 15px 20px; + margin-bottom: ${theme.spacing.xs}; + position: relative; + color: ${white}; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); + border-radius: ${theme.border.radius.md}; + display: flex; + flex-direction: row; + align-items: center; + ${background} + `, + icon: css` + padding: 0 ${theme.spacing.md} 0 0; + display: flex; + align-items: center; + justify-content: center; + width: 35px; + `, + title: css` + font-weight: ${theme.typography.weight.semibold}; + `, + body: css` + flex-grow: 1; + margin: 0 ${theme.spacing.md} 0 0; + overflow-wrap: break-word; + word-break: break-word; + + a { + color: ${white}; + text-decoration: underline; + } + `, + close: css` + background: none; + display: flex; + align-items: center; + border: ${outline ? `1px solid ${white}` : 'none'}; + border-radius: ${theme.border.radius.sm}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.mdx b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.mdx new file mode 100644 index 0000000..e0ac489 --- /dev/null +++ b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.mdx @@ -0,0 +1,10 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { AlphaNotice } from './AlphaNotice'; + + + +# AlphaNotice + +Used to indicate plugin state - Alpha, Beta or Deprecated. + + diff --git a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.story.tsx b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.story.tsx new file mode 100644 index 0000000..67b5236 --- /dev/null +++ b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.story.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { PluginState } from '@grafana/data'; +import { AlphaNotice } from './AlphaNotice'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './AlphaNotice.mdx'; + +export default { + title: 'Overlays/AlphaNotice', + component: AlphaNotice, + decorators: [withCenteredStory, withHorizontallyCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ; +}; diff --git a/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx new file mode 100644 index 0000000..8f6e35f --- /dev/null +++ b/packages/grafana-ui/src/components/AlphaNotice/AlphaNotice.tsx @@ -0,0 +1,37 @@ +import React, { FC, useContext } from 'react'; +import { css, cx } from 'emotion'; +import { ThemeContext } from '../../index'; +import { PluginState } from '@grafana/data'; +import { Icon } from '../Icon/Icon'; + +export interface Props { + state?: PluginState; + text?: string; + className?: string; +} + +export const AlphaNotice: FC = ({ state, text, className }) => { + const tooltipContent = text || 'This feature is a work in progress and updates may include breaking changes'; + const theme = useContext(ThemeContext); + + const styles = cx( + className, + css` + background: linear-gradient(to bottom, ${theme.palette.blue85}, ${theme.palette.blue77}); + color: ${theme.palette.gray7}; + white-space: nowrap; + border-radius: 3px; + text-shadow: none; + font-size: 13px; + padding: 4px 8px; + cursor: help; + display: inline-block; + ` + ); + + return ( +
+ {state} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Badge/Badge.mdx b/packages/grafana-ui/src/components/Badge/Badge.mdx new file mode 100644 index 0000000..8782bce --- /dev/null +++ b/packages/grafana-ui/src/components/Badge/Badge.mdx @@ -0,0 +1,5 @@ + + +# Badge + +The badge component adds meta information to other content, for example about release status or new elements. You can add any `Icon` component or use the badge without an icon. diff --git a/packages/grafana-ui/src/components/Badge/Badge.story.tsx b/packages/grafana-ui/src/components/Badge/Badge.story.tsx new file mode 100644 index 0000000..1035be9 --- /dev/null +++ b/packages/grafana-ui/src/components/Badge/Badge.story.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { Badge, BadgeProps } from '@grafana/ui'; +import { iconOptions } from '../../utils/storybook/knobs'; + +export default { + title: 'Data Display/Badge', + component: Badge, + decorators: [], + parameters: { + docs: {}, + knobs: { + disable: true, + }, + }, + argTypes: { + icon: { control: { type: 'select', options: iconOptions } }, + color: { control: 'select' }, + }, +}; + +const Template: Story = (args) => ; + +export const Basic = Template.bind({}); + +Basic.args = { + text: 'Badge label', + color: 'blue', + icon: 'rocket', +}; diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx new file mode 100644 index 0000000..295232b --- /dev/null +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -0,0 +1,78 @@ +import React, { HTMLAttributes } from 'react'; +import { Icon } from '../Icon/Icon'; +import { useTheme } from '../../themes/ThemeContext'; +import { stylesFactory } from '../../themes/stylesFactory'; +import { IconName } from '../../types'; +import { Tooltip } from '../Tooltip/Tooltip'; +import { getColorForTheme, GrafanaTheme } from '@grafana/data'; +import tinycolor from 'tinycolor2'; +import { css, cx } from 'emotion'; +import { HorizontalGroup } from '../Layout/Layout'; + +export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple'; + +export interface BadgeProps extends HTMLAttributes { + text: string; + color: BadgeColor; + icon?: IconName; + tooltip?: string; +} + +export const Badge = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => { + const theme = useTheme(); + const styles = getStyles(theme, color); + const badge = ( +
+ + {icon && } + {text} + +
+ ); + + return tooltip ? ( + + {badge} + + ) : ( + badge + ); +}); + +Badge.displayName = 'Badge'; + +const getStyles = stylesFactory((theme: GrafanaTheme, color: BadgeColor) => { + let sourceColor = getColorForTheme(color, theme); + let borderColor = ''; + let bgColor = ''; + let textColor = ''; + + if (theme.isDark) { + bgColor = tinycolor(sourceColor).setAlpha(0.15).toString(); + borderColor = tinycolor(sourceColor).darken(30).toString(); + textColor = tinycolor(sourceColor).lighten(15).toString(); + } else { + bgColor = tinycolor(sourceColor).setAlpha(0.15).toString(); + borderColor = tinycolor(sourceColor).lighten(20).toString(); + textColor = tinycolor(sourceColor).darken(15).toString(); + } + + return { + wrapper: css` + font-size: ${theme.typography.size.sm}; + display: inline-flex; + padding: 1px 4px; + border-radius: 3px; + background: ${bgColor}; + border: 1px solid ${borderColor}; + color: ${textColor}; + font-weight: ${theme.typography.weight.regular}; + + > span { + position: relative; + top: 1px; + margin-left: 2px; + } + `, + }; +}); diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx new file mode 100644 index 0000000..63aeaee --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx @@ -0,0 +1,65 @@ +import { toDataFrame, FieldType, VizOrientation } from '@grafana/data'; +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { BarChart } from './BarChart'; +import { LegendDisplayMode } from '../VizLegend/types'; +import { prepDataForStorybook } from '../../utils/storybook/data'; +import { useTheme } from '../../themes'; +import { select } from '@storybook/addon-knobs'; +import { BarChartOptions, BarStackingMode, BarValueVisibility } from './types'; + +export default { + title: 'Visualizations/BarChart', + component: BarChart, + decorators: [withCenteredStory], + parameters: { + docs: {}, + }, +}; + +const getKnobs = () => { + return { + legendPlacement: select( + 'Legend placement', + { + bottom: 'bottom', + right: 'right', + }, + 'bottom' + ), + orientation: select( + 'Bar orientation', + { + vertical: VizOrientation.Vertical, + horizontal: VizOrientation.Horizontal, + }, + VizOrientation.Vertical + ), + }; +}; + +export const Basic: React.FC = () => { + const { legendPlacement, orientation } = getKnobs(); + + const theme = useTheme(); + const frame = toDataFrame({ + fields: [ + { name: 'x', type: FieldType.string, values: ['group 1', 'group 2'] }, + { name: 'a', type: FieldType.number, values: [10, 20] }, + { name: 'b', type: FieldType.number, values: [30, 10] }, + ], + }); + + const data = prepDataForStorybook([frame], theme); + + const options: BarChartOptions = { + orientation: orientation, + legend: { displayMode: LegendDisplayMode.List, placement: legendPlacement, calcs: [] }, + stacking: BarStackingMode.None, + showValue: BarValueVisibility.Always, + barWidth: 0.97, + groupWidth: 0.7, + }; + + return ; +}; diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000..15e820b --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/BarChart.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { AlignedData } from 'uplot'; +import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data'; +import { VizLayout } from '../VizLayout/VizLayout'; +import { Themeable } from '../../types'; +import { UPlotChart } from '../uPlot/Plot'; +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { GraphNGLegendEvent } from '../GraphNG/types'; +import { BarChartOptions } from './types'; +import { withTheme } from '../../themes'; +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { preparePlotData } from '../uPlot/utils'; +import { LegendDisplayMode } from '../VizLegend/types'; +import { PlotLegend } from '../uPlot/PlotLegend'; + +/** + * @alpha + */ +export interface BarChartProps extends Themeable, BarChartOptions { + height: number; + width: number; + data: DataFrame[]; + onLegendClick?: (event: GraphNGLegendEvent) => void; + onSeriesColorChange?: (label: string, color: string) => void; +} + +interface BarChartState { + data: AlignedData; + alignedDataFrame: DataFrame; + config?: UPlotConfigBuilder; +} + +class UnthemedBarChart extends React.Component { + constructor(props: BarChartProps) { + super(props); + this.state = {} as BarChartState; + } + + static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) { + const frame = preparePlotFrame(props.data); + + if (!frame) { + return { ...state }; + } + + return { + ...state, + data: preparePlotData(frame), + alignedDataFrame: frame, + }; + } + + componentDidMount() { + const { alignedDataFrame } = this.state; + + if (!alignedDataFrame) { + return; + } + + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), + }); + } + + componentDidUpdate(prevProps: BarChartProps) { + const { data, orientation, groupWidth, barWidth, showValue } = this.props; + const { alignedDataFrame } = this.state; + let shouldConfigUpdate = false; + let hasStructureChanged = false; + + if ( + this.state.config === undefined || + orientation !== prevProps.orientation || + groupWidth !== prevProps.groupWidth || + barWidth !== prevProps.barWidth || + showValue !== prevProps.showValue + ) { + shouldConfigUpdate = true; + } + + if (data !== prevProps.data) { + if (!alignedDataFrame) { + return; + } + hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); + } + + if (shouldConfigUpdate || hasStructureChanged) { + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), + }); + } + } + + renderLegend() { + const { legend, onSeriesColorChange, onLegendClick, data } = this.props; + const { config } = this.state; + + if (!config || legend.displayMode === LegendDisplayMode.Hidden) { + return; + } + return ( + + ); + } + + render() { + const { width, height } = this.props; + const { config, data } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + + )} + + ); + } +} + +export const BarChart = withTheme(UnthemedBarChart); +BarChart.displayName = 'GraphNG'; diff --git a/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000..c2cd121 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap @@ -0,0 +1,961 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder orientation 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "left", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "bottom", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "distribution": "ordinal", + "isTime": false, + "orientation": 0, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 1, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/BarChart/bars.ts b/packages/grafana-ui/src/components/BarChart/bars.ts new file mode 100644 index 0000000..0012a03 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/bars.ts @@ -0,0 +1,294 @@ +import uPlot, { Axis, Series, Cursor, BBox } from 'uplot'; +import { Quadtree, Rect, pointWithin } from './quadtree'; +import { distribute, SPACE_BETWEEN } from './distribute'; + +const pxRatio = devicePixelRatio; + +const groupDistr = SPACE_BETWEEN; +const barDistr = SPACE_BETWEEN; + +const font = Math.round(10 * pxRatio) + 'px Arial'; + +type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void); + +function walkTwo( + groupWidth: number, + barWidth: number, + yIdx: number, + xCount: number, + yCount: number, + xDim: number, + xDraw?: WalkTwoCb, + yDraw?: WalkTwoCb +) { + distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => { + let groupOffPx = xDim * offPct; + let groupWidPx = xDim * dimPct; + + xDraw && xDraw(ix, groupOffPx, groupWidPx); + + yDraw && + distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => { + let barOffPx = groupWidPx * offPct; + let barWidPx = groupWidPx * dimPct; + + yDraw(ix, groupOffPx + barOffPx, barWidPx); + }); + }); +} + +/** + * @internal + */ +export interface BarsOptions { + xOri: 1 | 0; + xDir: 1 | -1; + groupWidth: number; + barWidth: number; + formatValue?: (seriesIdx: number, value: any) => string; + onHover?: (seriesIdx: number, valueIdx: any) => void; + onLeave?: (seriesIdx: number, valueIdx: any) => void; +} + +/** + * @internal + */ +export function getConfig(opts: BarsOptions) { + const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, onHover, onLeave } = opts; + + let qt: Quadtree; + + const drawBars: Series.PathBuilder = (u, sidx, i0, i1) => { + return uPlot.orient( + u, + sidx, + (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => { + const fill = new Path2D(); + const stroke = new Path2D(); + + let numGroups = dataX.length; + let barsPerGroup = u.series.length - 1; + + let y0Pos = valToPosY(0, scaleY, yDim, yOff); + + const _dir = dir * (ori === 0 ? 1 : -1); + + walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { + let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); + let barWid = Math.round(wid); + + if (dataY[ix] != null) { + let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); + + let btm = Math.round(Math.max(yPos, y0Pos)); + let top = Math.round(Math.min(yPos, y0Pos)); + let barHgt = btm - top; + + let strokeWidth = series.width || 0; + + if (strokeWidth) { + rect(stroke, lft + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth); + } + + rect(fill, lft, top, barWid, barHgt); + + let x = ori === 0 ? Math.round(lft - xOff) : Math.round(top - yOff); + let y = ori === 0 ? Math.round(top - yOff) : Math.round(lft - xOff); + let w = ori === 0 ? barWid : barHgt; + let h = ori === 0 ? barHgt : barWid; + + qt.add({ x, y, w, h, sidx: sidx, didx: ix }); + } + }); + + return { + stroke, + fill, + }; + } + ); + }; + + const drawPoints: Series.Points.Show = + formatValue == null + ? false + : (u, sidx, i0, i1) => { + u.ctx.font = font; + u.ctx.fillStyle = 'white'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect + ) => { + let numGroups = dataX.length; + let barsPerGroup = u.series.length - 1; + + const _dir = dir * (ori === 0 ? 1 : -1); + + walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { + let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); + let barWid = Math.round(wid); + + // prettier-ignore + if (dataY[ix] != null) { + let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); + + /* eslint-disable no-multi-spaces */ + let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos); + let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2); + + u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right'; + u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top'; + /* eslint-enable */ + + u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y); + } + }); + } + ); + + return false; + }; + + /* + const yRange: Scale.Range = (u, dataMin, dataMax) => { + // @ts-ignore + let [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true); + return [0, max]; + }; + */ + + const xSplits: Axis.Splits = (u: uPlot, axisIdx: number) => { + const dim = ori === 0 ? u.bbox.width : u.bbox.height; + const _dir = dir * (ori === 0 ? 1 : -1); + + let splits: number[] = []; + + distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => { + let groupLftPx = (dim * lftPct) / pxRatio; + let groupWidPx = (dim * widPct) / pxRatio; + + let groupCenterPx = groupLftPx + groupWidPx / 2; + + splits.push(u.posToVal(groupCenterPx, 'x')); + }); + + return _dir === 1 ? splits : splits.reverse(); + }; + + // @ts-ignore + const xValues: Axis.Values = (u) => u.data[0]; + + let hovered: Rect | null = null; + + let barMark = document.createElement('div'); + barMark.classList.add('bar-mark'); + barMark.style.position = 'absolute'; + barMark.style.background = 'rgba(255,255,255,0.4)'; + + // hide crosshair cursor & hover points + const cursor: Cursor = { + x: false, + y: false, + points: { + show: false, + }, + }; + + // disable selection + // uPlot types do not export the Select interface prior to 1.6.4 + const select: Partial = { + show: false, + }; + + const init = (u: uPlot) => { + let over = u.root.querySelector('.u-over')! as HTMLElement; + over.style.overflow = 'hidden'; + over.appendChild(barMark); + }; + + const drawClear = (u: uPlot) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // clear the path cache to force drawBars() to rebuild new quadtree + u.series.forEach((s) => { + // @ts-ignore + s._paths = null; + }); + }; + + // handle hover interaction with quadtree probing + const setCursor = (u: uPlot) => { + let found: Rect | null = null; + let cx = u.cursor.left! * pxRatio; + let cy = u.cursor.top! * pxRatio; + + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + found = o; + } + }); + + if (found) { + // prettier-ignore + if (found !== hovered) { + /* eslint-disable no-multi-spaces */ + barMark.style.display = ''; + barMark.style.left = found!.x / pxRatio + 'px'; + barMark.style.top = found!.y / pxRatio + 'px'; + barMark.style.width = found!.w / pxRatio + 'px'; + barMark.style.height = found!.h / pxRatio + 'px'; + hovered = found; + /* eslint-enable */ + + if (onHover != null) { + onHover(hovered!.sidx, hovered!.didx); + } + } + } else if (hovered != null) { + if (onLeave != null) { + onLeave(hovered!.sidx, hovered!.didx); + } + + hovered = null; + barMark.style.display = 'none'; + } + }; + + return { + // cursor & select opts + cursor, + select, + + // scale & axis opts + // yRange, + xValues, + xSplits, + + // pathbuilders + drawBars, + drawPoints, + + // hooks + init, + drawClear, + setCursor, + }; +} diff --git a/packages/grafana-ui/src/components/BarChart/distribute.ts b/packages/grafana-ui/src/components/BarChart/distribute.ts new file mode 100644 index 0000000..6a4b419 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/distribute.ts @@ -0,0 +1,49 @@ +function roundDec(val: number, dec: number) { + return Math.round(val * (dec = 10 ** dec)) / dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; + +const coord = (i: number, offs: number, iwid: number, gap: number) => roundDec(offs + i * (iwid + gap), 6); + +export type Each = (idx: number, offPct: number, dimPct: number) => void; + +/** + * @internal + */ +export function distribute(numItems: number, sizeFactor: number, justify: number, onlyIdx: number | null, each: Each) { + let space = 1 - sizeFactor; + + /* eslint-disable no-multi-spaces */ + // prettier-ignore + let gap = ( + justify === SPACE_BETWEEN ? space / (numItems - 1) : + justify === SPACE_AROUND ? space / (numItems ) : + justify === SPACE_EVENLY ? space / (numItems + 1) : 0 + ); + + if (isNaN(gap) || gap === Infinity) { + gap = 0; + } + + // prettier-ignore + let offs = ( + justify === SPACE_BETWEEN ? 0 : + justify === SPACE_AROUND ? gap / 2 : + justify === SPACE_EVENLY ? gap : 0 + ); + /* eslint-enable */ + + let iwid = sizeFactor / numItems; + let _iwid = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) { + each(i, coord(i, offs, iwid, gap), _iwid); + } + } else { + each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid); + } +} diff --git a/packages/grafana-ui/src/components/BarChart/quadtree.ts b/packages/grafana-ui/src/components/BarChart/quadtree.ts new file mode 100644 index 0000000..dba3018 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/quadtree.ts @@ -0,0 +1,113 @@ +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export type Quads = [Quadtree, Quadtree, Quadtree, Quadtree]; +export type Rect = { x: number; y: number; w: number; h: number; [_: string]: any }; + +/** + * @internal + */ +export function pointWithin(px: number, py: number, rlft: number, rtop: number, rrgt: number, rbtm: number) { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} + +/** + * @internal + */ +export class Quadtree { + o: Rect[]; + q: Quads | null; + constructor(public x: number, public y: number, public w: number, public h: number, public l: number = 0) { + this.o = []; + this.q = null; + } + + split() { + let t = this, + x = t.x, + y = t.y, + w = t.w / 2, + h = t.h / 2, + l = t.l + 1; + + t.q = [ + // top right + new Quadtree(x + w, y, w, h, l), + // top left + new Quadtree(x, y, w, h, l), + // bottom left + new Quadtree(x, y + h, w, h, l), + // bottom right + new Quadtree(x + w, y + h, w, h, l), + ]; + } + + // invokes callback with index of each overlapping quad + quads(x: number, y: number, w: number, h: number, cb: (q: Quadtree) => void) { + let t = this, + q = t.q!, + hzMid = t.x + t.w / 2, + vtMid = t.y + t.h / 2, + startIsNorth = y < vtMid, + startIsWest = x < hzMid, + endIsEast = x + w > hzMid, + endIsSouth = y + h > vtMid; + + // top-right quad + startIsNorth && endIsEast && cb(q[0]); + // top-left quad + startIsWest && startIsNorth && cb(q[1]); + // bottom-left quad + startIsWest && endIsSouth && cb(q[2]); + // bottom-right quad + endIsEast && endIsSouth && cb(q[3]); + } + + add(o: Rect) { + let t = this; + + if (t.q != null) { + t.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + let os = t.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) { + t.split(); + + for (let i = 0; i < os.length; i++) { + let oi = os[i]; + + t.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + t.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: Rect) => void) { + let t = this; + let os = t.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (t.q != null) { + t.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear() { + this.o.length = 0; + this.q = null; + } +} diff --git a/packages/grafana-ui/src/components/BarChart/types.ts b/packages/grafana-ui/src/components/BarChart/types.ts new file mode 100644 index 0000000..208a9d2 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/types.ts @@ -0,0 +1,52 @@ +import { VizOrientation } from '@grafana/data'; +import { AxisConfig, GraphGradientMode, HideableFieldConfig } from '../uPlot/config'; +import { VizLegendOptions } from '../VizLegend/types'; + +/** + * @alpha + */ +export enum BarStackingMode { + None = 'none', + Standard = 'standard', + Percent = 'percent', +} + +/** + * @alpha + */ +export enum BarValueVisibility { + Auto = 'auto', + Never = 'never', + Always = 'always', +} + +/** + * @alpha + */ +export interface BarChartOptions { + orientation: VizOrientation; + legend: VizLegendOptions; + stacking: BarStackingMode; + showValue: BarValueVisibility; + barWidth: number; + groupWidth: number; +} + +/** + * @alpha + */ +export interface BarChartFieldConfig extends AxisConfig, HideableFieldConfig { + lineWidth?: number; // 0 + fillOpacity?: number; // 100 + gradientMode?: GraphGradientMode; +} + +/** + * @alpha + */ +export const defaultBarChartFieldConfig: BarChartFieldConfig = { + lineWidth: 1, + fillOpacity: 80, + gradientMode: GraphGradientMode.None, + axisSoftMin: 0, +}; diff --git a/packages/grafana-ui/src/components/BarChart/utils.test.ts b/packages/grafana-ui/src/components/BarChart/utils.test.ts new file mode 100644 index 0000000..631d3b8 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.test.ts @@ -0,0 +1,101 @@ +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { FieldConfig, FieldType, GrafanaTheme, MutableDataFrame, VizOrientation } from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarStackingMode, BarValueVisibility } from './types'; +import { GraphGradientMode } from '../uPlot/config'; +import { LegendDisplayMode } from '../VizLegend/types'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.string, values: ['a', 'b', 'c'] }], + }); + + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + decimals: 2, + unit: 'm/s', + custom: { + gradientMode: GraphGradientMode.Opacity, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + decimals: 2, + unit: 'kWh', + custom: { + gradientMode: GraphGradientMode.Hue, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + state: {}, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + state: {}, + }); + + return preparePlotFrame([df1, df2]); +} + +describe('GraphNG utils', () => { + describe('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + + const config: BarChartOptions = { + orientation: VizOrientation.Auto, + groupWidth: 20, + barWidth: 2, + showValue: BarValueVisibility.Always, + legend: { + displayMode: LegendDisplayMode.List, + placement: 'bottom', + calcs: [], + }, + stacking: BarStackingMode.None, + }; + + it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + orientation: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + showValue: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarStackingMode.None, BarStackingMode.Percent, BarStackingMode.Standard])('stacking', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + stacking: v, + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BarChart/utils.ts b/packages/grafana-ui/src/components/BarChart/utils.ts new file mode 100644 index 0000000..f5ae05d --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.ts @@ -0,0 +1,190 @@ +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { + DataFrame, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme, + MutableDataFrame, + VizOrientation, +} from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; +import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; +import { BarsOptions, getConfig } from './bars'; +import { FIXED_UNIT } from '../GraphNG/GraphNG'; + +/** @alpha */ +export function preparePlotConfigBuilder( + data: DataFrame, + theme: GrafanaTheme, + { orientation, showValue, groupWidth, barWidth }: BarChartOptions +) { + const builder = new UPlotConfigBuilder(); + + // bar orientation -> x scale orientation & direction + let xOri = ScaleOrientation.Vertical; + let xDir = ScaleDirection.Down; + let yOri = ScaleOrientation.Horizontal; + let yDir = ScaleDirection.Right; + + if (orientation === VizOrientation.Vertical) { + xOri = ScaleOrientation.Horizontal; + xDir = ScaleDirection.Right; + yOri = ScaleOrientation.Vertical; + yDir = ScaleDirection.Up; + } + + const formatValue = + showValue !== BarValueVisibility.Never + ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) + : undefined; + + // Use bar width when only one field + if (data.fields.length === 2) { + groupWidth = barWidth; + barWidth = 1; + } + + const opts: BarsOptions = { + xOri, + xDir, + groupWidth, + barWidth, + formatValue, + onHover: (seriesIdx: number, valueIdx: number) => { + console.log('hover', { seriesIdx, valueIdx }); + }, + onLeave: (seriesIdx: number, valueIdx: number) => { + console.log('leave', { seriesIdx, valueIdx }); + }, + }; + + const config = getConfig(opts); + + builder.addHook('init', config.init); + builder.addHook('drawClear', config.drawClear); + builder.addHook('setCursor', config.setCursor); + + builder.setCursor(config.cursor); + builder.setSelect(config.select); + + builder.addScale({ + scaleKey: 'x', + isTime: false, + distribution: ScaleDistribution.Ordinal, + orientation: xOri, + direction: xDir, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: false, + placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, + splits: config.xSplits, + values: config.xValues, + grid: false, + ticks: false, + gap: 15, + theme, + }); + + let seriesIndex = 0; + + // iterate the y values + for (let i = 1; i < data.fields.length; i++) { + const field = data.fields[i]; + + field.state!.seriesIndex = seriesIndex++; + + const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; + + const scaleKey = field.config.unit || FIXED_UNIT; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + builder.addSeries({ + scaleKey, + pxAlign: false, + lineWidth: customConfig.lineWidth, + lineColor: seriesColor, + //lineStyle: customConfig.lineStyle, + fillOpacity: customConfig.fillOpacity, + theme, + colorMode, + pathBuilder: config.drawBars, + pointsBuilder: config.drawPoints, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: field.config.thresholds, + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: { + fieldIndex: i, + frameIndex: 0, + }, + fieldName: getFieldDisplayName(field, data), + hideInLegend: customConfig.hideFrom?.legend, + }); + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + orientation: yOri, + direction: yDir, + }); + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let placement = customConfig.axisPlacement; + if (!placement || placement === AxisPlacement.Auto) { + placement = AxisPlacement.Left; + } + if (xOri === 1) { + if (placement === AxisPlacement.Left) { + placement = AxisPlacement.Bottom; + } + if (placement === AxisPlacement.Right) { + placement = AxisPlacement.Top; + } + } + + builder.addAxis({ + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement, + formatValue: (v) => formattedValueToString(field.display!(v)), + theme, + }); + } + } + + return builder; +} + +/** @internal */ +export function preparePlotFrame(data: DataFrame[]) { + const firstFrame = data[0]; + const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); + + if (!firstString) { + throw new Error('No string field in DF'); + } + + const resultFrame = new MutableDataFrame(); + resultFrame.addField(firstString); + + for (const f of firstFrame.fields) { + if (f.type === FieldType.number) { + resultFrame.addField(f); + } + } + + return resultFrame; +} diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.mdx b/packages/grafana-ui/src/components/BarGauge/BarGauge.mdx new file mode 100644 index 0000000..cefcce5 --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.mdx @@ -0,0 +1,40 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { BarGauge } from './BarGauge'; + + + +# BarGauge + +## Usage +```tsx +import { BarGauge, BarGaugeDisplayMode } from '@grafana/ui'; +import { VizOrientation, ThresholdsMode, Field, FieldType, getDisplayProcessor } from '@grafana/data'; + +const field: Partial = { + type: FieldType.number, + config: { + min: minValue, + max: maxValue, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: threshold1Value, color: threshold1Color }, + { value: threshold2Value, color: threshold2Color }, + ], + }, + }, + }; + field.display = getDisplayProcessor({ field }); + +const value = { + text: value.toString(), + title: title, + numeric: value, +}; + +//... + +``` + + diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx new file mode 100644 index 0000000..c69af2c --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { BarGauge, BarGaugeDisplayMode } from '@grafana/ui'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { VizOrientation, ThresholdsMode, Field, FieldType, getDisplayProcessor } from '@grafana/data'; +import { Props } from './BarGauge'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './BarGauge.mdx'; +import { useTheme } from '../../themes'; + +export default { + title: 'Visualizations/BarGauge', + component: BarGauge, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + args: { + numeric: 70, + title: 'Title', + minValue: 0, + maxValue: 100, + threshold1Value: 40, + threshold1Color: 'orange', + threshold2Value: 60, + threshold2Color: 'red', + displayMode: BarGaugeDisplayMode.Gradient, + lcdCellWidth: 12, + itemSpacing: 8, + showUnfilled: true, + }, + argTypes: { + displayMode: { + control: { + type: 'select', + options: [BarGaugeDisplayMode.Lcd, BarGaugeDisplayMode.Gradient, BarGaugeDisplayMode.Basic], + }, + }, + width: { control: { type: 'range', min: 200, max: 800 } }, + height: { control: { type: 'range', min: 200, max: 800 } }, + threshold1Color: { control: 'color' }, + threshold2Color: { control: 'color' }, + theme: NOOP_CONTROL, + field: NOOP_CONTROL, + value: NOOP_CONTROL, + display: NOOP_CONTROL, + orientation: NOOP_CONTROL, + text: NOOP_CONTROL, + onClick: NOOP_CONTROL, + className: NOOP_CONTROL, + alignmentFactors: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + numeric: number; + title: string; + minValue: number; + maxValue: number; + threshold1Color: string; + threshold2Color: string; + threshold1Value: number; + threshold2Value: number; +} + +const AddBarGaugeStory = (storyProps: StoryProps) => { + const theme = useTheme(); + + const field: Partial = { + type: FieldType.number, + config: { + min: storyProps.minValue, + max: storyProps.maxValue, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: storyProps.threshold1Value, color: storyProps.threshold1Color }, + { value: storyProps.threshold2Value, color: storyProps.threshold2Color }, + ], + }, + }, + }; + field.display = getDisplayProcessor({ field }); + + const props: Partial = { + theme, + lcdCellWidth: storyProps.lcdCellWidth, + itemSpacing: storyProps.itemSpacing, + showUnfilled: storyProps.showUnfilled, + width: storyProps.width, + height: storyProps.height, + value: { + text: storyProps.numeric.toString(), + title: storyProps.title, + numeric: storyProps.numeric, + }, + displayMode: storyProps.displayMode, + orientation: storyProps.orientation, + field: field.config!, + display: field.display!, + }; + + return ; +}; + +export const barGaugeVertical: Story = AddBarGaugeStory.bind({}); +barGaugeVertical.args = { + height: 500, + width: 100, + orientation: VizOrientation.Vertical, +}; + +export const barGaugeHorizontal: Story = AddBarGaugeStory.bind({}); +barGaugeHorizontal.args = { + height: 100, + width: 500, + orientation: VizOrientation.Horizontal, +}; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx new file mode 100644 index 0000000..7eaa5db --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DisplayValue, VizOrientation, ThresholdsMode, Field, FieldType, getDisplayProcessor } from '@grafana/data'; +import { + BarGauge, + Props, + getValueColor, + getBasicAndGradientStyles, + getBarGradient, + getTitleStyles, + getValuePercent, + BarGaugeDisplayMode, + calculateBarAndValueDimensions, +} from './BarGauge'; +import { getTheme } from '../../themes'; + +const green = '#73BF69'; +const orange = '#FF9830'; + +function getProps(propOverrides?: Partial): Props { + const field: Partial = { + type: FieldType.number, + config: { + min: 0, + max: 100, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 70, color: 'orange' }, + { value: 90, color: 'red' }, + ], + }, + }, + }; + const theme = getTheme(); + field.display = getDisplayProcessor({ field, theme }); + + const props: Props = { + displayMode: BarGaugeDisplayMode.Basic, + field: field.config!, + display: field.display!, + height: 300, + width: 300, + value: field.display(25), + theme, + orientation: VizOrientation.Horizontal, + }; + + Object.assign(props, propOverrides); + return props; +} + +const setup = (propOverrides?: object) => { + const props = getProps(propOverrides); + const wrapper = shallow(); + const instance = wrapper.instance() as BarGauge; + + return { + instance, + wrapper, + }; +}; + +function getValue(value: number, title?: string): DisplayValue { + return { numeric: value, text: value.toString(), title: title }; +} + +describe('BarGauge', () => { + describe('Get value color', () => { + it('should get the threshold color if value is same as a threshold', () => { + const props = getProps(); + props.value = props.display!(70); + expect(getValueColor(props)).toEqual(orange); + }); + it('should get the base threshold', () => { + const props = getProps(); + props.value = props.display!(-10); + expect(getValueColor(props)).toEqual(green); + }); + }); + + describe('Get value percent', () => { + it('0 to 100 and value 40', () => { + expect(getValuePercent(40, 0, 100)).toEqual(0.4); + }); + + it('50 to 100 and value 75', () => { + expect(getValuePercent(75, 50, 100)).toEqual(0.5); + }); + + it('-30 to 30 and value 0', () => { + expect(getValuePercent(0, -30, 30)).toEqual(0.5); + }); + + it('-30 to 30 and value 30', () => { + expect(getValuePercent(30, -30, 30)).toEqual(1); + }); + }); + + describe('Vertical bar without title', () => { + it('should not include title height in height', () => { + const props = getProps({ + height: 300, + value: getValue(100), + orientation: VizOrientation.Vertical, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.bar.height).toBe('270px'); + }); + }); + + describe('Vertical bar with title', () => { + it('should include title height in height', () => { + const props = getProps({ + height: 300, + value: getValue(100, 'ServerA'), + orientation: VizOrientation.Vertical, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.bar.height).toBe('249px'); + expect(styles.emptyBar.bottom).toBe('-3px'); + }); + }); + + describe('Horizontal bar', () => { + it('should stretch items', () => { + const props = getProps({ + height: 300, + value: getValue(100, 'ServerA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.wrapper.alignItems).toBe('stretch'); + expect(styles.emptyBar.left).toBe('-3px'); + }); + }); + + describe('Horizontal bar with title', () => { + it('should place above if height > 40', () => { + const props = getProps({ + height: 41, + value: getValue(100, 'AA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.wrapper.flexDirection).toBe('column'); + }); + }); + + describe('Horizontal bar with title', () => { + it('should place below if height < 40', () => { + const props = getProps({ + height: 30, + value: getValue(100, 'AA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.wrapper.flexDirection).toBe('row'); + }); + + it('should calculate title width based on title', () => { + const props = getProps({ + height: 30, + value: getValue(100, 'AA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.title.width).toBe('17px'); + + const props2 = getProps({ + height: 30, + value: getValue(120, 'Longer title with many words'), + orientation: VizOrientation.Horizontal, + }); + const styles2 = getTitleStyles(props2); + expect(styles2.title.width).toBe('43px'); + }); + + it('should use alignmentFactors if provided', () => { + const props = getProps({ + height: 30, + value: getValue(100, 'AA'), + alignmentFactors: { + title: 'Super duper long title', + text: '1000', + }, + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.title.width).toBe('37px'); + }); + }); + + describe('Gradient', () => { + it('should build gradient based on thresholds', () => { + const props = getProps({ orientation: VizOrientation.Vertical, value: getValue(100) }); + const gradient = getBarGradient(props, 300); + expect(gradient).toBe('linear-gradient(0deg, #73BF69, #73BF69 105px, #FF9830 240px, #F2495C)'); + }); + + it('should stop gradient if value < threshold', () => { + const props = getProps({ orientation: VizOrientation.Vertical, value: getValue(70) }); + const gradient = getBarGradient(props, 300); + expect(gradient).toBe('linear-gradient(0deg, #73BF69, #73BF69 105px, #FF9830)'); + }); + }); + + describe('Render with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('calculateBarAndValueDimensions', () => { + it('valueWidth should including paddings in valueWidth', () => { + const result = calculateBarAndValueDimensions( + getProps({ + height: 30, + width: 100, + value: getValue(1, 'AA'), + orientation: VizOrientation.Horizontal, + }) + ); + expect(result.valueWidth).toBe(21); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx new file mode 100644 index 0000000..66d1c73 --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -0,0 +1,625 @@ +// Library +import React, { PureComponent, CSSProperties, ReactNode } from 'react'; +import tinycolor from 'tinycolor2'; +import { + TimeSeriesValue, + DisplayValue, + formattedValueToString, + FormattedValue, + DisplayValueAlignmentFactors, + ThresholdsMode, + DisplayProcessor, + FieldConfig, + FieldColorModeId, + getFieldColorMode, + getColorForTheme, + FALLBACK_COLOR, + TextDisplayOptions, + VizOrientation, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; +import { measureText, calculateFontSize } from '../../utils/measureText'; +import { Themeable } from '../../types'; + +const MIN_VALUE_HEIGHT = 18; +const MAX_VALUE_HEIGHT = 50; +const MAX_VALUE_WIDTH = 150; +const TITLE_LINE_HEIGHT = 1.5; +const VALUE_LINE_HEIGHT = 1; +const VALUE_LEFT_PADDING = 10; + +export interface Props extends Themeable { + height: number; + width: number; + field: FieldConfig; + display?: DisplayProcessor; + value: DisplayValue; + orientation: VizOrientation; + text?: TextDisplayOptions; + itemSpacing?: number; + lcdCellWidth?: number; + displayMode: BarGaugeDisplayMode; + onClick?: React.MouseEventHandler; + className?: string; + showUnfilled?: boolean; + alignmentFactors?: DisplayValueAlignmentFactors; +} + +export enum BarGaugeDisplayMode { + Basic = 'basic', + Lcd = 'lcd', + Gradient = 'gradient', +} + +export class BarGauge extends PureComponent { + static defaultProps: Partial = { + lcdCellWidth: 12, + value: { + text: '100', + numeric: 100, + }, + displayMode: BarGaugeDisplayMode.Gradient, + orientation: VizOrientation.Horizontal, + field: { + min: 0, + max: 100, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [], + }, + }, + itemSpacing: 8, + showUnfilled: true, + }; + + render() { + const { onClick, className } = this.props; + const { title } = this.props.value; + const styles = getTitleStyles(this.props); + + if (!title) { + return ( +
+ {this.renderBarAndValue()} +
+ ); + } + + return ( +
+
{title}
+ {this.renderBarAndValue()} +
+ ); + } + + renderBarAndValue() { + switch (this.props.displayMode) { + case 'lcd': + return this.renderRetroBars(); + case 'basic': + case 'gradient': + default: + return this.renderBasicAndGradientBars(); + } + } + + renderBasicAndGradientBars(): ReactNode { + const { value, showUnfilled } = this.props; + + const styles = getBasicAndGradientStyles(this.props); + + return ( +
+ + {showUnfilled &&
} +
+
+ ); + } + + getCellColor(positionValue: TimeSeriesValue): CellColors { + const { value, display } = this.props; + if (positionValue === null) { + return { + background: FALLBACK_COLOR, + border: FALLBACK_COLOR, + }; + } + + const color = display ? display(positionValue).color : null; + + if (color) { + // if we are past real value the cell is not "on" + if (value === null || (positionValue !== null && positionValue > value.numeric)) { + return { + background: tinycolor(color).setAlpha(0.18).toRgbString(), + border: 'transparent', + isLit: false, + }; + } else { + return { + background: tinycolor(color).setAlpha(0.95).toRgbString(), + backgroundShade: tinycolor(color).setAlpha(0.55).toRgbString(), + border: tinycolor(color).setAlpha(0.9).toRgbString(), + isLit: true, + }; + } + } + + return { + background: FALLBACK_COLOR, + border: FALLBACK_COLOR, + }; + } + + renderRetroBars(): ReactNode { + const { field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth, text } = this.props; + const { + valueHeight, + valueWidth, + maxBarHeight, + maxBarWidth, + wrapperWidth, + wrapperHeight, + } = calculateBarAndValueDimensions(this.props); + const minValue = field.min!; + const maxValue = field.max!; + + const isVert = isVertical(orientation); + const valueRange = maxValue - minValue; + const maxSize = isVert ? maxBarHeight : maxBarWidth; + const cellSpacing = itemSpacing!; + const cellCount = Math.floor(maxSize / lcdCellWidth!); + const cellSize = Math.floor((maxSize - cellSpacing * cellCount) / cellCount); + const valueColor = getValueColor(this.props); + + const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value; + const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text); + + const containerStyles: CSSProperties = { + width: `${wrapperWidth}px`, + height: `${wrapperHeight}px`, + display: 'flex', + }; + + if (isVert) { + containerStyles.flexDirection = 'column-reverse'; + containerStyles.alignItems = 'center'; + } else { + containerStyles.flexDirection = 'row'; + containerStyles.alignItems = 'center'; + valueStyles.justifyContent = 'flex-end'; + } + + const cells: JSX.Element[] = []; + + for (let i = 0; i < cellCount; i++) { + const currentValue = minValue + (valueRange / cellCount) * i; + const cellColor = this.getCellColor(currentValue); + const cellStyles: CSSProperties = { + borderRadius: '2px', + }; + + if (cellColor.isLit) { + cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`; + } else { + cellStyles.backgroundColor = cellColor.background; + } + + if (isVert) { + cellStyles.height = `${cellSize}px`; + cellStyles.width = `${maxBarWidth}px`; + cellStyles.marginTop = `${cellSpacing}px`; + } else { + cellStyles.width = `${cellSize}px`; + cellStyles.height = `${maxBarHeight}px`; + cellStyles.marginRight = `${cellSpacing}px`; + } + + cells.push(
); + } + + return ( +
+ {cells} + +
+ ); + } +} + +interface CellColors { + background: string; + backgroundShade?: string; + border: string; + isLit?: boolean; +} + +interface TitleDimensions { + fontSize: number; + placement: 'above' | 'left' | 'below'; + width: number; + height: number; +} + +function isVertical(orientation: VizOrientation) { + return orientation === VizOrientation.Vertical; +} + +function calculateTitleDimensions(props: Props): TitleDimensions { + const { height, width, alignmentFactors, orientation, text } = props; + const title = alignmentFactors ? alignmentFactors.title : props.value.title; + + if (!title) { + return { fontSize: 0, width: 0, height: 0, placement: 'above' }; + } + + if (isVertical(orientation)) { + const fontSize = text?.titleSize ?? 14; + return { + fontSize: fontSize, + width: width, + height: fontSize * TITLE_LINE_HEIGHT, + placement: 'below', + }; + } + + // if height above 40 put text to above bar + if (height > 40) { + if (text?.titleSize) { + return { + fontSize: text?.titleSize, + width: 0, + height: text.titleSize * TITLE_LINE_HEIGHT, + placement: 'above', + }; + } + + const maxTitleHeightRatio = 0.45; + const titleHeight = Math.max(Math.min(height * maxTitleHeightRatio, MAX_VALUE_HEIGHT), 17); + + return { + fontSize: titleHeight / TITLE_LINE_HEIGHT, + width: 0, + height: titleHeight, + placement: 'above', + }; + } + + // title to left of bar scenario + const maxTitleHeightRatio = 0.6; + const titleHeight = Math.max(height * maxTitleHeightRatio, MIN_VALUE_HEIGHT); + const titleFontSize = titleHeight / TITLE_LINE_HEIGHT; + const textSize = measureText(title, titleFontSize); + + return { + fontSize: text?.titleSize ?? titleFontSize, + height: 0, + width: textSize.width + 15, + placement: 'left', + }; +} + +export function getTitleStyles(props: Props): { wrapper: CSSProperties; title: CSSProperties } { + const wrapperStyles: CSSProperties = { + display: 'flex', + overflow: 'hidden', + width: '100%', + }; + + const titleDim = calculateTitleDimensions(props); + + const titleStyles: CSSProperties = { + fontSize: `${titleDim.fontSize}px`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%', + alignItems: 'center', + alignSelf: 'center', + }; + + if (isVertical(props.orientation)) { + wrapperStyles.flexDirection = 'column-reverse'; + titleStyles.textAlign = 'center'; + } else { + if (titleDim.placement === 'above') { + wrapperStyles.flexDirection = 'column'; + } else { + wrapperStyles.flexDirection = 'row'; + + titleStyles.width = `${titleDim.width}px`; + titleStyles.textAlign = 'right'; + titleStyles.paddingRight = '10px'; + } + } + + return { + wrapper: wrapperStyles, + title: titleStyles, + }; +} + +interface BasicAndGradientStyles { + wrapper: CSSProperties; + bar: CSSProperties; + emptyBar: CSSProperties; + value: CSSProperties; +} + +interface BarAndValueDimensions { + valueWidth: number; + valueHeight: number; + maxBarWidth: number; + maxBarHeight: number; + wrapperHeight: number; + wrapperWidth: number; +} + +/** + * @internal + * Only exported for unit tests + **/ +export function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { + const { height, width, orientation, text, alignmentFactors } = props; + const titleDim = calculateTitleDimensions(props); + const value = alignmentFactors ?? props.value; + const valueString = formattedValueToString(value); + + let maxBarHeight = 0; + let maxBarWidth = 0; + let valueHeight = 0; + let valueWidth = 0; + let wrapperWidth = 0; + let wrapperHeight = 0; + + // measure text with title font size or min 14px + const fontSizeToMeasureWith = text?.valueSize ?? Math.max(titleDim.fontSize, 12); + const realTextSize = measureText(valueString, fontSizeToMeasureWith); + const realValueWidth = realTextSize.width + VALUE_LEFT_PADDING * 2; + + if (isVertical(orientation)) { + if (text?.valueSize) { + valueHeight = text.valueSize * VALUE_LINE_HEIGHT; + } else { + valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT); + } + + valueWidth = width; + maxBarHeight = height - (titleDim.height + valueHeight); + maxBarWidth = width; + wrapperWidth = width; + wrapperHeight = height - titleDim.height; + } else { + valueHeight = height - titleDim.height; + valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), realValueWidth); + + maxBarHeight = height - titleDim.height; + maxBarWidth = width - valueWidth - titleDim.width; + + if (titleDim.placement === 'above') { + wrapperWidth = width; + wrapperHeight = height - titleDim.height; + } else { + wrapperWidth = width - titleDim.width; + wrapperHeight = height; + } + } + + return { + valueWidth, + valueHeight, + maxBarWidth, + maxBarHeight, + wrapperHeight, + wrapperWidth, + }; +} + +export function getValuePercent(value: number, minValue: number, maxValue: number): number { + return Math.min((value - minValue) / (maxValue - minValue), 1); +} + +/** + * Only exported to for unit test + */ +export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles { + const { displayMode, field, value, alignmentFactors, orientation, theme, text } = props; + const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props); + + const valuePercent = getValuePercent(value.numeric, field.min!, field.max!); + const valueColor = getValueColor(props); + + const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value; + const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text); + + const isBasic = displayMode === 'basic'; + const wrapperStyles: CSSProperties = { + display: 'flex', + flexGrow: 1, + }; + + const barStyles: CSSProperties = { + borderRadius: '3px', + position: 'relative', + zIndex: 1, + }; + + const emptyBar: CSSProperties = { + background: `rgba(${theme.isDark ? '255,255,255' : '0,0,0'}, 0.07)`, + flexGrow: 1, + display: 'flex', + borderRadius: '3px', + position: 'relative', + }; + + if (isVertical(orientation)) { + const barHeight = Math.max(valuePercent * maxBarHeight, 1); + + // vertical styles + wrapperStyles.flexDirection = 'column'; + wrapperStyles.justifyContent = 'flex-end'; + + barStyles.transition = 'height 1s'; + barStyles.height = `${barHeight}px`; + barStyles.width = `${maxBarWidth}px`; + + // adjust so that filled in bar is at the bottom + emptyBar.bottom = '-3px'; + + if (isBasic) { + // Basic styles + barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`; + barStyles.borderTop = `2px solid ${valueColor}`; + } else { + // Gradient styles + barStyles.background = getBarGradient(props, maxBarHeight); + } + } else { + const barWidth = Math.max(valuePercent * maxBarWidth, 1); + + // Custom styles for horizontal orientation + wrapperStyles.flexDirection = 'row-reverse'; + wrapperStyles.justifyContent = 'flex-end'; + wrapperStyles.alignItems = 'stretch'; + + barStyles.transition = 'width 1s'; + barStyles.height = `${maxBarHeight}px`; + barStyles.width = `${barWidth}px`; + + // shift empty region back to fill gaps due to border radius + emptyBar.left = '-3px'; + emptyBar.width = `${maxBarWidth - barWidth}px`; + + if (isBasic) { + // Basic styles + barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`; + barStyles.borderRight = `2px solid ${valueColor}`; + } else { + // Gradient styles + barStyles.background = getBarGradient(props, maxBarWidth); + } + } + + return { + wrapper: wrapperStyles, + bar: barStyles, + value: valueStyles, + emptyBar, + }; +} + +/** + * Only exported to for unit test + */ +export function getBarGradient(props: Props, maxSize: number): string { + const { field, value, orientation, theme } = props; + const cssDirection = isVertical(orientation) ? '0deg' : '90deg'; + const minValue = field.min!; + const maxValue = field.max!; + + let gradient = ''; + let lastpos = 0; + let mode = getFieldColorMode(field.color?.mode); + + if (mode.id === FieldColorModeId.Thresholds) { + const thresholds = field.thresholds!; + + for (let i = 0; i < thresholds.steps.length; i++) { + const threshold = thresholds.steps[i]; + const color = getColorForTheme(threshold.color, props.theme); + const valuePercent = getValuePercent(threshold.value, minValue, maxValue); + const pos = valuePercent * maxSize; + const offset = Math.round(pos - (pos - lastpos) / 2); + + if (gradient === '') { + gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`; + } else if (value.numeric < threshold.value) { + break; + } else { + lastpos = pos; + gradient += ` ${offset}px, ${color}`; + } + } + + return gradient + ')'; + } + + if (mode.isContinuous && mode.colors) { + const scheme = mode.colors.map((item) => getColorForTheme(item, theme)); + for (let i = 0; i < scheme.length; i++) { + const color = scheme[i]; + + if (gradient === '') { + gradient = `linear-gradient(${cssDirection}, ${color} 0px`; + } else { + const valuePercent = i / (scheme.length - 1); + const pos = valuePercent * maxSize; + gradient += `, ${color} ${pos}px`; + } + } + return gradient + ')'; + } + + return value.color ?? FALLBACK_COLOR; +} + +/** + * Only exported to for unit test + */ +export function getValueColor(props: Props): string { + const { value } = props; + if (value.color) { + return value.color; + } + + return FALLBACK_COLOR; +} + +function getValueStyles( + value: FormattedValue, + color: string, + width: number, + height: number, + orientation: VizOrientation, + text?: TextDisplayOptions +): CSSProperties { + const styles: CSSProperties = { + color, + height: `${height}px`, + width: `${width}px`, + display: 'flex', + alignItems: 'center', + lineHeight: VALUE_LINE_HEIGHT, + }; + + // how many pixels in wide can the text be? + let textWidth = width; + const formattedValueString = formattedValueToString(value); + + if (isVertical(orientation)) { + styles.fontSize = text?.valueSize ?? calculateFontSize(formattedValueString, textWidth, height, VALUE_LINE_HEIGHT); + styles.justifyContent = `center`; + } else { + styles.fontSize = + text?.valueSize ?? + calculateFontSize(formattedValueString, textWidth - VALUE_LEFT_PADDING * 2, height, VALUE_LINE_HEIGHT); + styles.justifyContent = `flex-end`; + styles.paddingLeft = `${VALUE_LEFT_PADDING}px`; + styles.paddingRight = `${VALUE_LEFT_PADDING}px`; + // Need to remove the left padding from the text width constraints + textWidth -= VALUE_LEFT_PADDING; + } + + return styles; +} diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap new file mode 100644 index 0000000..eed60db --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BarGauge Render with basic options should render 1`] = ` +
+
+ +
+
+
+
+`; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.mdx b/packages/grafana-ui/src/components/BigValue/BigValue.mdx new file mode 100644 index 0000000..46ad7bf --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValue.mdx @@ -0,0 +1,70 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { BigValue } from './BigValue'; + + + +# BigValue +Component for showing a value based on a [DisplayValue](https://grafana.com/docs/grafana/latest/packages_api/data/displayvalue/#displayvalue-interface). + +### Display properties +There are a few properties that will determine the look of the BigValue. + +#### Justify mode +There are two modes for aligning text, auto and center. + +#### Graph mode +You can control graph shown in BigValue with the `GraphMode` property. `GraphMode.Area` renders a +graph in the behind the value. `GrapMode.None` will hide it. + +#### Color mode +`ColorMode` controls which part of the component that should have the color from thresholds or field config, +`ColorMode.Background` and `ColorMode.Value`. + +Note, `ColorMode.Value` will only set the color for the value. + +#### Text mode +There are four variants to render text: + + * `TextMode.Auto` - Show value and name if there's more than on BigValue in the same pane. + * `TextMode.Value` - Show only the value. + * `TextMode.ValueAndName` - Show value and the name. + * `TextMode.None` - Do not show any value or name. + + + +### Example usage +An example usage is in the [Stat panel](https://grafana.com/docs/grafana/latest/panels/visualizations/stat-panel/). + +```tsx +import { DisplayValue } from '@grafana/data'; +import { + BigValue, + BigValueColorMode, + BigValueGraphMode, + BigValueJustifyMode, + BigValueTextMode, + useTheme +} from '@grafana/ui'; + +const bigValue: DisplayValue = { + color: 'red', + value: '5000', + numeric: 5000, + title: 'Volume' +}; + +return ( + +); + + +``` +### Props + diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx new file mode 100644 index 0000000..28bb2b1 --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { + BigValue, + BigValueColorMode, + BigValueGraphMode, + BigValueJustifyMode, + BigValueTextMode, + Props, +} from './BigValue'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './BigValue.mdx'; +import { useTheme } from '../../themes'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { ArrayVector, FieldSparkline, FieldType } from '@grafana/data'; + +export default { + title: 'Visualizations/BigValue', + component: BigValue, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + argTypes: { + width: { control: { type: 'range', min: 200, max: 800 } }, + height: { control: { type: 'range', min: 200, max: 800 } }, + colorMode: { control: { type: 'select', options: [BigValueColorMode.Value, BigValueColorMode.Background] } }, + graphMode: { control: { type: 'select', options: [BigValueGraphMode.Area, BigValueGraphMode.None] } }, + justifyMode: { control: { type: 'select', options: [BigValueJustifyMode.Auto, BigValueJustifyMode.Center] } }, + textMode: { + control: { + type: 'radio', + options: [ + BigValueTextMode.Auto, + BigValueTextMode.Name, + BigValueTextMode.ValueAndName, + BigValueTextMode.None, + BigValueTextMode.Value, + ], + }, + }, + color: { control: 'color' }, + value: NOOP_CONTROL, + sparkline: NOOP_CONTROL, + onClick: NOOP_CONTROL, + className: NOOP_CONTROL, + alignmentFactors: NOOP_CONTROL, + text: NOOP_CONTROL, + count: NOOP_CONTROL, + theme: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + numeric: number; + title: string; + color: string; + valueText: string; +} + +export const Basic: Story = ({ + valueText, + title, + colorMode, + graphMode, + height, + width, + color, + textMode, + justifyMode, +}) => { + const theme = useTheme(); + const sparkline: FieldSparkline = { + y: { + name: '', + values: new ArrayVector([1, 2, 3, 4, 3]), + type: FieldType.number, + config: {}, + }, + }; + + return ( + + ); +}; + +Basic.args = { + valueText: '$5022', + title: 'Total Earnings', + colorMode: BigValueColorMode.Value, + graphMode: BigValueGraphMode.Area, + justifyMode: BigValueJustifyMode.Auto, + width: 400, + height: 300, + color: 'red', + textMode: BigValueTextMode.Auto, +}; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx new file mode 100644 index 0000000..a1acf46 --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { BigValue, Props, BigValueColorMode, BigValueGraphMode } from './BigValue'; +import { getTheme } from '../../themes'; + +function getProps(propOverrides?: Partial): Props { + const props: Props = { + colorMode: BigValueColorMode.Background, + graphMode: BigValueGraphMode.Line, + height: 300, + width: 300, + value: { + text: '25', + numeric: 25, + }, + theme: getTheme(), + }; + + Object.assign(props, propOverrides); + return props; +} + +const setup = (propOverrides?: object) => { + const props = getProps(propOverrides); + const wrapper = shallow(); + const instance = wrapper.instance() as BigValue; + + return { + instance, + wrapper, + }; +}; + +describe('BigValue', () => { + describe('Render with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx new file mode 100644 index 0000000..b9fa541 --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -0,0 +1,94 @@ +// Library +import React, { PureComponent } from 'react'; +import { DisplayValue, DisplayValueAlignmentFactors, FieldSparkline, TextDisplayOptions } from '@grafana/data'; + +// Types +import { Themeable } from '../../types'; +import { buildLayout } from './BigValueLayout'; +import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; + +export enum BigValueColorMode { + Value = 'value', + Background = 'background', +} + +export enum BigValueGraphMode { + None = 'none', + Line = 'line', + Area = 'area', +} + +export enum BigValueJustifyMode { + Auto = 'auto', + Center = 'center', +} + +/** + * Options for how the value & title are to be displayed + */ +export enum BigValueTextMode { + Auto = 'auto', + Value = 'value', + ValueAndName = 'value_and_name', + Name = 'name', + None = 'none', +} + +export interface Props extends Themeable { + /** Height of the component */ + height: number; + /** Width of the component */ + width: number; + /** Value displayed as Big Value */ + value: DisplayValue; + /** Sparkline values for showing a graph under/behind the value */ + sparkline?: FieldSparkline; + /** onClick handler for the value */ + onClick?: React.MouseEventHandler; + /** Custom styling */ + className?: string; + /** Color mode for coloring the value or the background */ + colorMode: BigValueColorMode; + /** Show a graph behind/under the value */ + graphMode: BigValueGraphMode; + /** Auto justify value and text or center it */ + justifyMode?: BigValueJustifyMode; + /** Factors that should influence the positioning of the text */ + alignmentFactors?: DisplayValueAlignmentFactors; + /** Explicit font size control */ + text?: TextDisplayOptions; + /** Specify which text should be visible in the BigValue */ + textMode?: BigValueTextMode; + + /** + * If part of a series of stat panes, this is the total number. + * Used by BigValueTextMode.Auto text mode. + */ + count?: number; +} + +export class BigValue extends PureComponent { + static defaultProps: Partial = { + justifyMode: BigValueJustifyMode.Auto, + }; + + render() { + const { onClick, className } = this.props; + const layout = buildLayout(this.props); + const panelStyles = layout.getPanelStyles(); + const valueAndTitleContainerStyles = layout.getValueAndTitleContainerStyles(); + const valueStyles = layout.getValueStyles(); + const titleStyles = layout.getTitleStyles(); + const textValues = layout.textValues; + + return ( +
+
+ {textValues.title &&
{textValues.title}
} + +
+ {layout.renderChart()} +
+ ); + } +} diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.test.tsx new file mode 100644 index 0000000..b963b79 --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.test.tsx @@ -0,0 +1,53 @@ +import { Props, BigValueColorMode, BigValueGraphMode } from './BigValue'; +import { buildLayout, StackedWithChartLayout, WideWithChartLayout } from './BigValueLayout'; +import { getTheme } from '../../themes'; +import { ArrayVector, FieldType } from '@grafana/data'; + +function getProps(propOverrides?: Partial): Props { + const props: Props = { + colorMode: BigValueColorMode.Background, + graphMode: BigValueGraphMode.Area, + height: 300, + width: 300, + value: { + text: '25', + numeric: 25, + }, + sparkline: { + y: { + name: '', + values: new ArrayVector([1, 2, 3, 4, 3]), + type: FieldType.number, + config: {}, + }, + }, + theme: getTheme(), + }; + + Object.assign(props, propOverrides); + return props; +} + +describe('BigValueLayout', () => { + describe('buildLayout', () => { + it('should auto select to stacked layout', () => { + const layout = buildLayout( + getProps({ + width: 300, + height: 300, + }) + ); + expect(layout).toBeInstanceOf(StackedWithChartLayout); + }); + + it('should auto select to wide layout', () => { + const layout = buildLayout( + getProps({ + width: 300, + height: 100, + }) + ); + expect(layout).toBeInstanceOf(WideWithChartLayout); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx new file mode 100644 index 0000000..334406b --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -0,0 +1,497 @@ +// Libraries +import React, { CSSProperties } from 'react'; +import tinycolor from 'tinycolor2'; + +// Utils +import { formattedValueToString, DisplayValue, getColorForTheme, FieldConfig } from '@grafana/data'; +import { calculateFontSize } from '../../utils/measureText'; + +// Types +import { BigValueColorMode, Props, BigValueJustifyMode, BigValueTextMode } from './BigValue'; +import { getTextColorForBackground } from '../../utils'; +import { DrawStyle, GraphFieldConfig } from '../uPlot/config'; +import { Sparkline } from '../Sparkline/Sparkline'; + +const LINE_HEIGHT = 1.2; +const MAX_TITLE_SIZE = 30; + +export abstract class BigValueLayout { + titleFontSize: number; + valueFontSize: number; + chartHeight: number; + chartWidth: number; + valueColor: string; + panelPadding: number; + justifyCenter: boolean; + titleToAlignTo?: string; + valueToAlignTo: string; + maxTextWidth: number; + maxTextHeight: number; + textValues: BigValueTextValues; + + constructor(private props: Props) { + const { width, height, value, theme, text } = props; + + this.valueColor = getColorForTheme(value.color || 'green', theme); + this.panelPadding = height > 100 ? 12 : 8; + this.textValues = getTextValues(props); + this.justifyCenter = shouldJustifyCenter(props.justifyMode, this.textValues.title); + this.valueToAlignTo = this.textValues.valueToAlignTo; + this.titleToAlignTo = this.textValues.titleToAlignTo; + this.titleFontSize = 14; + this.valueFontSize = 14; + this.chartHeight = 0; + this.chartWidth = 0; + this.maxTextWidth = width - this.panelPadding * 2; + this.maxTextHeight = height - this.panelPadding * 2; + + // Explicit font sizing + if (text) { + if (text.titleSize) { + this.titleFontSize = text.titleSize; + this.titleToAlignTo = undefined; + } + if (text.valueSize) { + this.valueFontSize = text.valueSize; + this.valueToAlignTo = ''; + } + } + } + + getTitleStyles(): CSSProperties { + const styles: CSSProperties = { + fontSize: `${this.titleFontSize}px`, + lineHeight: LINE_HEIGHT, + }; + + if (this.props.colorMode === BigValueColorMode.Background) { + styles.color = getTextColorForBackground(this.valueColor); + } + + return styles; + } + + getValueStyles(): CSSProperties { + const styles: CSSProperties = { + fontSize: this.valueFontSize, + fontWeight: 500, + lineHeight: LINE_HEIGHT, + position: 'relative', + zIndex: 1, + }; + + if (this.justifyCenter) { + styles.textAlign = 'center'; + } + + switch (this.props.colorMode) { + case BigValueColorMode.Value: + styles.color = this.valueColor; + break; + case BigValueColorMode.Background: + styles.color = getTextColorForBackground(this.valueColor); + } + + return styles; + } + + getValueAndTitleContainerStyles() { + const styles: CSSProperties = { + display: 'flex', + }; + + if (this.justifyCenter) { + styles.alignItems = 'center'; + styles.justifyContent = 'center'; + styles.flexGrow = 1; + } + + return styles; + } + + getPanelStyles(): CSSProperties { + const { width, height, theme, colorMode } = this.props; + + const panelStyles: CSSProperties = { + width: `${width}px`, + height: `${height}px`, + padding: `${this.panelPadding}px`, + borderRadius: '3px', + position: 'relative', + display: 'flex', + }; + + const themeFactor = theme.isDark ? 1 : -0.7; + + switch (colorMode) { + case BigValueColorMode.Background: + const bgColor2 = tinycolor(this.valueColor) + .darken(15 * themeFactor) + .spin(8) + .toRgbString(); + const bgColor3 = tinycolor(this.valueColor) + .darken(5 * themeFactor) + .spin(-8) + .toRgbString(); + panelStyles.background = `linear-gradient(120deg, ${bgColor2}, ${bgColor3})`; + break; + case BigValueColorMode.Value: + panelStyles.background = `transparent`; + break; + } + + if (this.justifyCenter) { + panelStyles.alignItems = 'center'; + panelStyles.flexDirection = 'row'; + } + + return panelStyles; + } + + renderChart(): JSX.Element | null { + const { sparkline, colorMode } = this.props; + + if (!sparkline || !sparkline.y) { + return null; + } + + let fillColor: string; + let lineColor: string; + + switch (colorMode) { + case BigValueColorMode.Value: + lineColor = this.valueColor; + fillColor = tinycolor(this.valueColor).setAlpha(0.2).toRgbString(); + break; + case BigValueColorMode.Background: + fillColor = 'rgba(255,255,255,0.4)'; + lineColor = tinycolor(this.valueColor).brighten(40).toRgbString(); + } + + // The graph field configuration applied to Y values + const config: FieldConfig = { + custom: { + drawStyle: DrawStyle.Line, + lineWidth: 1, + fillColor, + lineColor, + }, + }; + + return ( +
+ +
+ ); + } + getChartStyles(): CSSProperties { + return { + position: 'absolute', + right: 0, + bottom: 0, + }; + } +} + +export class WideNoChartLayout extends BigValueLayout { + constructor(props: Props) { + super(props); + + const valueWidthPercent = this.titleToAlignTo?.length ? 0.3 : 1.0; + + if (this.valueToAlignTo.length) { + // initial value size + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth * valueWidthPercent, + this.maxTextHeight, + LINE_HEIGHT + ); + } + + if (this.titleToAlignTo?.length) { + // How big can we make the title and still have it fit + this.titleFontSize = calculateFontSize( + this.titleToAlignTo, + this.maxTextWidth * 0.6, + this.maxTextHeight, + LINE_HEIGHT, + MAX_TITLE_SIZE + ); + + // make sure it's a bit smaller than valueFontSize + this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + } + } + + getValueAndTitleContainerStyles() { + const styles = super.getValueAndTitleContainerStyles(); + styles.flexDirection = 'row'; + styles.alignItems = 'center'; + styles.flexGrow = 1; + + if (!this.justifyCenter) { + styles.justifyContent = 'space-between'; + } + + return styles; + } + + renderChart(): JSX.Element | null { + return null; + } + + getPanelStyles() { + const panelStyles = super.getPanelStyles(); + panelStyles.alignItems = 'center'; + return panelStyles; + } +} + +export class WideWithChartLayout extends BigValueLayout { + constructor(props: Props) { + super(props); + + const { width, height } = props; + + const chartHeightPercent = 0.5; + const titleWidthPercent = 0.6; + const valueWidthPercent = 1 - titleWidthPercent; + const textHeightPercent = 0.4; + + this.chartWidth = width; + this.chartHeight = height * chartHeightPercent; + + if (this.titleToAlignTo?.length) { + this.titleFontSize = calculateFontSize( + this.titleToAlignTo, + this.maxTextWidth * titleWidthPercent, + this.maxTextHeight * textHeightPercent, + LINE_HEIGHT, + MAX_TITLE_SIZE + ); + } + + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth * valueWidthPercent, + this.maxTextHeight * chartHeightPercent, + LINE_HEIGHT + ); + } + } + + getValueAndTitleContainerStyles() { + const styles = super.getValueAndTitleContainerStyles(); + styles.flexDirection = 'row'; + styles.flexGrow = 1; + + if (!this.justifyCenter) { + styles.justifyContent = 'space-between'; + } + + return styles; + } + + getPanelStyles() { + const styles = super.getPanelStyles(); + styles.flexDirection = 'row'; + styles.justifyContent = 'space-between'; + return styles; + } +} + +export class StackedWithChartLayout extends BigValueLayout { + constructor(props: Props) { + super(props); + + const { width, height } = props; + const titleHeightPercent = 0.15; + const chartHeightPercent = 0.25; + let titleHeight = 0; + + this.chartHeight = height * chartHeightPercent; + this.chartWidth = width; + + if (this.titleToAlignTo?.length) { + this.titleFontSize = calculateFontSize( + this.titleToAlignTo, + this.maxTextWidth, + height * titleHeightPercent, + LINE_HEIGHT, + MAX_TITLE_SIZE + ); + } + titleHeight = this.titleFontSize * LINE_HEIGHT; + + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth, + this.maxTextHeight - this.chartHeight - titleHeight, + LINE_HEIGHT + ); + } + + // make title fontsize it's a bit smaller than valueFontSize + if (this.titleToAlignTo?.length) { + this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + } + + // make chart take up unused space + this.chartHeight = height - this.titleFontSize * LINE_HEIGHT - this.valueFontSize * LINE_HEIGHT; + } + + getValueAndTitleContainerStyles() { + const styles = super.getValueAndTitleContainerStyles(); + styles.flexDirection = 'column'; + styles.justifyContent = 'center'; + return styles; + } + + getPanelStyles() { + const styles = super.getPanelStyles(); + styles.flexDirection = 'column'; + return styles; + } +} + +export class StackedWithNoChartLayout extends BigValueLayout { + constructor(props: Props) { + super(props); + + const { height } = props; + const titleHeightPercent = 0.15; + let titleHeight = 0; + + if (this.titleToAlignTo?.length) { + this.titleFontSize = calculateFontSize( + this.titleToAlignTo, + this.maxTextWidth, + height * titleHeightPercent, + LINE_HEIGHT, + MAX_TITLE_SIZE + ); + + titleHeight = this.titleFontSize * LINE_HEIGHT; + } + + if (this.valueToAlignTo.length) { + this.valueFontSize = calculateFontSize( + this.valueToAlignTo, + this.maxTextWidth, + this.maxTextHeight - titleHeight, + LINE_HEIGHT + ); + } + + // make title fontsize it's a bit smaller than valueFontSize + this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize); + } + + getValueAndTitleContainerStyles() { + const styles = super.getValueAndTitleContainerStyles(); + styles.flexDirection = 'column'; + styles.flexGrow = 1; + return styles; + } + + getPanelStyles() { + const styles = super.getPanelStyles(); + styles.alignItems = 'center'; + return styles; + } +} + +export function buildLayout(props: Props): BigValueLayout { + const { width, height, sparkline } = props; + const useWideLayout = width / height > 2.5; + + if (useWideLayout) { + if (height > 50 && !!sparkline) { + return new WideWithChartLayout(props); + } else { + return new WideNoChartLayout(props); + } + } + + // stacked layouts + if (height > 100 && !!sparkline) { + return new StackedWithChartLayout(props); + } else { + return new StackedWithNoChartLayout(props); + } +} + +export function shouldJustifyCenter(justifyMode?: BigValueJustifyMode, title?: string) { + if (justifyMode === BigValueJustifyMode.Center) { + return true; + } + + return (title ?? '').length === 0; +} + +export interface BigValueTextValues extends DisplayValue { + valueToAlignTo: string; + titleToAlignTo?: string; + tooltip?: string; +} + +function getTextValues(props: Props): BigValueTextValues { + const { value, alignmentFactors, count } = props; + let { textMode } = props; + + const titleToAlignTo = alignmentFactors ? alignmentFactors.title : value.title; + const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : value); + + // In the auto case we only show title if this big value is part of more panes (count > 1) + if (textMode === BigValueTextMode.Auto && (count ?? 1) === 1) { + textMode = BigValueTextMode.Value; + } + + switch (textMode) { + case BigValueTextMode.Name: + return { + ...value, + title: undefined, + prefix: undefined, + suffix: undefined, + text: value.title || '', + titleToAlignTo: undefined, + valueToAlignTo: titleToAlignTo ?? '', + tooltip: formattedValueToString(value), + }; + case BigValueTextMode.Value: + return { + ...value, + title: undefined, + titleToAlignTo: undefined, + valueToAlignTo, + tooltip: value.title, + }; + case BigValueTextMode.None: + return { + numeric: value.numeric, + color: value.color, + title: undefined, + text: '', + titleToAlignTo: undefined, + valueToAlignTo: '1', + tooltip: `Name: ${value.title}\nValue: ${formattedValueToString(value)}`, + }; + case BigValueTextMode.ValueAndName: + default: + return { + ...value, + titleToAlignTo, + valueToAlignTo, + }; + } +} diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap new file mode 100644 index 0000000..1a370a2 --- /dev/null +++ b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BigValue Render with basic options should render 1`] = ` +
+
+ +
+
+`; diff --git a/packages/grafana-ui/src/components/Button/Button.mdx b/packages/grafana-ui/src/components/Button/Button.mdx new file mode 100644 index 0000000..730a238 --- /dev/null +++ b/packages/grafana-ui/src/components/Button/Button.mdx @@ -0,0 +1,85 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { Button } from './Button'; + + + +# Button + +## Primary + +Used for "call to action", i.e. triggering the main action. There should never be more than one on a page. If you need multiple buttons for different actions, decide which action is the most important and make that the primary `Button`. All other `Button` components should be secondary. + +If there is no primary action, all `Button` components should be secondary. + + +
+ + + +
+
+ +## Secondary + +The secondary `Button` is the default button style and can trigger various actions. How it is used depends on its surroundings: + +1. When next to the primary `Button`, the Secondary style can for example be used for "Cancel" or "Abort" actions. +2. When there is no main important action on a given page, all `Button` components should use the secondary style. + + +
+ + + +
+
+ +## Destructive + +Used for triggering a removing or deleting action. Because of its dominant coloring, it should be used sparingly. If you need multiple Destructive `Button` components in one view, we recommend using a secondary `Button` or Link variant instead and only use the Destructive variant to double confirm. + + +
+ + + +
+
+ +## Link + +Used for hyperlinks. + + +
+ + + +
+
+ + diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx new file mode 100644 index 0000000..a2f2bef --- /dev/null +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { Button, ButtonProps, ButtonVariant } from './Button'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { iconOptions } from '../../utils/storybook/knobs'; +import mdx from './Button.mdx'; +import { HorizontalGroup, VerticalGroup } from '../Layout/Layout'; +import { ButtonGroup } from './ButtonGroup'; +import { ComponentSize } from '../../types/size'; + +export default { + title: 'Buttons/Button', + component: Button, + decorators: [withCenteredStory, withHorizontallyCenteredStory], + argTypes: { + variant: { control: { type: 'select', options: ['primary', 'secondary', 'destructive', 'link'] } }, + size: { control: { type: 'select', options: ['sm', 'md', 'lg'] } }, + icon: { control: { type: 'select', options: iconOptions } }, + css: { control: { disable: true } }, + className: { control: { disable: true } }, + }, + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, +}; + +export const Variants: Story = ({ children, ...args }) => { + const sizes: ComponentSize[] = ['lg', 'md', 'sm']; + const variants: ButtonVariant[] = ['primary', 'secondary', 'destructive', 'link']; + + return ( + + + {variants.map((variant) => ( + + {sizes.map((size) => ( + + ))} + + ))} + +
+ +
With icon and text
+ + + +
+
+ +
With icon only
+ +
+ +
Inside ButtonGroup
+ + + + ); + } +); + +Button.displayName = 'Button'; + +type ButtonLinkProps = CommonProps & ButtonHTMLAttributes & AnchorHTMLAttributes; + +export const LinkButton = React.forwardRef( + ({ variant = 'primary', size = 'md', icon, fullWidth, children, className, disabled, ...otherProps }, ref) => { + const theme = useTheme(); + const styles = getButtonStyles({ + theme, + fullWidth, + size, + variant, + icon, + children, + }); + + const linkButtonStyles = + disabled && + cx( + disabledStyles, + css` + pointer-events: none; + ` + ); + + return ( + + {icon && } + {children && {children}} + + ); + } +); + +LinkButton.displayName = 'LinkButton'; + +export interface StyleProps { + size: ComponentSize; + variant: ButtonVariant; + children?: React.ReactNode; + icon?: IconName; + theme: GrafanaTheme; + fullWidth?: boolean; + narrow?: boolean; +} + +const disabledStyles = css` + cursor: not-allowed; + opacity: 0.65; + box-shadow: none; +`; + +export const getButtonStyles = (props: StyleProps) => { + const { theme, variant, size, children, fullWidth } = props; + const { padding, fontSize, height } = getPropertiesForButtonSize(size, theme); + const { borderColor, variantStyles } = getPropertiesForVariant(theme, variant); + const iconOnly = !children; + + return { + button: css` + label: button; + display: inline-flex; + align-items: center; + font-weight: ${theme.typography.weight.semibold}; + font-family: ${theme.typography.fontFamily.sansSerif}; + font-size: ${fontSize}; + padding: 0 ${padding}px; + height: ${height}px; + // Deduct border from line-height for perfect vertical centering on windows and linux + line-height: ${height - 2}px; + vertical-align: middle; + cursor: pointer; + border: 1px solid ${borderColor}; + border-radius: ${theme.border.radius.sm}; + ${fullWidth && + ` + flex-grow: 1; + justify-content: center; + `} + ${variantStyles} + + &[disabled], + &:disabled { + ${disabledStyles}; + } + `, + img: css` + width: 16px; + height: 16px; + margin-right: ${theme.spacing.sm}; + margin-left: -${theme.spacing.xs}; + `, + icon: css` + margin-left: -${padding / 2}px; + margin-right: ${(iconOnly ? -padding : padding) / 2}px; + `, + content: css` + display: flex; + flex-direction: row; + align-items: center; + white-space: nowrap; + height: 100%; + `, + }; +}; + +function getButtonVariantStyles(from: string, to: string, textColor: string, theme: GrafanaTheme) { + return css` + background: linear-gradient(180deg, ${from} 0%, ${to} 100%); + color: ${textColor}; + &:hover { + background: ${from}; + color: ${textColor}; + } + + &:focus { + background: ${from}; + outline: none; + ${focusCss(theme)}; + } + `; +} + +export function getPropertiesForVariant(theme: GrafanaTheme, variant: ButtonVariant) { + switch (variant) { + case 'secondary': + const from = theme.isLight ? theme.palette.gray7 : theme.palette.gray15; + const to = theme.isLight ? tinycolor(from).darken(5).toString() : tinycolor(from).lighten(4).toString(); + return { + borderColor: theme.isLight ? theme.palette.gray85 : theme.palette.gray25, + variantStyles: getButtonVariantStyles( + from, + to, + theme.isLight ? theme.palette.gray25 : theme.palette.gray4, + theme + ), + }; + + case 'destructive': + return { + borderColor: theme.palette.redShade, + variantStyles: getButtonVariantStyles( + theme.palette.redBase, + theme.palette.redShade, + theme.palette.white, + theme + ), + }; + + case 'link': + return { + borderColor: 'transparent', + variantStyles: css` + background: transparent; + color: ${theme.colors.linkExternal}; + + &:focus { + outline: none; + text-decoration: underline; + } + `, + }; + + case 'primary': + default: + return { + borderColor: theme.colors.bgBlue1, + variantStyles: getButtonVariantStyles(theme.colors.bgBlue1, theme.colors.bgBlue2, theme.palette.white, theme), + }; + } +} diff --git a/packages/grafana-ui/src/components/Button/ButtonGroup.tsx b/packages/grafana-ui/src/components/Button/ButtonGroup.tsx new file mode 100644 index 0000000..68aacef --- /dev/null +++ b/packages/grafana-ui/src/components/Button/ButtonGroup.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef, HTMLAttributes } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '../../themes'; + +export interface Props extends HTMLAttributes { + className?: string; +} + +export const ButtonGroup = forwardRef(({ className, children, ...rest }, ref) => { + const styles = useStyles(getStyles); + + return ( +
+ {children} +
+ ); +}); + +ButtonGroup.displayName = 'ButtonGroup'; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + display: flex; + + > button { + border-radius: 0; + border-right-width: 0; + + &.toolbar-button { + margin-left: 0; + } + + &:last-of-type { + border-radius: 0 ${theme.border.radius.sm} ${theme.border.radius.sm} 0; + border-right-width: 1px; + } + + &:first-child { + border-radius: ${theme.border.radius.sm} 0 0 ${theme.border.radius.sm}; + } + } + `, +}); diff --git a/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx b/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx new file mode 100644 index 0000000..ab7087e --- /dev/null +++ b/packages/grafana-ui/src/components/Button/FullWidthButtonContainer.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import { css, cx } from 'emotion'; +import { stylesFactory } from '../../themes'; + +export interface Props { + className?: string; +} + +export const FullWidthButtonContainer: FC = ({ className, children }) => { + const styles = getStyles(); + + return
{children}
; +}; + +const getStyles = stylesFactory(() => { + return css` + display: flex; + + button { + flex-grow: 1; + justify-content: center; + } + + > * { + flex-grow: 1; + } + + label { + flex-grow: 1; + text-align: center; + } + `; +}); diff --git a/packages/grafana-ui/src/components/Button/ToolbarButton.story.tsx b/packages/grafana-ui/src/components/Button/ToolbarButton.story.tsx new file mode 100644 index 0000000..d20f90c --- /dev/null +++ b/packages/grafana-ui/src/components/Button/ToolbarButton.story.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { ToolbarButton, ButtonGroup, VerticalGroup, HorizontalGroup } from '@grafana/ui'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { ToolbarButtonRow } from './ToolbarButtonRow'; +import { ToolbarButtonVariant } from './ToolbarButton'; +import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas'; + +export default { + title: 'Buttons/ToolbarButton', + component: ToolbarButton, + decorators: [withCenteredStory], + parameters: {}, +}; + +export const List = () => { + const variants: ToolbarButtonVariant[] = ['default', 'active', 'primary', 'destructive']; + + return ( + + + Button states + + Just text + + With imgSrc + + isOpen + + + isOpen = false + + +
+ disabled + + + Disabled + + +
+ Variants + + {variants.map((variant) => ( + + {variant} + + ))} + +
+ disabled + + + Disabled + + +
+ Variants + + {variants.map((variant) => ( + + {variant} + + ))} + +
+ Wrapped in noSpacing ButtonGroup + + + 2020-10-02 + + + +
+ + + + +
+ As primary and destructive variant + + + + Run query + + + + + + Run query + + + + +
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/Button/ToolbarButton.tsx b/packages/grafana-ui/src/components/Button/ToolbarButton.tsx new file mode 100644 index 0000000..ad1df23 --- /dev/null +++ b/packages/grafana-ui/src/components/Button/ToolbarButton.tsx @@ -0,0 +1,188 @@ +import React, { forwardRef, ButtonHTMLAttributes } from 'react'; +import { cx, css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { styleMixins, useStyles } from '../../themes'; +import { IconName } from '../../types/icon'; +import { Tooltip } from '../Tooltip/Tooltip'; +import { Icon } from '../Icon/Icon'; +import { getPropertiesForVariant } from './Button'; +import { isString } from 'lodash'; +import { selectors } from '@grafana/e2e-selectors'; + +export interface Props extends ButtonHTMLAttributes { + /** Icon name */ + icon?: IconName | React.ReactNode; + /** Tooltip */ + tooltip?: string; + /** For image icons */ + imgSrc?: string; + /** if true or false will show angle-down/up */ + isOpen?: boolean; + /** Controls flex-grow: 1 */ + fullWidth?: boolean; + /** reduces padding to xs */ + narrow?: boolean; + /** variant */ + variant?: ToolbarButtonVariant; + /** Hide any children and only show icon */ + iconOnly?: boolean; +} + +export type ToolbarButtonVariant = 'default' | 'primary' | 'destructive' | 'active'; + +export const ToolbarButton = forwardRef( + ( + { + tooltip, + icon, + className, + children, + imgSrc, + fullWidth, + isOpen, + narrow, + variant = 'default', + iconOnly, + 'aria-label': ariaLabel, + ...rest + }, + ref + ) => { + const styles = useStyles(getStyles); + + const buttonStyles = cx( + 'toolbar-button', + { + [styles.button]: true, + [styles.buttonFullWidth]: fullWidth, + [styles.narrow]: narrow, + }, + (styles as any)[variant], + className + ); + + const contentStyles = cx({ + [styles.content]: true, + [styles.contentWithIcon]: !!icon, + [styles.contentWithRightIcon]: isOpen !== undefined, + }); + + const body = ( + + ); + + return tooltip ? ( + + {body} + + ) : ( + body + ); + } +); + +function getButttonAriaLabel(ariaLabel: string | undefined, tooltip: string | undefined) { + return ariaLabel ? ariaLabel : tooltip ? selectors.components.PageToolbar.item(tooltip) : undefined; +} + +function renderIcon(icon: IconName | React.ReactNode) { + if (!icon) { + return null; + } + + if (isString(icon)) { + return ; + } + + return icon; +} + +const getStyles = (theme: GrafanaTheme) => { + const primaryVariant = getPropertiesForVariant(theme, 'primary'); + const destructiveVariant = getPropertiesForVariant(theme, 'destructive'); + + return { + button: css` + label: toolbar-button; + display: flex; + align-items: center; + height: ${theme.height.md}px; + padding: 0 ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + line-height: ${theme.height.md - 2}px; + font-weight: ${theme.typography.weight.semibold}; + border: 1px solid ${theme.colors.border2}; + white-space: nowrap; + + &:focus { + outline: none; + } + + &[disabled], + &:disabled { + cursor: not-allowed; + opacity: 0.5; + &:hover { + color: ${theme.colors.textWeak}; + background: ${theme.colors.bg1}; + } + } + `, + default: css` + color: ${theme.colors.textWeak}; + background-color: ${theme.colors.bg1}; + &:hover { + color: ${theme.colors.text}; + background: ${styleMixins.hoverColor(theme.colors.bg1, theme)}; + } + `, + active: css` + color: ${theme.palette.orangeDark}; + border-color: ${theme.palette.orangeDark}; + background-color: transparent; + &:hover { + color: ${theme.colors.text}; + background: ${styleMixins.hoverColor(theme.colors.bg1, theme)}; + } + `, + primary: css` + border-color: ${primaryVariant.borderColor}; + ${primaryVariant.variantStyles} + `, + destructive: css` + border-color: ${destructiveVariant.borderColor}; + ${destructiveVariant.variantStyles} + `, + narrow: css` + padding: 0 ${theme.spacing.xs}; + `, + img: css` + width: 16px; + height: 16px; + margin-right: ${theme.spacing.sm}; + `, + buttonFullWidth: css` + flex-grow: 1; + `, + content: css` + flex-grow: 1; + `, + contentWithIcon: css` + display: none; + padding-left: ${theme.spacing.sm}; + + @media ${styleMixins.mediaUp(theme.breakpoints.md)} { + display: block; + } + `, + contentWithRightIcon: css` + padding-right: ${theme.spacing.xs}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Button/ToolbarButtonRow.tsx b/packages/grafana-ui/src/components/Button/ToolbarButtonRow.tsx new file mode 100644 index 0000000..0011790 --- /dev/null +++ b/packages/grafana-ui/src/components/Button/ToolbarButtonRow.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef, HTMLAttributes } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '../../themes'; + +export interface Props extends HTMLAttributes { + className?: string; +} + +export const ToolbarButtonRow = forwardRef(({ className, children, ...rest }, ref) => { + const styles = useStyles(getStyles); + + return ( +
+ {children} +
+ ); +}); + +ToolbarButtonRow.displayName = 'ToolbarButtonRow'; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + display: flex; + + .button-group, + .toolbar-button { + margin-left: ${theme.spacing.sm}; + + &:first-child { + margin-left: 0; + } + } + `, +}); diff --git a/packages/grafana-ui/src/components/Button/index.ts b/packages/grafana-ui/src/components/Button/index.ts new file mode 100644 index 0000000..cc6da63 --- /dev/null +++ b/packages/grafana-ui/src/components/Button/index.ts @@ -0,0 +1,4 @@ +export * from './Button'; +export { ButtonGroup } from './ButtonGroup'; +export { ToolbarButton, ToolbarButtonVariant } from './ToolbarButton'; +export { ToolbarButtonRow } from './ToolbarButtonRow'; diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx new file mode 100644 index 0000000..916b761 --- /dev/null +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { ButtonCascader } from '@grafana/ui'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { ButtonCascaderProps } from './ButtonCascader'; + +export default { + title: 'Forms/Cascader/ButtonCascader', + component: ButtonCascader, + decorators: [withCenteredStory], + parameters: { + knobs: { + disable: true, + }, + }, + args: { + disabled: false, + children: 'Click me!', + options: [ + { + label: 'A', + value: 'A', + children: [ + { label: 'B', value: 'B' }, + { label: 'C', value: 'C' }, + ], + }, + { label: 'D', value: 'D' }, + ], + }, + argTypes: { + icon: { control: { type: 'select', options: ['plus', 'minus', 'table'] } }, + options: { control: 'object' }, + className: NOOP_CONTROL, + value: NOOP_CONTROL, + fieldNames: NOOP_CONTROL, + }, +}; + +const Template: Story = ({ children, ...args }) => { + return {children}; +}; + +export const simple = Template.bind({}); + +export const withIcon = Template.bind({}); +withIcon.args = { + icon: 'plus', +}; diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx new file mode 100644 index 0000000..0444737 --- /dev/null +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from '../Icon/Icon'; +import { IconName } from '../../types/icon'; +import { css, cx } from 'emotion'; + +// @ts-ignore +import RCCascader from 'rc-cascader'; +import { CascaderOption } from '../Cascader/Cascader'; +import { onChangeCascader, onLoadDataCascader } from '../Cascader/optionMappings'; +import { stylesFactory, useTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; + +export interface ButtonCascaderProps { + options: CascaderOption[]; + children: string; + icon?: IconName; + disabled?: boolean; + value?: string[]; + fieldNames?: { label: string; value: string; children: string }; + loadData?: (selectedOptions: CascaderOption[]) => void; + onChange?: (value: string[], selectedOptions: CascaderOption[]) => void; + onPopupVisibleChange?: (visible: boolean) => void; + className?: string; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + popup: css` + label: popup; + z-index: ${theme.zIndex.dropdown}; + `, + icons: { + right: css` + margin: 1px 0 0 4px; + `, + left: css` + margin: -1px 4px 0 0; + `, + }, + }; +}); + +export const ButtonCascader: React.FC = (props) => { + const { onChange, className, loadData, icon, ...rest } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + return ( + + + + ); +}; + +ButtonCascader.displayName = 'ButtonCascader'; diff --git a/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss new file mode 100644 index 0000000..8793c83 --- /dev/null +++ b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss @@ -0,0 +1,190 @@ +.rc-cascader { + font-size: 12px; + + &-menus { + font-size: 12px; + overflow: hidden; + background: $page-bg; + position: absolute; + border: $panel-border; + border-radius: $border-radius; + box-shadow: $typeahead-shadow; + white-space: nowrap; + + &-hidden { + display: none; + } + + &.slide-up-enter, + &.slide-up-appear { + animation-duration: 0.3s; + animation-fill-mode: both; + transform-origin: 0 0; + opacity: 0; + animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); + animation-play-state: paused; + } + + &.slide-up-enter.slide-up-enter-active.rc-cascader-menus-placement, + &.slide-up-appear.slide-up-appear-active.rc-cascader-menus-placement { + &-bottomLeft { + animation-name: SlideUpIn; + animation-play-state: running; + } + + &-topLeft { + animation-name: SlideDownIn; + animation-play-state: running; + } + } + + &.slide-up-leave { + animation-duration: 0.3s; + animation-fill-mode: both; + transform-origin: 0 0; + opacity: 1; + animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); + animation-play-state: paused; + + &.slide-up-leave-active.rc-cascader-menus-placement { + &-bottomLeft { + animation-name: SlideUpOut; + animation-play-state: running; + } + + &-topLeft { + animation-name: SlideDownOut; + animation-play-state: running; + } + } + } + } + + &-menu { + display: inline-block; + /* width: 100px; */ + max-width: 50vw; + height: 192px; + list-style: none; + margin: 0; + padding: 0; + border-right: $panel-border; + overflow: auto; + + &:last-child { + border-right: 0; + } + + &-item { + height: 32px; + line-height: 32px; + padding: 0 2.5em 0 16px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.3s ease; + position: relative; + + &:hover { + background: $typeahead-selected-bg; + } + + &-disabled { + cursor: not-allowed; + color: $text-color-weak; + + &:hover { + background: transparent; + } + + &:after { + position: absolute; + right: 12px; + content: 'loading'; + color: $text-color-weak; + font-style: italic; + } + } + + &-active { + color: $typeahead-selected-color; + background: $typeahead-selected-bg; + + &:hover { + color: $typeahead-selected-color; + background: $typeahead-selected-bg; + } + } + + &-expand { + position: relative; + + &:after { + content: '>'; + font-size: 12px; + color: $text-color-weak; + position: absolute; + right: 16px; + line-height: 32px; + } + } + } + } +} + +@keyframes SlideUpIn { + 0% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0.8); + } + + 100% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } +} + +@keyframes SlideUpOut { + 0% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } + + 100% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0.8); + } +} + +@keyframes SlideDownIn { + 0% { + opacity: 0; + transform-origin: 0% 100%; + transform: scaleY(0.8); + } + + 100% { + opacity: 1; + transform-origin: 0% 100%; + transform: scaleY(1); + } +} + +@keyframes SlideDownOut { + 0% { + opacity: 1; + transform-origin: 0% 100%; + transform: scaleY(1); + } + + 100% { + opacity: 0; + transform-origin: 0% 100%; + transform: scaleY(0.8); + } +} diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.internal.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.internal.tsx new file mode 100644 index 0000000..06d9b17 --- /dev/null +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.internal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import { CallToActionCard, CallToActionCardProps } from './CallToActionCard'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { Story } from '@storybook/react'; +import { Button } from '../Button/Button'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Layout/CallToActionCard', + component: CallToActionCard, + parameters: { + knobs: { + disable: true, + }, + }, + argTypes: { + Element: { control: { type: 'select', options: ['button', 'custom'] } }, + className: NOOP_CONTROL, + callToActionElement: NOOP_CONTROL, + theme: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + Element: string; + H1Text: string; + buttonText: string; +} + +export const Basic: Story = (args) => { + const ctaElements: { [key: string]: JSX.Element } = { + custom:

{args.H1Text}

, + button: ( + + ), + }; + + return renderComponentWithTheme(CallToActionCard, { + message: args.message, + callToActionElement: ctaElements[args.Element], + footer: args.footer, + }); +}; + +Basic.args = { + Element: 'custom', + message: 'Renders message prop content', + footer: 'Renders footer prop content', + H1Text: 'This is just H1 tag, you can any component as CTA element', + buttonText: 'Add datasource', +}; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx new file mode 100644 index 0000000..afd3d6c --- /dev/null +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import { render } from 'enzyme'; +import { CallToActionCard, CallToActionCardProps } from './CallToActionCard'; +import { ThemeContext } from '../../themes'; + +type Omit = Pick>; + +const TestRenderer = (props: Omit) => { + const theme = useContext(ThemeContext); + return ; +}; + +describe('CallToActionCard', () => { + describe('rendering', () => { + it('when no message and footer provided', () => { + const tree = render(Click me
} />); + expect(tree).toMatchSnapshot(); + }); + + it('when message and no footer provided', () => { + const tree = render( + Click me} /> + ); + expect(tree).toMatchSnapshot(); + }); + + it('when message and footer provided', () => { + const tree = render( + Click me} + /> + ); + expect(tree).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx new file mode 100644 index 0000000..59df03d --- /dev/null +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Themeable } from '../../types/theme'; +import { GrafanaTheme } from '@grafana/data'; +import { css, cx } from 'emotion'; +import { stylesFactory } from '../../themes'; + +export interface CallToActionCardProps extends Themeable { + message?: string | JSX.Element; + callToActionElement: JSX.Element; + footer?: string | JSX.Element; + className?: string; +} + +const getCallToActionCardStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrapper: css` + label: call-to-action-card; + padding: ${theme.spacing.lg}; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.md}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + `, + message: css` + margin-bottom: ${theme.spacing.lg}; + font-style: italic; + `, + footer: css` + margin-top: ${theme.spacing.lg}; + `, +})); + +export const CallToActionCard: React.FunctionComponent = ({ + message, + callToActionElement, + footer, + theme, + className, +}) => { + const css = getCallToActionCardStyles(theme); + + return ( +
+ {message &&
{message}
} + {callToActionElement} + {footer &&
{footer}
} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap new file mode 100644 index 0000000..d90d501 --- /dev/null +++ b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallToActionCard rendering when message and footer provided 1`] = ` +
+
+ Click button bellow +
+ + Click me + +
+ footer content +
+
+`; + +exports[`CallToActionCard rendering when message and no footer provided 1`] = ` +
+
+ Click button bellow +
+ + Click me + +
+`; + +exports[`CallToActionCard rendering when no message and footer provided 1`] = ` + +`; diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx new file mode 100644 index 0000000..4594e8e --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.mdx @@ -0,0 +1,342 @@ +import { Meta, Preview, Props } from "@storybook/addon-docs/blocks"; +import { Card } from "./Card"; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; +import {TagList} from '../Tags/TagList'; + +export const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png' + + + +# Card + +## Usage + +### Basic +A basic `Card` component expects at least a heading, used as a title. +```jsx + +``` + + + + + +### Multiple metadata elements +For providing metadata elements, which can be any extra information for the card, `Card.Meta` component should be used. If metadata consists of multiple strings, each of them has to be escaped (wrapped in brackets `{}`) or better passed in as an array. + +```jsx + + + {['Folder: Test', 'Views: 100']} + + +``` + + + + + {['Folder: Test', 'Views: 100']} + + + + +Metadata also accepts HTML elements, which could be links, for example. For elements, that are not strings, a `key` prop has to be manually specified. + +```jsx + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + +``` + + + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + + +The separator for multiple metadata elements defaults to a vertical line `|`, but can be customised. + +```jsx + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + +``` + + + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + + +### Tags +Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component. Note that this component does not provide any tag styling and that should be handled by the children. It is recommended to use it with Grafana-UI's `TagList` component. + +```jsx + + + console.log(tag)} /> + + +``` + + + + + console.log(tag)} /> + + + + +### As a link + +Card can be used as a clickable link item by specifying `href` prop. In this case the Card's content will be rendered inside `a`. + +```jsx + +``` + + + + +### Inside a list item + +To render cards in a list, it is possible to nest them inside `li` items. +```jsx +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+``` + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +### With media elements + +Cards can also be rendered with media content such icons or images. Such elements need to be wrapped in `Card.Figure` component. + +```jsx + + + Grafana Logo + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + +``` + + + + + Grafana Logo + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + + +### Action Cards + +Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. The actions need to be wrappd in `Card.Actions` and `Card.SecondaryActions` components respectively. + +```jsx + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + + + + + + + + + +``` + + + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + + + + + + + + + + + +### Disabled state + +Card can have a disabled state, effectively making it and its actions non-clickable. If there are any actions, they will be disabled instead of the whole card. + +```jsx + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + +``` + + + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + + + +```jsx + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + + + + + + + + + +``` + + + + + Grafana + https://ops-us-east4.grafana.net/api/prom + + + Grafana Logo + + + + + + + + + + + + +### Props + + diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx new file mode 100644 index 0000000..0c0712d --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { Card, Props } from './Card'; +import mdx from './Card.mdx'; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; +import { TagList } from '../Tags/TagList'; + +const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; + +export default { + title: 'General/Card', + component: Card, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + argTypes: { + heading: { control: { disable: true } }, + description: { control: { disable: true } }, + href: { control: { disable: true } }, + tooltip: { control: { disable: true } }, + onClick: { control: { disable: true } }, + }, +}; + +export const Basic: Story = ({ disabled }) => { + return ( + + ); +}; + +export const AsLink: Story = ({ disabled }) => { + return ( + + ); +}; + +export const WithTooltip: Story = ({ disabled }) => { + return ( + + ); +}; + +export const WithTags: Story = ({ disabled }) => { + return ( + + Elastic Search + + console.log('tag', tag)} /> + + + ); +}; + +export const WithMedia: Story = ({ disabled }) => { + return ( + + + Prometheus + + https://ops-us-east4.grafana.net/api/prom + + + + Prometheus Logo + + + ); +}; +export const WithActions: Story = ({ disabled }) => { + return ( + + + Prometheus + + https://ops-us-east4.grafana.net/api/prom + + + + Prometheus Logo + + + + + + + + + + + ); +}; + +export const Full: Story = ({ disabled }) => { + return ( + + + {['Subtitle', 'Meta info 1', 'Meta info 2']} + + https://ops-us-east4.grafana.net/api/prom + + + + Prometheus Logo + + + + + + + + + + + + + + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.test.tsx b/packages/grafana-ui/src/components/Card/Card.test.tsx new file mode 100644 index 0000000..16325de --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Card } from './Card'; + +describe('Card', () => { + it('should execute callback when clicked', () => { + const callback = jest.fn(); + render(); + fireEvent.click(screen.getByText('Test Heading')); + expect(callback).toBeCalledTimes(1); + }); +}); diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx new file mode 100644 index 0000000..cdcee58 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -0,0 +1,364 @@ +import React, { memo, cloneElement, FC, HTMLAttributes, ReactNode, useCallback } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useTheme, styleMixins, stylesFactory } from '../../themes'; +import { Tooltip, PopoverContent } from '../Tooltip/Tooltip'; + +/** + * @public + */ +export interface ContainerProps extends HTMLAttributes { + /** Content for the card's tooltip */ + tooltip?: PopoverContent; +} + +const CardContainer: FC = ({ children, tooltip, ...props }) => { + return tooltip ? ( + +
{children}
+
+ ) : ( +
{children}
+ ); +}; + +/** + * @public + */ +export interface CardInnerProps { + href?: string; +} + +const CardInner: FC = ({ children, href }) => { + const theme = useTheme(); + const styles = getCardStyles(theme); + return href ? ( + + {children} + + ) : ( + <>{children} + ); +}; + +/** + * @public + */ +export interface Props extends ContainerProps { + /** Main heading for the Card **/ + heading: ReactNode; + /** Card description text */ + description?: string; + /** Indicates if the card and all its actions can be interacted with */ + disabled?: boolean; + /** Link to redirect to on card click. If provided, the Card inner content will be rendered inside `a` */ + href?: string; + /** On click handler for the Card */ + onClick?: () => void; +} + +export interface CardInterface extends FC { + Tags: typeof Tags; + Figure: typeof Figure; + Meta: typeof Meta; + Actions: typeof Actions; + SecondaryActions: typeof SecondaryActions; +} + +/** + * Generic card component + * + * @public + */ +export const Card: CardInterface = ({ + heading, + description, + disabled, + tooltip, + href, + onClick, + className, + children, + ...htmlProps +}) => { + const theme = useTheme(); + const styles = getCardStyles(theme); + const [tags, figure, meta, actions, secondaryActions] = ['Tags', 'Figure', 'Meta', 'Actions', 'SecondaryActions'].map( + (item) => { + const found = React.Children.toArray(children as React.ReactElement[]).find((child) => { + return child?.type && (child.type as any).displayName === item; + }); + + if (found) { + return React.cloneElement(found, { disabled, styles, ...found.props }); + } + return found; + } + ); + + const hasActions = Boolean(actions || secondaryActions); + const disableHover = disabled || (!onClick && !href); + const disableEvents = disabled && !actions; + + const containerStyles = getContainerStyles(theme, disableEvents, disableHover); + const onCardClick = useCallback(() => (disableHover ? () => {} : onClick?.()), [disableHover, onClick]); + + return ( + + + {figure} +
+
+
+
+ {heading} +
+ {meta} + {description &&

{description}

} +
+ {tags} +
+ {hasActions && ( +
+ {actions} + {secondaryActions} +
+ )} +
+
+
+ ); +}; + +/** + * @public + */ +export const getContainerStyles = stylesFactory((theme: GrafanaTheme, disabled = false, disableHover = false) => { + return css` + display: flex; + width: 100%; + color: ${theme.colors.textStrong}; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing.md}; + position: relative; + pointer-events: ${disabled ? 'none' : 'auto'}; + margin-bottom: ${theme.spacing.sm}; + + &::after { + content: ''; + display: ${disabled ? 'block' : 'none'}; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + background: linear-gradient(180deg, rgba(75, 79, 84, 0.5) 0%, rgba(82, 84, 92, 0.5) 100%); + width: calc(100% - 2px); + height: calc(100% - 2px); + border-radius: ${theme.border.radius.sm}; + } + + &:hover { + background: ${disableHover ? theme.colors.bg2 : styleMixins.hoverColor(theme.colors.bg2, theme)}; + cursor: ${disableHover ? 'default' : 'pointer'}; + } + + &:focus { + ${styleMixins.focusCss(theme)}; + } + `; +}); + +/** + * @public + */ +export const getCardStyles = stylesFactory((theme: GrafanaTheme) => { + return { + inner: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + flex-wrap: wrap; + `, + heading: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-bottom: 0; + font-size: ${theme.typography.size.md}; + line-height: ${theme.typography.lineHeight.xs}; + color: ${theme.colors.text}; + font-weight: ${theme.typography.weight.semibold}; + `, + info: css` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + `, + metadata: css` + display: flex; + align-items: center; + width: 100%; + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; + margin: ${theme.spacing.xs} 0 0; + line-height: ${theme.typography.lineHeight.xs}; + `, + description: css` + width: 100%; + margin: ${theme.spacing.sm} 0 0; + color: ${theme.colors.textSemiWeak}; + line-height: ${theme.typography.lineHeight.md}; + `, + media: css` + margin-right: ${theme.spacing.md}; + width: 40px; + display: flex; + align-items: center; + + & > * { + width: 100%; + } + + &:empty { + display: none; + } + `, + actionRow: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: ${theme.spacing.md}; + `, + actions: css` + & > * { + margin-right: ${theme.spacing.sm}; + } + `, + secondaryActions: css` + display: flex; + align-items: center; + color: ${theme.colors.textSemiWeak}; + // align to the right + margin-left: auto; + & > * { + margin-right: ${theme.spacing.sm} !important; + } + `, + separator: css` + margin: 0 ${theme.spacing.sm}; + `, + innerLink: css` + display: flex; + width: 100%; + `, + tagList: css` + max-width: 50%; + `, + }; +}); + +interface ChildProps { + styles?: ReturnType; + disabled?: boolean; +} + +const Tags: FC = ({ children, styles }) => { + return
{children}
; +}; +Tags.displayName = 'Tags'; + +const Figure: FC = ({ children, styles, align = 'top' }) => { + return ( +
+ {children} +
+ ); +}; + +Figure.displayName = 'Figure'; + +const Meta: FC = memo(({ children, styles, separator = '|' }) => { + let meta = children; + + // Join meta data elements by separator + if (Array.isArray(children) && separator) { + const filtered = React.Children.toArray(children).filter(Boolean); + if (!filtered.length) { + return null; + } + meta = filtered.reduce((prev, curr, i) => [ + prev, + + {separator} + , + curr, + ]); + } + return
{meta}
; +}); + +Meta.displayName = 'Meta'; + +interface ActionsProps extends ChildProps { + children: JSX.Element | JSX.Element[]; + variant?: 'primary' | 'secondary'; +} + +const BaseActions: FC = ({ children, styles, disabled, variant }) => { + const css = variant === 'primary' ? styles?.actions : styles?.secondaryActions; + return ( +
+ {Array.isArray(children) + ? React.Children.map(children, (child) => cloneElement(child, { disabled })) + : cloneElement(children, { disabled })} +
+ ); +}; + +const Actions: FC = ({ children, styles, disabled }) => { + return ( + + {children} + + ); +}; + +Actions.displayName = 'Actions'; + +const SecondaryActions: FC = ({ children, styles, disabled }) => { + return ( + + {children} + + ); +}; + +SecondaryActions.displayName = 'SecondaryActions'; + +Card.Tags = Tags; +Card.Figure = Figure; +Card.Meta = Meta; +Card.Actions = Actions; +Card.SecondaryActions = SecondaryActions; diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.mdx b/packages/grafana-ui/src/components/Cascader/Cascader.mdx new file mode 100644 index 0000000..9197f93 --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.mdx @@ -0,0 +1,12 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { Cascader } from './Cascader'; + + + +# Cascader + +The cascader component is a `Select` with a cascading flyout menu. When you have lots of options in your select, they can be hard to navigate from a regular dropdown list. In that case you can use the cascader to organize your options into groups hierarchically. Just like in the `Select` component, the cascader input doubles as a search field to quickly jump to a selection without navigating the list. + +You can either use the `Simple` cascader component for an empty input as default state or use the `initialValue` or `allowCustomValue` fields to pre-fill your cascader. Initial value means that one of the options from your cascaded list is pre-selected. Custom value means that apart from existing options from the list, your users can add custom values to the list by typing them in the `Select` input. + + diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx new file mode 100644 index 0000000..b02f48a --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx @@ -0,0 +1,95 @@ +import { Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { Cascader } from '@grafana/ui'; +import { CascaderOption, CascaderProps } from './Cascader'; +import mdx from './Cascader.mdx'; +import React from 'react'; + +const onSelect = (val: string) => console.log(val); +const options = [ + { + label: 'First', + value: '1', + items: [ + { + label: 'Second', + value: '2', + }, + { + label: 'Third', + value: '3', + }, + { + label: 'Fourth', + value: '4', + }, + ], + }, + { + label: 'FirstFirst', + value: '5', + }, +]; + +export default { + title: 'Forms/Cascader', + component: Cascader, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + args: { + onSelect, + options, + }, + argTypes: { + width: { control: { type: 'range', min: 0, max: 70 } }, + placeholder: NOOP_CONTROL, + initialValue: NOOP_CONTROL, + changeOnSelect: NOOP_CONTROL, + }, +}; + +const Template: Story = (args) => ; + +export const Simple = Template.bind({}); +Simple.args = { + separator: '', +}; + +export const WithInitialValue = Template.bind({}); +WithInitialValue.args = { + initialValue: '3', +}; + +export const WithCustomValue = Template.bind({}); +WithCustomValue.args = { + initialValue: 'Custom Initial Value', + allowCustomValue: true, + formatCreateLabel: (val) => 'Custom Label' + val, +}; + +export const WithDisplayAllSelectedLevels = Template.bind({}); +WithDisplayAllSelectedLevels.args = { + displayAllSelectedLevels: true, + separator: ',', +}; + +export const WithOptionsStateUpdate = () => { + const [updatedOptions, setOptions] = React.useState([ + { + label: 'Initial state option', + value: 'initial', + }, + ]); + + setTimeout(() => setOptions(options), 2000); + + return ; +}; diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx new file mode 100644 index 0000000..efbd9b2 --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Cascader, CascaderOption, CascaderProps } from './Cascader'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const options = [ + { + label: 'First', + value: '1', + items: [ + { + label: 'Second', + value: '2', + }, + { + label: 'Third', + value: '3', + }, + { + label: 'Fourth', + value: '4', + }, + ], + }, + { + label: 'FirstFirst', + value: '5', + }, +]; + +const CascaderWithOptionsStateUpdate = (props: Omit) => { + const [updatedOptions, setOptions] = React.useState([ + { + label: 'Initial state option', + value: 'initial', + }, + ]); + + setTimeout(() => setOptions(options), 1000); + + return ; +}; + +describe('Cascader', () => { + const placeholder = 'cascader-placeholder'; + + describe('options from state change', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('displays updated options', () => { + render(); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + + expect(screen.getByText('Initial state option')).toBeInTheDocument(); + expect(screen.queryByText('First')).not.toBeInTheDocument(); + + act(() => { + jest.runAllTimers(); + }); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + expect(screen.queryByText('Initial state option')).not.toBeInTheDocument(); + expect(screen.getByText('First')).toBeInTheDocument(); + }); + + it('filters updated results when searching', () => { + render(); + + act(() => { + jest.runAllTimers(); + }); + + userEvent.type(screen.getByPlaceholderText(placeholder), 'Third'); + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + expect(screen.getByText('First / Third')).toBeInTheDocument(); + }); + }); + + it('filters results when searching', () => { + render(); + + userEvent.type(screen.getByPlaceholderText(placeholder), 'Third'); + + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + expect(screen.getByText('First / Third')).toBeInTheDocument(); + }); + + it('displays selected value with all levels when displayAllSelectedLevels is true and selecting a value from the search', () => { + render( + + ); + + userEvent.type(screen.getByPlaceholderText(placeholder), 'Third'); + userEvent.click(screen.getByText('First / Third')); + + expect(screen.getByDisplayValue('First / Third')).toBeInTheDocument(); + }); + + it('displays all levels selected with default separator when displayAllSelectedLevels is true', () => { + render( + {}} /> + ); + + expect(screen.queryByDisplayValue('First/Second')).not.toBeInTheDocument(); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + userEvent.click(screen.getByText('First')); + userEvent.click(screen.getByText('Second')); + + expect(screen.getByDisplayValue('First/Second')).toBeInTheDocument(); + }); + + it('displays all levels selected with separator passed in when displayAllSelectedLevels is true', () => { + const separator = ','; + + render( + {}} + /> + ); + + expect(screen.queryByDisplayValue('First/Second')).not.toBeInTheDocument(); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + userEvent.click(screen.getByText('First')); + userEvent.click(screen.getByText('Second')); + + expect(screen.getByDisplayValue(`First${separator}Second`)).toBeInTheDocument(); + }); + + it('displays last level selected when displayAllSelectedLevels is false', () => { + render( + + ); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + userEvent.click(screen.getByText('First')); + userEvent.click(screen.getByText('Second')); + + expect(screen.getByDisplayValue('Second')).toBeInTheDocument(); + }); + + it('displays last level selected when displayAllSelectedLevels is not passed in', () => { + render(); + + userEvent.click(screen.getByPlaceholderText(placeholder)); + userEvent.click(screen.getByText('First')); + userEvent.click(screen.getByText('Second')); + + expect(screen.getByDisplayValue('Second')).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx new file mode 100644 index 0000000..5cd45e1 --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { Icon } from '../Icon/Icon'; +import RCCascader from 'rc-cascader'; + +import { Select } from '../Select/Select'; +import { Input } from '../Input/Input'; +import { SelectableValue } from '@grafana/data'; +import { css } from 'emotion'; +import { onChangeCascader } from './optionMappings'; +import memoizeOne from 'memoize-one'; + +export interface CascaderProps { + /** The separator between levels in the search */ + separator?: string; + placeholder?: string; + options: CascaderOption[]; + /** Changes the value for every selection, including branch nodes. Defaults to true. */ + changeOnSelect?: boolean; + onSelect(val: string): void; + /** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/ + width?: number; + initialValue?: string; + allowCustomValue?: boolean; + /** A function for formatting the message for custom value creation. Only applies when allowCustomValue is set to true*/ + formatCreateLabel?: (val: string) => string; + displayAllSelectedLevels?: boolean; +} + +interface CascaderState { + isSearching: boolean; + focusCascade: boolean; + //Array for cascade navigation + rcValue: SelectableValue; + activeLabel: string; +} + +export interface CascaderOption { + /** + * The value used under the hood + */ + value: any; + /** + * The label to display in the UI + */ + label: string; + /** Items will be just flattened into the main list of items recursively. */ + items?: CascaderOption[]; + disabled?: boolean; + /** Avoid using */ + title?: string; + /** Children will be shown in a submenu. Use 'items' instead, as 'children' exist to ensure backwards compatibility.*/ + children?: CascaderOption[]; +} + +const disableDivFocus = css(` +&:focus{ + outline: none; +} +`); + +const DEFAULT_SEPARATOR = '/'; + +export class Cascader extends React.PureComponent { + constructor(props: CascaderProps) { + super(props); + const searchableOptions = this.getSearchableOptions(props.options); + const { rcValue, activeLabel } = this.setInitialValue(searchableOptions, props.initialValue); + this.state = { + isSearching: false, + focusCascade: false, + rcValue, + activeLabel, + }; + } + + static defaultProps = { changeOnSelect: true }; + + flattenOptions = (options: CascaderOption[], optionPath: CascaderOption[] = []) => { + let selectOptions: Array> = []; + for (const option of options) { + const cpy = [...optionPath]; + cpy.push(option); + if (!option.items) { + selectOptions.push({ + singleLabel: cpy[cpy.length - 1].label, + label: cpy.map((o) => o.label).join(this.props.separator || ` ${DEFAULT_SEPARATOR} `), + value: cpy.map((o) => o.value), + }); + } else { + selectOptions = [...selectOptions, ...this.flattenOptions(option.items, cpy)]; + } + } + return selectOptions; + }; + + getSearchableOptions = memoizeOne((options: CascaderOption[]) => this.flattenOptions(options)); + + setInitialValue(searchableOptions: Array>, initValue?: string) { + if (!initValue) { + return { rcValue: [], activeLabel: '' }; + } + for (const option of searchableOptions) { + const optionPath = option.value || []; + + if (optionPath.indexOf(initValue) === optionPath.length - 1) { + return { + rcValue: optionPath, + activeLabel: this.props.displayAllSelectedLevels ? option.label : option.singleLabel || '', + }; + } + } + if (this.props.allowCustomValue) { + return { rcValue: [], activeLabel: initValue }; + } + return { rcValue: [], activeLabel: '' }; + } + + //For rc-cascader + onChange = (value: string[], selectedOptions: CascaderOption[]) => { + this.setState({ + rcValue: value, + focusCascade: true, + activeLabel: this.props.displayAllSelectedLevels + ? selectedOptions.map((option) => option.label).join(this.props.separator || DEFAULT_SEPARATOR) + : selectedOptions[selectedOptions.length - 1].label, + }); + + this.props.onSelect(selectedOptions[selectedOptions.length - 1].value); + }; + + //For select + onSelect = (obj: SelectableValue) => { + const valueArray = obj.value || []; + this.setState({ + activeLabel: this.props.displayAllSelectedLevels ? obj.label : obj.singleLabel || '', + rcValue: valueArray, + isSearching: false, + }); + this.props.onSelect(valueArray[valueArray.length - 1]); + }; + + onCreateOption = (value: string) => { + this.setState({ + activeLabel: value, + rcValue: [], + isSearching: false, + }); + this.props.onSelect(value); + }; + + onBlur = () => { + this.setState({ + isSearching: false, + focusCascade: false, + }); + + if (this.state.activeLabel === '') { + this.setState({ + rcValue: [], + }); + } + }; + + onBlurCascade = () => { + this.setState({ + focusCascade: false, + }); + }; + + onInputKeyDown = (e: React.KeyboardEvent) => { + if ( + e.key === 'ArrowDown' || + e.key === 'ArrowUp' || + e.key === 'Enter' || + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' + ) { + return; + } + this.setState({ + focusCascade: false, + isSearching: true, + }); + }; + + render() { + const { allowCustomValue, placeholder, width, changeOnSelect, options } = this.props; + const { focusCascade, isSearching, rcValue, activeLabel } = this.state; + + const searchableOptions = this.getSearchableOptions(options); + + return ( +
+ {isSearching ? ( + {}} + suffix={ + focusCascade ? ( + + ) : ( + + ) + } + /> +
+ + )} +
+ ); + } +} diff --git a/packages/grafana-ui/src/components/Cascader/optionMappings.ts b/packages/grafana-ui/src/components/Cascader/optionMappings.ts new file mode 100644 index 0000000..294f5dd --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/optionMappings.ts @@ -0,0 +1,29 @@ +import { CascaderOption as RCCascaderOption } from 'rc-cascader/lib/Cascader'; +import { CascaderOption } from './Cascader'; + +type onChangeType = ((values: string[], options: CascaderOption[]) => void) | undefined; + +export const onChangeCascader = (onChanged: onChangeType) => (values: string[], options: RCCascaderOption[]) => { + if (onChanged) { + onChanged(values, fromRCOptions(options)); + } +}; + +type onLoadDataType = ((options: CascaderOption[]) => void) | undefined; + +export const onLoadDataCascader = (onLoadData: onLoadDataType) => (options: RCCascaderOption[]) => { + if (onLoadData) { + onLoadData(fromRCOptions(options)); + } +}; + +const fromRCOptions = (options: RCCascaderOption[]): CascaderOption[] => { + return options.map(fromRCOption); +}; + +const fromRCOption = (option: RCCascaderOption): CascaderOption => { + return { + value: option.value ?? '', + label: (option.label as unknown) as string, + }; +}; diff --git a/packages/grafana-ui/src/components/Chart/Tooltip.tsx b/packages/grafana-ui/src/components/Chart/Tooltip.tsx new file mode 100644 index 0000000..177d0c5 --- /dev/null +++ b/packages/grafana-ui/src/components/Chart/Tooltip.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Portal } from '../Portal/Portal'; +import { Dimensions, TimeZone } from '@grafana/data'; +import { FlotPosition } from '../Graph/types'; +import { TooltipContainer } from './TooltipContainer'; +import { useStyles } from '../../themes'; + +export type TooltipMode = 'single' | 'multi' | 'none'; + +// Describes active dimensions user interacts with +// It's a key-value pair where: +// - key is the name of the dimension +// - value is a tuple addressing which column and row from given dimension is active. +// If row is undefined, it means that we are not hovering over a datapoint +export type ActiveDimensions = { [key in keyof T]: [number, number | undefined] | null }; + +export interface TooltipContentProps { + // Each dimension is described by array of fields representing it + // I.e. for graph there are two dimensions: x and y axis: + // { xAxis: [], yAxis: []} + // TODO: type this better, no good idea how yet + dimensions: T; // Dimension[] + activeDimensions?: ActiveDimensions; + timeZone?: TimeZone; + pos: FlotPosition; + mode: TooltipMode; +} + +export interface TooltipProps { + /** Element used as tooltips content */ + content?: React.ReactElement; + + /** Optional component to be used as a tooltip content */ + tooltipComponent?: React.ComponentType; + + /** x/y position relative to the window */ + position?: { x: number; y: number }; + + /** x/y offset relative to tooltip origin element, i.e. graph's datapoint */ + offset?: { x: number; y: number }; + + // Mode in which tooltip works + // - single - display single series info + // - multi - display all series info + mode?: TooltipMode; +} + +export const Tooltip: React.FC = ({ content, position, offset }) => { + const styles = useStyles(getStyles); + if (position) { + return ( + + + {content} + + + ); + } + return null; +}; + +Tooltip.displayName = 'ChartTooltip'; + +const getStyles = () => { + return { + portal: css` + position: absolute; + top: 0; + left: 0; + pointer-events: none; + width: 100%; + height: 100%; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx b/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx new file mode 100644 index 0000000..76de3f1 --- /dev/null +++ b/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx @@ -0,0 +1,115 @@ +import React, { useState, useLayoutEffect, useRef, HTMLAttributes, useMemo } from 'react'; +import { stylesFactory } from '../../themes/stylesFactory'; +import { css, cx } from 'emotion'; +import { useTheme } from '../../themes/ThemeContext'; +import useWindowSize from 'react-use/lib/useWindowSize'; +import { Dimensions2D, GrafanaTheme } from '@grafana/data'; + +interface TooltipContainerProps extends HTMLAttributes { + position: { x: number; y: number }; + offset: { x: number; y: number }; + children?: JSX.Element; +} + +export const TooltipContainer: React.FC = ({ + position: { x: positionX, y: positionY }, + offset: { x: offsetX, y: offsetY }, + children, + className, + ...otherProps +}) => { + const theme = useTheme(); + const tooltipRef = useRef(null); + const [tooltipMeasurement, setTooltipMeasurement] = useState({ width: 0, height: 0 }); + const { width, height } = useWindowSize(); + const [placement, setPlacement] = useState({ + x: positionX + offsetX, + y: positionY + offsetY, + }); + + const resizeObserver = useMemo( + () => + // TS has hard time playing games with @types/resize-observer-browser, hence the ignore + // @ts-ignore + new ResizeObserver((entries) => { + for (let entry of entries) { + const tW = Math.floor(entry.contentRect.width + 2 * 8); // adding padding until Safari supports borderBoxSize + const tH = Math.floor(entry.contentRect.height + 2 * 8); + + if (tooltipMeasurement.width !== tW || tooltipMeasurement.height !== tH) { + setTooltipMeasurement({ + width: tW, + height: tH, + }); + } + } + }), + [tooltipMeasurement.height, tooltipMeasurement.width] + ); + + useLayoutEffect(() => { + if (tooltipRef.current) { + resizeObserver.observe(tooltipRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [resizeObserver]); + + // Make sure tooltip does not overflow window + useLayoutEffect(() => { + let xO = 0, + yO = 0; + if (tooltipRef && tooltipRef.current) { + const xOverflow = width - (positionX + tooltipMeasurement.width); + const yOverflow = height - (positionY + tooltipMeasurement.height); + if (xOverflow < 0) { + xO = tooltipMeasurement.width; + } + + if (yOverflow < 0) { + yO = tooltipMeasurement.height; + } + } + + setPlacement({ + x: positionX + offsetX - xO, + y: positionY + offsetY - yO, + }); + }, [width, height, positionX, offsetX, positionY, offsetY, tooltipMeasurement.width, tooltipMeasurement.height]); + + const styles = getTooltipContainerStyles(theme); + + return ( +
+ {children} +
+ ); +}; + +TooltipContainer.displayName = 'TooltipContainer'; + +const getTooltipContainerStyles = stylesFactory((theme: GrafanaTheme) => { + return { + wrapper: css` + overflow: hidden; + background: ${theme.colors.bg2}; + /* max-width is set up based on .grafana-tooltip class that's used in dashboard */ + max-width: 800px; + padding: ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + z-index: ${theme.zIndex.tooltip}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Chart/index.tsx b/packages/grafana-ui/src/components/Chart/index.tsx new file mode 100644 index 0000000..aa0057f --- /dev/null +++ b/packages/grafana-ui/src/components/Chart/index.tsx @@ -0,0 +1,7 @@ +import { Tooltip } from './Tooltip'; + +const Chart = { + Tooltip, +}; + +export default Chart; diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx new file mode 100644 index 0000000..720098c --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.mdx @@ -0,0 +1,18 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ClickOutsideWrapper } from './ClickOutsideWrapper'; + + + +# ClickOutsideWrapper + +A wrapper component that detects clicks outside of the elements by attaching event listener to `window` or `document` objects. +Useful for components that require an action being triggered when a click outside has occurred, for example closing an overlay or popup. + +# Usage + +```jsx + console.log('Clicked outside')}> +
Container
+
+```` + diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx new file mode 100644 index 0000000..8746e6b --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.story.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { ClickOutsideWrapper } from './ClickOutsideWrapper'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './ClickOutsideWrapper.mdx'; + +export default { + title: 'Layout/ClickOutsideWrapper', + component: ClickOutsideWrapper, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ( + +
Container
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx new file mode 100644 index 0000000..e976629 --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -0,0 +1,61 @@ +import React, { PureComponent, createRef } from 'react'; + +export interface Props { + /** + * Callback to trigger when clicking outside of current element occurs. + */ + onClick: () => void; + /** + * Runs the 'onClick' function when pressing a key outside of the current element. Defaults to true. + */ + includeButtonPress: boolean; + /** Object to attach the click event listener to. */ + parent: Window | Document; + /** + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false. + */ + useCapture?: boolean; +} + +interface State { + hasEventListener: boolean; +} + +export class ClickOutsideWrapper extends PureComponent { + static defaultProps = { + includeButtonPress: true, + parent: window, + useCapture: false, + }; + myRef = createRef(); + state = { + hasEventListener: false, + }; + + componentDidMount() { + this.props.parent.addEventListener('click', this.onOutsideClick, this.props.useCapture); + if (this.props.includeButtonPress) { + // Use keyup since keydown already has an event listener on window + this.props.parent.addEventListener('keyup', this.onOutsideClick, this.props.useCapture); + } + } + + componentWillUnmount() { + this.props.parent.removeEventListener('click', this.onOutsideClick, this.props.useCapture); + if (this.props.includeButtonPress) { + this.props.parent.removeEventListener('keyup', this.onOutsideClick, this.props.useCapture); + } + } + + onOutsideClick = (event: any) => { + const domNode = this.myRef.current; + + if (!domNode || !domNode.contains(event.target)) { + this.props.onClick(); + } + }; + + render() { + return
{this.props.children}
; + } +} diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx new file mode 100644 index 0000000..8bed9f4 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.mdx @@ -0,0 +1,21 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ClipboardButton } from './ClipboardButton'; + + + +# ClipboardButton + +A wrapper for [clipboard.js](https://github.com/zenorocha/clipboard.js) library that allows copying text to clipboard. The text to be copied should be provided via `getText` prop. + +# Usage + +```jsx + 'Text to be copied'} + onClipboardCopy={() => console.log('text copied')} +> + Copy to clipboard + +```` + diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx new file mode 100644 index 0000000..9b97a81 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.internal.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { ClipboardButton, Props } from './ClipboardButton'; +import { Input } from '../Forms/Legacy/Input/Input'; +import mdx from './ClipboardButton.mdx'; + +export default { + title: 'Buttons/ClipboardButton', + component: ClipboardButton, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + argTypes: { + size: NOOP_CONTROL, + variant: NOOP_CONTROL, + icon: NOOP_CONTROL, + className: NOOP_CONTROL, + fullWidth: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + inputText: string; + buttonText: string; +} + +const Wrapper: Story = (args) => { + const [copyMessage, setCopyMessage] = useState(''); + const clipboardCopyMessage = 'Value copied to clipboard'; + return ( +
+
+ args.inputText} + onClipboardCopy={() => setCopyMessage(clipboardCopyMessage)} + > + {args.buttonText} + + {}} /> +
+ {copyMessage} +
+ ); +}; +export const CopyToClipboard = Wrapper.bind({}); +CopyToClipboard.args = { + inputText: 'go run build.go -goos linux -pkg-arch amd64 ${OPT} package-only', + buttonText: 'Copy to clipboard', +}; diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx new file mode 100644 index 0000000..52e8d10 --- /dev/null +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -0,0 +1,51 @@ +import React, { PureComponent } from 'react'; +import Clipboard from 'clipboard'; +import { Button, ButtonProps } from '../Button'; + +export interface Props extends ButtonProps { + /** A function that returns text to be copied */ + getText(): string; + /** Callback when the text has been successfully copied */ + onClipboardCopy?(e: Clipboard.Event): void; + /** Callback when there was an error copying the text */ + onClipboardError?(e: Clipboard.Event): void; +} + +export class ClipboardButton extends PureComponent { + private clipboard!: Clipboard; + private elem!: HTMLButtonElement; + + setRef = (elem: HTMLButtonElement) => { + this.elem = elem; + }; + + componentDidMount() { + const { getText, onClipboardCopy, onClipboardError } = this.props; + + this.clipboard = new Clipboard(this.elem, { + text: () => getText(), + }); + + this.clipboard.on('success', (e: Clipboard.Event) => { + onClipboardCopy && onClipboardCopy(e); + }); + + this.clipboard.on('error', (e: Clipboard.Event) => { + onClipboardError && onClipboardError(e); + }); + } + + componentWillUnmount() { + this.clipboard.destroy(); + } + + render() { + const { getText, onClipboardCopy, onClipboardError, children, ...buttonProps } = this.props; + + return ( + + ); + } +} diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx new file mode 100644 index 0000000..4cdd689 --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.mdx @@ -0,0 +1,10 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { CollapsableSection } from './CollapsableSection'; + + + +# Collapsable Section +A simple container for enabling collapsing/expanding of content. + + + diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx new file mode 100644 index 0000000..6e02c8c --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.story.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CollapsableSection } from './CollapsableSection'; +import mdx from './CollapsableSection.mdx'; + +export default { + title: 'Layout/CollapsableSection', + component: CollapsableSection, + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const simple = () => { + return ( + +
{"Here's some content"}
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx new file mode 100644 index 0000000..c19b926 --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -0,0 +1,53 @@ +import React, { FC, ReactNode, useState } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useStyles } from '../../themes'; +import { Icon } from '..'; + +export interface Props { + label: string; + isOpen: boolean; + children: ReactNode; +} + +export const CollapsableSection: FC = ({ label, isOpen, children }) => { + const [open, toggleOpen] = useState(isOpen); + const styles = useStyles(collapsableSectionStyles); + const headerClass = cx({ + [styles.header]: true, + [styles.headerCollapsed]: !open, + }); + + const tooltip = `Click to ${open ? 'collapse' : 'expand'}`; + + return ( +
+
toggleOpen(!open)} className={headerClass} title={tooltip}> + {label} + +
+ {open &&
{children}
} +
+ ); +}; + +const collapsableSectionStyles = (theme: GrafanaTheme) => { + return { + header: css` + display: flex; + justify-content: space-between; + font-size: ${theme.typography.size.lg}; + line-height: ${theme.spacing.base * 5}px; + cursor: pointer; + `, + headerCollapsed: css` + border-bottom: 1px solid ${theme.colors.border2}; + `, + icon: css` + color: ${theme.colors.textWeak}; + `, + content: css` + padding: ${theme.spacing.md} 0; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.mdx b/packages/grafana-ui/src/components/Collapse/Collapse.mdx new file mode 100644 index 0000000..35bdede --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.mdx @@ -0,0 +1,18 @@ +import { Meta, Preview, Props } from '@storybook/addon-docs/blocks'; +import {Collapse} from "./Collapse"; + +# Collapse + +A content area, which can be horizontally collapsed and expanded. Can be used to hide extra information on the page. + +## Usage + +```jsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(!isOpen)}> +

Panel data

+
+``` + + diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx new file mode 100644 index 0000000..b7b00dc --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.story.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Collapse, ControlledCollapse } from './Collapse'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import mdx from './Collapse.mdx'; + +export default { + title: 'Layout/Collapse', + component: Collapse, + decorators: [withCenteredStory, withHorizontallyCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ( + + {(state, updateValue) => { + return ( + updateValue({ isOpen: !state.isOpen })} + > +

Panel data

+
+ ); + }} +
+ ); +}; + +export const controlled = () => { + return ( + +

Panel data

+
+ ); +}; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx new file mode 100644 index 0000000..25207d8 --- /dev/null +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -0,0 +1,155 @@ +import React, { FunctionComponent, useContext, useState } from 'react'; +import { css, cx } from 'emotion'; + +import { GrafanaTheme } from '@grafana/data'; +import { ThemeContext } from '../../themes/ThemeContext'; +import { stylesFactory } from '../../themes/stylesFactory'; +import { Icon } from '../Icon/Icon'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + collapse: css` + label: collapse; + margin-bottom: ${theme.spacing.sm}; + `, + collapseBody: css` + label: collapse__body; + padding: ${theme.panelPadding}px; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + `, + bodyContentWrapper: css` + label: bodyContentWrapper; + flex: 1; + overflow: hidden; + `, + loader: css` + label: collapse__loader; + height: 2px; + position: relative; + overflow: hidden; + background: none; + margin: ${theme.spacing.xs}; + `, + loaderActive: css` + label: collapse__loader_active; + &:after { + content: ' '; + display: block; + width: 25%; + top: 0; + top: -50%; + height: 250%; + position: absolute; + animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms; + animation-iteration-count: 100; + left: -25%; + background: ${theme.palette.blue85}; + } + @keyframes loader { + from { + left: -25%; + opacity: 0.1; + } + to { + left: 100%; + opacity: 1; + } + } + `, + header: css` + label: collapse__header; + padding: ${theme.spacing.sm} ${theme.spacing.md}; + display: flex; + cursor: inherit; + transition: all 0.1s linear; + cursor: pointer; + `, + headerCollapsed: css` + label: collapse__header--collapsed; + cursor: pointer; + padding: ${theme.spacing.sm} ${theme.spacing.md}; + `, + headerButtons: css` + label: collapse__header-buttons; + margin-right: ${theme.spacing.sm}; + margin-top: ${theme.spacing.xxs}; + font-size: ${theme.typography.size.lg}; + line-height: ${theme.typography.heading.h6}; + display: inherit; + `, + headerButtonsCollapsed: css` + label: collapse__header-buttons--collapsed; + display: none; + `, + headerLabel: css` + label: collapse__header-label; + font-weight: ${theme.typography.weight.semibold}; + margin-right: ${theme.spacing.sm}; + font-size: ${theme.typography.heading.h6}; + `, +})); + +export interface Props { + /** Expand or collapse te content */ + isOpen?: boolean; + /** Element or text for the Collapse header */ + label: React.ReactNode; + /** Indicates loading state of the content */ + loading?: boolean; + /** Toggle collapsed header icon */ + collapsible?: boolean; + /** Callback for the toggle functionality */ + onToggle?: (isOpen: boolean) => void; +} + +export const ControlledCollapse: FunctionComponent = ({ isOpen, onToggle, ...otherProps }) => { + const [open, setOpen] = useState(isOpen); + return ( + { + setOpen(!open); + if (onToggle) { + onToggle(!open); + } + }} + /> + ); +}; + +export const Collapse: FunctionComponent = ({ isOpen, label, loading, collapsible, onToggle, children }) => { + const theme = useContext(ThemeContext); + const style = getStyles(theme); + const onClickToggle = () => { + if (onToggle) { + onToggle(!isOpen); + } + }; + + const panelClass = cx([style.collapse, 'panel-container']); + const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]); + const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]); + const headerButtonsClass = collapsible ? cx([style.headerButtons]) : cx([style.headerButtonsCollapsed]); + + return ( +
+
+
+ +
+
{label}
+
+ {isOpen && ( +
+
+
{children}
+
+ )} +
+ ); +}; + +Collapse.displayName = 'Collapse'; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx new file mode 100644 index 0000000..1805bd8 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import tinycolor from 'tinycolor2'; +import debounce from 'lodash/debounce'; + +import { ColorPickerProps } from './ColorPickerPopover'; +import { Input } from '../Forms/Legacy/Input/Input'; + +interface ColorInputState { + previousColor: string; + value: string; +} + +interface ColorInputProps extends ColorPickerProps { + style?: React.CSSProperties; +} + +class ColorInput extends React.PureComponent { + constructor(props: ColorInputProps) { + super(props); + this.state = { + previousColor: props.color, + value: props.color, + }; + + this.updateColor = debounce(this.updateColor, 100); + } + + static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) { + const newColor = tinycolor(props.color); + if (newColor.isValid() && props.color !== state.previousColor) { + return { + ...state, + previousColor: props.color, + value: newColor.toString(), + }; + } + + return state; + } + updateColor = (color: string) => { + this.props.onChange(color); + }; + + onChange = (event: React.SyntheticEvent) => { + const newColor = tinycolor(event.currentTarget.value); + + this.setState({ + value: event.currentTarget.value, + }); + + if (newColor.isValid()) { + this.updateColor(newColor.toString()); + } + }; + + onBlur = () => { + const newColor = tinycolor(this.state.value); + + if (!newColor.isValid()) { + this.setState({ + value: this.props.color, + }); + } + }; + + render() { + const { value } = this.state; + return ( +
+
+
+ +
+
+ ); + } +} + +export default ColorInput; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx new file mode 100644 index 0000000..6f1b631 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.mdx @@ -0,0 +1,14 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ColorPicker } from './ColorPicker'; + + + +# ColorPicker + +The `ColorPicker` component group consists of several building blocks that are combined in Grafana to create the `ColorPicker`: popover, pickers and palettes. There are different combinations of these building blocks depending on where the `ColorPicker` is used in Grafana. + +The `Popover` is a tabbed view where you can switch between `Palettes`. The `NamedColorsPalette` shows an arrangement of preset colors, while the `SpectrumPalette` is an unlimited HSB color picker. The preset colors are optimized to work well with both light and dark theme. `Popover` is triggered, for example, by the series legend of graphs, or by `Pickers`. + +The `Pickers` are single circular color fields that show the currently picked color. On click, they open the `Popover`. + + diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx new file mode 100644 index 0000000..7d0f61c --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { SeriesColorPicker, ColorPicker } from '@grafana/ui'; +import { action } from '@storybook/addon-actions'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { UseState } from '../../utils/storybook/UseState'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import { ColorPickerProps } from './ColorPickerPopover'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker', + component: ColorPicker, + subcomponents: { SeriesColorPicker }, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + args: { + enableNamedColors: false, + }, + argTypes: { + color: NOOP_CONTROL, + onChange: NOOP_CONTROL, + onColorChange: NOOP_CONTROL, + }, +}; + +export const Basic: Story = ({ enableNamedColors }) => { + return ( + + {(selectedColor, updateSelectedColor) => { + return renderComponentWithTheme(ColorPicker, { + enableNamedColors, + color: selectedColor, + onChange: (color: any) => { + action('Color changed')(color); + updateSelectedColor(color); + }, + }); + }} + + ); +}; + +export const SeriesPicker: Story = ({ enableNamedColors }) => { + return ( + + {(selectedColor, updateSelectedColor) => { + return ( + {}} + color={selectedColor} + onChange={(color) => updateSelectedColor(color)} + > + {({ ref, showColorPicker, hideColorPicker }) => ( +
+ Open color picker +
+ )} +
+ ); + }} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 0000000..8bf41f3 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ColorPicker } from './ColorPicker'; +import { ColorPickerTrigger } from './ColorPickerTrigger'; + +describe('ColorPicker', () => { + it('renders ColorPickerTrigger component by default', () => { + expect( + renderer.create( {}} />).root.findByType(ColorPickerTrigger) + ).toBeTruthy(); + }); + + it('renders custom trigger when supplied', () => { + const div = renderer + .create( + {}}> + {() =>
Custom trigger
} +
+ ) + .root.findByType('div'); + expect(div.children[0]).toBe('Custom trigger'); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..be1fd8d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,121 @@ +import React, { Component, createRef } from 'react'; +import omit from 'lodash/omit'; +import { PopoverController } from '../Tooltip/PopoverController'; +import { Popover } from '../Tooltip/Popover'; +import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover'; +import { getColorForTheme, GrafanaTheme } from '@grafana/data'; +import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; + +import { css } from 'emotion'; +import { withTheme, stylesFactory } from '../../themes'; +import { ColorPickerTrigger } from './ColorPickerTrigger'; + +/** + * If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function + * as a child. You will get show/hide function which you can map to desired interaction (like onClick or onMouseLeave) + * and a ref which needs to be passed to an HTMLElement for correct positioning. If you want to use class or functional + * component as a custom trigger you will need to forward the reference to first HTMLElement child. + */ +type ColorPickerTriggerRenderer = (props: { + // This should be a React.RefObject but due to how object refs are defined you cannot downcast from that + // to a specific type like React.RefObject even though it would be fine in runtime. + ref: React.RefObject; + showColorPicker: () => void; + hideColorPicker: () => void; +}) => React.ReactNode; + +export const colorPickerFactory = ( + popover: React.ComponentType, + displayName = 'ColorPicker' +) => { + return class ColorPicker extends Component { + static displayName = displayName; + pickerTriggerRef = createRef(); + + onColorChange = (color: string) => { + const { onColorChange, onChange } = this.props; + const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler; + + return changeHandler(color); + }; + + render() { + const { theme, children } = this.props; + const styles = getStyles(theme); + const popoverElement = React.createElement(popover, { + ...omit(this.props, 'children'), + onChange: this.onColorChange, + }); + + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {this.pickerTriggerRef.current && ( + + )} + + {children ? ( + // Children have a bit weird type due to intersection used in the definition so we need to cast here, + // but the definition is correct and should not allow to pass a children that does not conform to + // ColorPickerTriggerRenderer type. + (children as ColorPickerTriggerRenderer)({ + ref: this.pickerTriggerRef, + showColorPicker: showPopper, + hideColorPicker: hidePopper, + }) + ) : ( + + )} + + ); + }} + + ); + } + }; +}; + +export const ColorPicker = withTheme(colorPickerFactory(ColorPickerPopover, 'ColorPicker')); +export const SeriesColorPicker = withTheme(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker')); + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + colorPicker: css` + position: absolute; + z-index: ${theme.zIndex.tooltip}; + color: ${theme.colors.text}; + max-width: 400px; + font-size: ${theme.typography.size.sm}; + // !important because these styles are also provided to popper via .popper classes from Tooltip component + // hope to get rid of those soon + padding: 15px !important; + & [data-placement^='top'] { + padding-left: 0 !important; + padding-right: 0 !important; + } + & [data-placement^='bottom'] { + padding-left: 0 !important; + padding-right: 0 !important; + } + & [data-placement^='left'] { + padding-top: 0 !important; + } + & [data-placement^='right'] { + padding-top: 0 !important; + } + `, + }; +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx new file mode 100644 index 0000000..5b04d0d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx @@ -0,0 +1,30 @@ +import { ColorPickerPopover } from './ColorPickerPopover'; + +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; + +export default { + title: 'Pickers and Editors/ColorPicker/Popovers', + component: ColorPickerPopover, + subcomponents: { SeriesColorPickerPopover }, + decorators: [withCenteredStory], +}; + +export const basic = () => { + return renderComponentWithTheme(ColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); +}; + +export const seriesColorPickerPopover = () => { + return renderComponentWithTheme(SeriesColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx new file mode 100644 index 0000000..112ce7a --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { ColorPickerPopover } from './ColorPickerPopover'; +import { ColorSwatch } from './NamedColorsGroup'; +import flatten from 'lodash/flatten'; +import { getNamedColorPalette, getColorFromHexRgbOrName } from '@grafana/data'; + +const allColors = flatten(Array.from(getNamedColorPalette().values())); + +describe('ColorPickerPopover', () => { + describe('rendering', () => { + it('should render provided color as selected if color provided by name', () => { + const wrapper = mount( {}} />); + const selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false); + + expect(selectedSwatch.length).toBe(1); + expect(notSelectedSwatches.length).toBe(allColors.length - 1); + expect(selectedSwatch.prop('isSelected')).toBe(true); + }); + + it('should render provided color as selected if color provided by hex', () => { + const wrapper = mount( {}} />); + + const selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false); + + expect(selectedSwatch.length).toBe(1); + expect(notSelectedSwatches.length).toBe(allColors.length - 1); + expect(selectedSwatch.prop('isSelected')).toBe(true); + }); + }); + + describe('named colors support', () => { + const onChangeSpy = jest.fn(); + let wrapper: ReactWrapper; + + afterEach(() => { + wrapper.unmount(); + onChangeSpy.mockClear(); + }); + + it('should pass hex color value to onChange prop by default', () => { + wrapper = mount(); + + const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + basicBlueSwatch.simulate('click'); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith(getColorFromHexRgbOrName('green')); + }); + + it('should pass color name to onChange prop when named colors enabled', () => { + wrapper = mount(); + + const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === 'green'); + basicBlueSwatch.simulate('click'); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith('green'); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx new file mode 100644 index 0000000..39a809c --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { NamedColorsPalette } from './NamedColorsPalette'; +import { PopoverContentProps } from '../Tooltip/Tooltip'; +import SpectrumPalette from './SpectrumPalette'; +import { Themeable } from '../../types/theme'; +import { warnAboutColorPickerPropsDeprecation } from './warnAboutColorPickerPropsDeprecation'; +import { css, cx } from 'emotion'; +import { GrafanaTheme, GrafanaThemeType, getColorForTheme } from '@grafana/data'; +import { stylesFactory, withTheme } from '../../themes'; + +export type ColorPickerChangeHandler = (color: string) => void; + +export interface ColorPickerProps extends Themeable { + color: string; + onChange: ColorPickerChangeHandler; + + /** + * @deprecated Use onChange instead + */ + onColorChange?: ColorPickerChangeHandler; + enableNamedColors?: boolean; +} + +export interface Props extends ColorPickerProps, PopoverContentProps { + customPickers?: T; +} + +type PickerType = 'palette' | 'spectrum'; + +export interface CustomPickersDescriptor { + [key: string]: { + tabComponent: React.ComponentType; + name: string; + }; +} + +interface State { + activePicker: PickerType | keyof T; +} + +class UnThemedColorPickerPopover extends React.Component, State> { + constructor(props: Props) { + super(props); + this.state = { + activePicker: 'palette', + }; + warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props); + } + + getTabClassName = (tabName: PickerType | keyof T) => { + const { activePicker } = this.state; + return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`; + }; + + handleChange = (color: any) => { + const { onColorChange, onChange, enableNamedColors, theme } = this.props; + const changeHandler = onColorChange || onChange; + + if (enableNamedColors) { + return changeHandler(color); + } + changeHandler(getColorForTheme(color, theme)); + }; + + onTabChange = (tab: PickerType | keyof T) => { + return () => this.setState({ activePicker: tab }); + }; + + renderPicker = () => { + const { activePicker } = this.state; + const { color, theme } = this.props; + + switch (activePicker) { + case 'spectrum': + return ; + case 'palette': + return ; + default: + return this.renderCustomPicker(activePicker); + } + }; + + renderCustomPicker = (tabKey: keyof T) => { + const { customPickers, color, theme } = this.props; + if (!customPickers) { + return null; + } + + return React.createElement(customPickers[tabKey].tabComponent, { + color, + theme, + onChange: this.handleChange, + }); + }; + + renderCustomPickerTabs = () => { + const { customPickers } = this.props; + + if (!customPickers) { + return null; + } + + return ( + <> + {Object.keys(customPickers).map((key) => { + return ( +
+ {customPickers[key].name} +
+ ); + })} + + ); + }; + + render() { + const { theme } = this.props; + const styles = getStyles(theme); + return ( +
+
+
+ Colors +
+
+ Custom +
+ {this.renderCustomPickerTabs()} +
+ +
{this.renderPicker()}
+
+ ); + } +} + +export const ColorPickerPopover = withTheme(UnThemedColorPickerPopover); +ColorPickerPopover.displayName = 'ColorPickerPopover'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + colorPickerPopover: css` + border-radius: ${theme.border.radius.md}; + `, + colorPickerPopoverLight: css` + color: ${theme.palette.black}; + background: linear-gradient(180deg, ${theme.palette.white} 0%, #f7f8fa 104.25%); + box-shadow: 0px 2px 4px #dde4ed, 0px 0px 2px #dde4ed; + + .ColorPickerPopover__tab { + width: 50%; + text-align: center; + padding: ${theme.spacing.sm} 0; + background: #dde4ed; + } + .ColorPickerPopover__tab--active { + background: ${theme.palette.white}; + } + `, + colorPickerPopoverDark: css` + color: #d8d9da; + background: linear-gradient(180deg, #1e2028 0%, #161719 104.25%); + box-shadow: 0px 2px 4px ${theme.palette.black}, 0px 0px 2px ${theme.palette.black}; + + .ColorPickerPopover__tab { + width: 50%; + text-align: center; + padding: ${theme.spacing.sm} 0; + background: #303133; + color: ${theme.palette.white}; + cursor: pointer; + } + .ColorPickerPopover__tab--active { + background: none; + } + `, + colorPickerPopoverContent: css` + width: 336px; + min-height: 184px; + padding: ${theme.spacing.lg}; + `, + colorPickerPopoverTabs: css` + display: flex; + width: 100%; + border-radius: ${theme.border.radius.md} ${theme.border.radius.md} 0 0; + overflow: hidden; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx new file mode 100644 index 0000000..6846552 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx @@ -0,0 +1,56 @@ +import React, { forwardRef } from 'react'; + +interface ColorPickerTriggerProps { + onClick: () => void; + onMouseLeave: () => void; + color: string; +} + +export const ColorPickerTrigger = forwardRef(function ColorPickerTrigger( + props: ColorPickerTriggerProps, + ref: React.Ref +) { + return ( +
+
+
+
+
+ ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx new file mode 100644 index 0000000..014d2b2 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx @@ -0,0 +1,111 @@ +import React, { FunctionComponent } from 'react'; +import { Themeable } from '../../types'; +import { ColorDefinition } from '@grafana/data'; +import { Color } from 'csstype'; +import upperFirst from 'lodash/upperFirst'; +import find from 'lodash/find'; + +type ColorChangeHandler = (color: ColorDefinition) => void; + +export enum ColorSwatchVariant { + Small = 'small', + Large = 'large', +} + +interface ColorSwatchProps extends Themeable, React.DOMAttributes { + color: string; + label?: string; + variant?: ColorSwatchVariant; + isSelected?: boolean; +} + +export const ColorSwatch: FunctionComponent = ({ + color, + label, + variant = ColorSwatchVariant.Small, + isSelected, + theme, + ...otherProps +}) => { + const isSmall = variant === ColorSwatchVariant.Small; + const swatchSize = isSmall ? '16px' : '32px'; + + const swatchStyles = { + width: swatchSize, + height: swatchSize, + borderRadius: '50%', + background: `${color}`, + marginRight: isSmall ? '0px' : '8px', + boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.bg1}` : 'none', + }; + + return ( +
+
+ {variant === ColorSwatchVariant.Large && {label}} +
+ ); +}; + +interface NamedColorsGroupProps extends Themeable { + colors: ColorDefinition[]; + selectedColor?: Color; + onColorSelect: ColorChangeHandler; + key?: string; +} + +const NamedColorsGroup: FunctionComponent = ({ + colors, + selectedColor, + onColorSelect, + theme, + ...otherProps +}) => { + const primaryColor = find(colors, (color) => !!color.isPrimary); + + return ( +
+ {primaryColor && ( + onColorSelect(primaryColor)} + theme={theme} + /> + )} +
+ {colors.map( + (color) => + !color.isPrimary && ( +
+ onColorSelect(color)} + theme={theme} + /> +
+ ) + )} +
+
+ ); +}; + +export default NamedColorsGroup; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx new file mode 100644 index 0000000..6c0c0a4 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { NamedColorsPalette, NamedColorsPaletteProps } from './NamedColorsPalette'; +import { Story } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { UseState } from '../../utils/storybook/UseState'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker/Palettes/NamedColorsPalette', + component: NamedColorsPalette, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + argTypes: { + selectedColor: { control: { type: 'select', options: ['green', 'red', 'light-blue', 'yellow'] } }, + theme: NOOP_CONTROL, + color: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + selectedColor: string; +} + +export const NamedColors: Story = ({ selectedColor }) => { + return ( + + {(selectedColor, updateSelectedColor) => { + return renderComponentWithTheme(NamedColorsPalette, { + color: selectedColor, + onChange: updateSelectedColor, + }); + }} + + ); +}; +NamedColors.args = { + selectedColor: 'red', +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx new file mode 100644 index 0000000..03524a7 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { NamedColorsPalette } from './NamedColorsPalette'; +import { ColorSwatch } from './NamedColorsGroup'; +import { getColorDefinitionByName, GrafanaThemeType } from '@grafana/data'; +import { getTheme } from '../../themes'; + +describe('NamedColorsPalette', () => { + const BasicGreen = getColorDefinitionByName('green'); + + describe('theme support for named colors', () => { + let wrapper: ReactWrapper, selectedSwatch; + + afterEach(() => { + wrapper.unmount(); + }); + + it('should render provided color variant specific for theme', () => { + wrapper = mount( {}} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark); + + wrapper.unmount(); + wrapper = mount( + {}} /> + ); + selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light); + }); + + it('should render dar variant of provided color when theme not provided', () => { + wrapper = mount( {}} theme={getTheme()} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx new file mode 100644 index 0000000..1934560 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { getNamedColorPalette } from '@grafana/data'; +import { Themeable } from '../../types/index'; +import NamedColorsGroup from './NamedColorsGroup'; + +export interface NamedColorsPaletteProps extends Themeable { + color?: string; + onChange: (colorName: string) => void; +} + +export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPaletteProps) => { + const swatches: JSX.Element[] = []; + getNamedColorPalette().forEach((colors, hue) => { + swatches.push( + { + onChange(color.name); + }} + /> + ); + }); + + return ( +
+ {swatches} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx new file mode 100644 index 0000000..e8a75f4 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -0,0 +1,103 @@ +import React, { FunctionComponent } from 'react'; + +import { ColorPickerPopover, ColorPickerProps } from './ColorPickerPopover'; +import { PopoverContentProps } from '../Tooltip/Tooltip'; +import { Switch } from '../Forms/Legacy/Switch/Switch'; +import { css } from 'emotion'; +import { withTheme, useStyles } from '../../themes'; + +export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopoverContentProps { + yaxis?: number; + onToggleAxis?: () => void; +} + +export const SeriesColorPickerPopover: FunctionComponent = (props) => { + const styles = useStyles(getStyles); + const { yaxis, onToggleAxis, color, ...colorPickerProps } = props; + + const customPickers = onToggleAxis + ? { + yaxis: { + name: 'Y-Axis', + tabComponent() { + return ( + { + if (onToggleAxis) { + onToggleAxis(); + } + }} + /> + ); + }, + }, + } + : undefined; + return ; +}; + +interface AxisSelectorProps { + yaxis: number; + onToggleAxis?: () => void; +} + +interface AxisSelectorState { + yaxis: number; +} + +export class AxisSelector extends React.PureComponent { + constructor(props: AxisSelectorProps) { + super(props); + this.state = { + yaxis: this.props.yaxis, + }; + this.onToggleAxis = this.onToggleAxis.bind(this); + } + + onToggleAxis() { + this.setState({ + yaxis: this.state.yaxis === 2 ? 1 : 2, + }); + + if (this.props.onToggleAxis) { + this.props.onToggleAxis(); + } + } + + render() { + const leftButtonClass = this.state.yaxis === 1 ? 'btn-primary' : 'btn-inverse'; + const rightButtonClass = this.state.yaxis === 2 ? 'btn-primary' : 'btn-inverse'; + + return ( +
+ + + +
+ ); + } +} + +// This component is to enable SeriesColorPickerPopover usage via series-color-picker-popover directive +export const SeriesColorPickerPopoverWithTheme = withTheme(SeriesColorPickerPopover); + +const getStyles = () => { + return { + colorPickerAxisSwitch: css` + width: 100%; + `, + colorPickerAxisSwitchLabel: css` + display: flex; + flex-grow: 1; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx new file mode 100644 index 0000000..dc6713e --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import SpectrumPalette from './SpectrumPalette'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; +import mdx from './ColorPicker.mdx'; + +export default { + title: 'Pickers and Editors/ColorPicker/Palettes/SpectrumPalette', + component: SpectrumPalette, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const simple = () => { + return ( + + {(selectedColor, updateSelectedColor) => { + return renderComponentWithTheme(SpectrumPalette, { color: selectedColor, onChange: updateSelectedColor }); + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx new file mode 100644 index 0000000..cc76477 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { CustomPicker, ColorResult } from 'react-color'; + +import { Saturation, Hue, Alpha } from 'react-color/lib/components/common'; +import tinycolor from 'tinycolor2'; +import ColorInput from './ColorInput'; +import { Themeable } from '../../types'; +import SpectrumPalettePointer, { SpectrumPalettePointerProps } from './SpectrumPalettePointer'; +import { GrafanaTheme, getColorForTheme } from '@grafana/data'; + +export interface SpectrumPaletteProps extends Themeable { + color: string; + onChange: (color: string) => void; +} + +// eslint-disable-next-line react/display-name +const renderPointer = (theme: GrafanaTheme) => (props: SpectrumPalettePointerProps) => ( + +); + +// @ts-ignore +const SpectrumPicker = CustomPicker(({ rgb, hsl, onChange, theme }) => { + return ( +
+
+
+
+ {/* + // @ts-ignore */} + +
+
+ {/* + // @ts-ignore */} + +
+
+ +
+ {/* + // @ts-ignore */} + +
+
+
+ ); +}); + +const SpectrumPalette: React.FunctionComponent = ({ color, onChange, theme }) => { + return ( +
+ { + onChange(tinycolor(a.rgb).toString()); + }} + theme={theme} + /> + +
+ ); +}; + +export default SpectrumPalette; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx new file mode 100644 index 0000000..99b76a6 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Themeable } from '../../types'; + +export interface SpectrumPalettePointerProps extends Themeable { + direction?: string; +} + +const SpectrumPalettePointer: React.FunctionComponent = ({ theme, direction }) => { + const styles = { + picker: { + width: '16px', + height: '16px', + transform: direction === 'vertical' ? 'translate(0, -8px)' : 'translate(-8px, 0)', + }, + }; + + const pointerColor = theme.colors.text; + + let pointerStyles: React.CSSProperties = { + position: 'absolute', + left: '6px', + width: '0', + height: '0', + borderStyle: 'solid', + background: 'none', + }; + + let topArrowStyles: React.CSSProperties = { + top: '-7px', + borderWidth: '6px 3px 0px 3px', + borderColor: `${pointerColor} transparent transparent transparent`, + }; + + let bottomArrowStyles: React.CSSProperties = { + bottom: '-7px', + borderWidth: '0px 3px 6px 3px', + borderColor: ` transparent transparent ${pointerColor} transparent`, + }; + + if (direction === 'vertical') { + pointerStyles = { + ...pointerStyles, + left: 'auto', + }; + topArrowStyles = { + borderWidth: '3px 0px 3px 6px', + borderColor: `transparent transparent transparent ${pointerColor}`, + left: '-7px', + top: '7px', + }; + bottomArrowStyles = { + borderWidth: '3px 6px 3px 0px', + borderColor: `transparent ${pointerColor} transparent transparent`, + right: '-7px', + top: '7px', + }; + } + + return ( +
+
+
+
+ ); +}; + +export default SpectrumPalettePointer; diff --git a/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts new file mode 100644 index 0000000..145cf5d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/warnAboutColorPickerPropsDeprecation.ts @@ -0,0 +1,9 @@ +import { deprecationWarning } from '@grafana/data'; +import { ColorPickerProps } from './ColorPickerPopover'; + +export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => { + const { onColorChange } = props; + if (onColorChange) { + deprecationWarning(componentName, 'onColorChange', 'onChange'); + } +}; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx new file mode 100644 index 0000000..04daec4 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.mdx @@ -0,0 +1,31 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { ConfirmButton } from './ConfirmButton'; + + + +# ConfirmButton + +The ConfirmButton is an interactive component that adds a double-confirm option to a clickable action. When clicked, the action is replaced by an inline confirmation with the option to cancel. In Grafana, this is used, for example, for editing values in settings tables. + +## Variants + +There are four variants of the `ConfirmButton`: primary, secondary, destructive, and link. The primary and secondary variants include a primary or secondary `Button` component. The primary and secondary variant should be used to confirm actions like saving or adding data. The destructive variant includes a destructive `Button` component. The destructive variant should be used to double-confirm a deletion or removal of an element. The link variant doesn't include any button and double-confirms as links instead. + +Apart from the button variant, you can also modify the button size and the button text. + +## Usage + +```jsx + { + console.log('Action confirmed!') + }} +> + Click me + +``` + diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx new file mode 100644 index 0000000..c71b4fb --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Story } from '@storybook/react'; +import { ConfirmButton } from '@grafana/ui'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { NOOP_CONTROL } from '../../utils/storybook/noopControl'; +import { action } from '@storybook/addon-actions'; +import { Button } from '../Button'; +import { DeleteButton } from './DeleteButton'; +import { Props } from './ConfirmButton'; +import mdx from './ConfirmButton.mdx'; + +export default { + title: 'Buttons/ConfirmButton', + component: ConfirmButton, + decorators: [withCenteredStory], + subcomponents: { DeleteButton }, + parameters: { + docs: { + page: mdx, + }, + knobs: { + disable: true, + }, + }, + args: { + buttonText: 'Edit', + confirmText: 'Save', + size: 'md', + confirmVariant: 'primary', + disabled: false, + closeOnConfirm: true, + }, + argTypes: { + confirmVariant: { control: { type: 'select' } }, + size: { control: { type: 'select' } }, + className: NOOP_CONTROL, + }, +}; + +interface StoryProps extends Partial { + buttonText: string; +} + +export const Basic: Story = (args) => { + return ( + { + action('Saved')('save!'); + }} + > + {args.buttonText} + + ); +}; + +export const WithCustomButton: Story = (args) => { + return ( + { + action('Saved')('save!'); + }} + > + + + ); +}; + +export const Delete: Story = (args) => { + return ( + { + action('Deleted')('delete!'); + }} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx new file mode 100644 index 0000000..e3117ec --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ConfirmButton } from './ConfirmButton'; +import { mount, ShallowWrapper } from 'enzyme'; +import { Button } from '../Button'; + +describe('ConfirmButton', () => { + let wrapper: any; + let deleted: any; + + beforeAll(() => { + deleted = false; + + function deleteItem() { + deleted = true; + } + + wrapper = mount( + deleteItem()}> + Delete + + ); + }); + + it('should show confirm delete when clicked', () => { + expect(deleted).toBe(false); + wrapper + .find(Button) + .findWhere((n: ShallowWrapper) => { + return n.text() === 'Confirm delete' && n.type() === Button; + }) + .simulate('click'); + expect(deleted).toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx new file mode 100644 index 0000000..eb864c1 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -0,0 +1,178 @@ +import React, { PureComponent, SyntheticEvent } from 'react'; +import { cx, css } from 'emotion'; +import { stylesFactory, withTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import { Themeable } from '../../types'; +import { ComponentSize } from '../../types/size'; +import { Button, ButtonVariant } from '../Button'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + buttonContainer: css` + direction: rtl; + display: flex; + align-items: center; + `, + buttonDisabled: css` + text-decoration: none; + color: ${theme.colors.text}; + opacity: 0.65; + cursor: not-allowed; + pointer-events: none; + `, + buttonShow: css` + opacity: 1; + transition: opacity 0.1s ease; + z-index: 2; + `, + buttonHide: css` + opacity: 0; + transition: opacity 0.1s ease; + z-index: 0; + `, + confirmButton: css` + align-items: flex-start; + background: ${theme.colors.bg1}; + display: flex; + overflow: hidden; + position: absolute; + `, + confirmButtonShow: css` + z-index: 1; + opacity: 1; + transition: opacity 0.08s ease-out, transform 0.1s ease-out; + transform: translateX(0); + `, + confirmButtonHide: css` + opacity: 0; + transition: opacity 0.12s ease-in, transform 0.14s ease-in; + transform: translateX(100px); + `, + }; +}); + +export interface Props extends Themeable { + /** Confirm action callback */ + onConfirm(): void; + /** Custom button styles */ + className?: string; + /** Button size */ + size?: ComponentSize; + /** Text for the Confirm button */ + confirmText?: string; + /** Disable button click action */ + disabled?: boolean; + /** Variant of the Confirm button */ + confirmVariant?: ButtonVariant; + /** Hide confirm actions when after of them is clicked */ + closeOnConfirm?: boolean; + + /** Optional on click handler for the original button */ + onClick?(): void; + /** Callback for the cancel action */ + onCancel?(): void; +} + +interface State { + showConfirm: boolean; +} + +class UnThemedConfirmButton extends PureComponent { + state: State = { + showConfirm: false, + }; + + onClickButton = (event: SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + + this.setState({ + showConfirm: true, + }); + + if (this.props.onClick) { + this.props.onClick(); + } + }; + + onClickCancel = (event: SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + this.setState({ + showConfirm: false, + }); + if (this.props.onCancel) { + this.props.onCancel(); + } + }; + onConfirm = () => { + this.props.onConfirm(); + if (this.props.closeOnConfirm) { + this.setState({ + showConfirm: false, + }); + } + }; + + render() { + const { + theme, + className, + size, + disabled, + confirmText, + confirmVariant: confirmButtonVariant, + children, + } = this.props; + const styles = getStyles(theme); + const buttonClass = cx( + className, + this.state.showConfirm ? styles.buttonHide : styles.buttonShow, + disabled && styles.buttonDisabled + ); + const confirmButtonClass = cx( + styles.confirmButton, + this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide + ); + + const onClick = disabled ? () => {} : this.onClickButton; + + return ( + + {typeof children === 'string' ? ( + + + + ) : ( + + {children} + + )} + + + + + + ); + } +} + +export const ConfirmButton = withTheme(UnThemedConfirmButton); + +// Declare defaultProps directly on the themed component so they are displayed +// in the props table +ConfirmButton.defaultProps = { + size: 'md', + confirmText: 'Save', + disabled: false, + confirmVariant: 'primary', +}; +ConfirmButton.displayName = 'ConfirmButton'; diff --git a/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx new file mode 100644 index 0000000..1f73f30 --- /dev/null +++ b/packages/grafana-ui/src/components/ConfirmButton/DeleteButton.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { ConfirmButton } from './ConfirmButton'; +import { ComponentSize } from '../../types/size'; +import { Button } from '../Button'; + +export interface Props { + /** Confirm action callback */ + onConfirm(): void; + /** Button size */ + size?: ComponentSize; + /** Disable button click action */ + disabled?: boolean; +} + +export const DeleteButton: FC = ({ size, disabled, onConfirm }) => { + return ( + + + + +
+ + ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + modal: css` + width: 500px; + `, + modalContent: css` + text-align: center; + `, + modalText: css` + font-size: ${theme.typography.heading.h4}; + color: ${theme.colors.link}; + margin-bottom: calc(${theme.spacing.d} * 2); + padding-top: ${theme.spacing.d}; + `, +})); diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx new file mode 100644 index 0000000..4f5176f --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx @@ -0,0 +1,31 @@ +import { Props } from '@storybook/addon-docs/blocks'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from "./WithContextMenu"; + +# ContextMenu + +A menu displaying additional options when it's not possible to show them at all times due to a space constraint. + +### Usage + +There are controlled and uncontrolled versions of the component available. With the controlled component (`ContextMenu`) the open/close logic needs to be handled separately. Uncontrolled component (`WithContextMenu`) handles this logic internally. + +#### Controlled component + +```jsx + {}} items={[{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]} /> +``` + +#### Uncontrolled component + +```jsx + [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]}> + {({ openMenu }) => } + +``` + +### Props of ContextMenu + + +### Props of WithContextMenu + diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx new file mode 100644 index 0000000..30eb28b --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { IconButton } from '../IconButton/IconButton'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from './WithContextMenu'; +import mdx from './ContextMenu.mdx'; + +export default { + title: 'General/ContextMenu', + component: ContextMenu, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const menuItems = [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]; + +export const Basic = () => { + return {}} items={menuItems} />; +}; + +export const WithState = () => { + return ( + menuItems}> + {({ openMenu }) => } + + ); +}; diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..9cb7ada --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,55 @@ +import React, { useRef, useState, useLayoutEffect } from 'react'; +import { useClickAway } from 'react-use'; +import { Portal } from '../Portal/Portal'; +import { Menu, MenuItemsGroup } from '../Menu/Menu'; + +export interface ContextMenuProps { + /** Starting horizontal position for the menu */ + x: number; + /** Starting vertical position for the menu */ + y: number; + /** Callback for closing the menu */ + onClose?: () => void; + /** List of the menu items to display */ + items?: MenuItemsGroup[]; + /** A function that returns header element */ + renderHeader?: () => React.ReactNode; +} + +export const ContextMenu: React.FC = React.memo(({ x, y, onClose, items, renderHeader }) => { + const menuRef = useRef(null); + const [positionStyles, setPositionStyles] = useState({}); + + useLayoutEffect(() => { + const menuElement = menuRef.current; + if (menuElement) { + const rect = menuElement.getBoundingClientRect(); + const OFFSET = 5; + const collisions = { + right: window.innerWidth < x + rect.width, + bottom: window.innerHeight < rect.bottom + rect.height + OFFSET, + }; + + setPositionStyles({ + position: 'fixed', + left: collisions.right ? x - rect.width - OFFSET : x - OFFSET, + top: collisions.bottom ? y - rect.height - OFFSET : y + OFFSET, + }); + } + }, [x, y]); + + useClickAway(menuRef, () => { + if (onClose) { + onClose(); + } + }); + + const header = renderHeader && renderHeader(); + return ( + + + + ); +}); + +ContextMenu.displayName = 'ContextMenu'; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx new file mode 100644 index 0000000..683654b --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { ContextMenu } from '../ContextMenu/ContextMenu'; +import { MenuItemsGroup } from '../Menu/Menu'; + +interface WithContextMenuProps { + /** Menu item trigger that accepts openMenu prop */ + children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + /** A function that returns an array of menu items */ + getContextMenuItems: () => MenuItemsGroup[]; +} + +export const WithContextMenu: React.FC = ({ children, getContextMenuItems }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + + return ( + <> + {children({ + openMenu: (e) => { + setIsMenuOpen(true); + setMenuPosition({ + x: e.pageX, + y: e.pageY, + }); + }, + })} + + {isMenuOpen && ( + setIsMenuOpen(false)} + x={menuPosition.x} + y={menuPosition.y} + items={getContextMenuItems()} + /> + )} + + ); +}; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx new file mode 100644 index 0000000..32375a5 --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { CustomScrollbar } from './CustomScrollbar'; + +describe('CustomScrollbar', () => { + it('renders correctly', () => { + const tree = renderer + .create( + +

Scrollable content

+
+ ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx new file mode 100644 index 0000000..fd00711 --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -0,0 +1,176 @@ +import React, { FC, useCallback, useEffect, useRef } from 'react'; +import isNil from 'lodash/isNil'; +import classNames from 'classnames'; +import { css } from 'emotion'; +import Scrollbars from 'react-custom-scrollbars'; +import { useStyles } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; + +interface Props { + className?: string; + autoHide?: boolean; + autoHideTimeout?: number; + autoHeightMax?: string; + hideTracksWhenNotNeeded?: boolean; + hideHorizontalTrack?: boolean; + hideVerticalTrack?: boolean; + scrollTop?: number; + setScrollTop?: (event: any) => void; + autoHeightMin?: number | string; + updateAfterMountMs?: number; +} + +/** + * Wraps component into component from `react-custom-scrollbars` + */ +export const CustomScrollbar: FC = ({ + autoHide = false, + autoHideTimeout = 200, + setScrollTop, + className, + autoHeightMin = '0', + autoHeightMax = '100%', + hideTracksWhenNotNeeded = false, + hideHorizontalTrack, + hideVerticalTrack, + updateAfterMountMs, + scrollTop, + children, +}) => { + const ref = useRef(null); + const styles = useStyles(getStyles); + + const updateScroll = () => { + if (ref.current && !isNil(scrollTop)) { + ref.current.scrollTop(scrollTop); + } + }; + + useEffect(() => { + updateScroll(); + }); + + /** + * Special logic for doing a update a few milliseconds after mount to check for + * updated height due to dynamic content + */ + + useEffect(() => { + if (!updateAfterMountMs) { + return; + } + setTimeout(() => { + const scrollbar = ref.current as any; + if (scrollbar?.update) { + scrollbar.update(); + } + }, updateAfterMountMs); + }, [updateAfterMountMs]); + + function renderTrack(className: string, hideTrack: boolean | undefined, passedProps: any) { + if (passedProps.style && hideTrack) { + passedProps.style.display = 'none'; + } + + return
; + } + + const renderTrackHorizontal = useCallback( + (passedProps: any) => { + return renderTrack('track-horizontal', hideHorizontalTrack, passedProps); + }, + [hideHorizontalTrack] + ); + + const renderTrackVertical = useCallback( + (passedProps: any) => { + return renderTrack('track-vertical', hideVerticalTrack, passedProps); + }, + [hideVerticalTrack] + ); + + const renderThumbHorizontal = useCallback((passedProps: any) => { + return
; + }, []); + + const renderThumbVertical = useCallback((passedProps: any) => { + return
; + }, []); + + const renderView = useCallback((passedProps: any) => { + return
; + }, []); + + return ( + + {children} + + ); +}; + +export default CustomScrollbar; + +const getStyles = (theme: GrafanaTheme) => { + return { + customScrollbar: css` + // Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to + // make scroll working it should fit outer container size (scroll appears only when inner container size is + // greater than outer one). + display: flex; + flex-grow: 1; + .scrollbar-view { + display: flex; + flex-grow: 1; + flex-direction: column; + } + .track-vertical { + border-radius: ${theme.border.radius.md}; + width: ${theme.spacing.sm} !important; + right: 0px; + bottom: ${theme.spacing.xxs}; + top: ${theme.spacing.xxs}; + } + .track-horizontal { + border-radius: ${theme.border.radius.md}; + height: ${theme.spacing.sm} !important; + right: ${theme.spacing.xxs}; + bottom: ${theme.spacing.xxs}; + left: ${theme.spacing.xxs}; + } + .thumb-vertical { + background: ${theme.colors.bg3}; + border-radius: ${theme.border.radius.md}; + opacity: 0; + } + .thumb-horizontal { + background: ${theme.colors.bg3}; + border-radius: ${theme.border.radius.md}; + opacity: 0; + } + &:hover { + .thumb-vertical, + .thumb-horizontal { + opacity: 1; + transition: opacity 0.3s ease-in-out; + } + } + `, + }; +}; 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 new file mode 100644 index 0000000..f709b0e --- /dev/null +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomScrollbar renders correctly 1`] = ` +
+
+

+ Scrollable content +

+
+
+
+
+
+
+
+
+`; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx new file mode 100644 index 0000000..95d8b8d --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkButton.tsx @@ -0,0 +1,35 @@ +import { Field, LinkModel } from '@grafana/data'; +import React from 'react'; +import { ButtonProps, Button } from '../Button'; + +type DataLinkButtonProps = { + link: LinkModel; + buttonProps?: ButtonProps; +}; + +/** + * @internal + */ +export function DataLinkButton({ link, buttonProps }: DataLinkButtonProps) { + return ( + { + if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) { + event.preventDefault(); + link.onClick(event); + } + } + : undefined + } + > + + + ); +} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx new file mode 100644 index 0000000..26a7f00 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -0,0 +1,70 @@ +import React, { ChangeEvent, useContext } from 'react'; +import { VariableSuggestion, GrafanaTheme, DataLink } from '@grafana/data'; +import { Switch } from '../Switch/Switch'; +import { css } from 'emotion'; +import { ThemeContext, stylesFactory } from '../../themes/index'; +import { DataLinkInput } from './DataLinkInput'; +import { Field } from '../Forms/Field'; +import { Input } from '../Input/Input'; + +interface DataLinkEditorProps { + index: number; + isLast: boolean; + value: DataLink; + suggestions: VariableSuggestion[]; + onChange: (index: number, link: DataLink, callback?: () => void) => void; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + listItem: css` + margin-bottom: ${theme.spacing.sm}; + `, + infoText: css` + padding-bottom: ${theme.spacing.md}; + margin-left: 66px; + color: ${theme.colors.textWeak}; + `, +})); + +export const DataLinkEditor: React.FC = React.memo( + ({ index, value, onChange, suggestions, isLast }) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + const onUrlChange = (url: string, callback?: () => void) => { + onChange(index, { ...value, url }, callback); + }; + const onTitleChange = (event: ChangeEvent) => { + onChange(index, { ...value, title: event.target.value }); + }; + + const onOpenInNewTabChanged = () => { + onChange(index, { ...value, targetBlank: !value.targetBlank }); + }; + + return ( +
+ + + + + + + + + + + + + {isLast && ( +
+ With data links you can reference data variables like series name, labels and values. Type CMD+Space, + CTRL+Space, or $ to open variable suggestions. +
+ )} +
+ ); + } +); + +DataLinkEditor.displayName = 'DataLinkEditor'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx new file mode 100644 index 0000000..455a0ae --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -0,0 +1,210 @@ +import React, { memo, RefObject, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; +import { DataLinkSuggestions } from './DataLinkSuggestions'; +import { makeValue, ThemeContext } from '../../index'; +import { SelectionReference } from './SelectionReference'; +import { getFormStyles, Portal } from '../index'; + +// @ts-ignore +import Prism, { Grammar, LanguageMap } from 'prismjs'; +import { Editor } from '@grafana/slate-react'; +import { Value } from 'slate'; +import Plain from 'slate-plain-serializer'; +import { Popper as ReactPopper } from 'react-popper'; +import { css, cx } from 'emotion'; + +import { SlatePrism } from '../../slate-plugins'; +import { SCHEMA } from '../../utils/slate'; +import { stylesFactory } from '../../themes'; +import { DataLinkBuiltInVars, GrafanaTheme, VariableOrigin, VariableSuggestion } from '@grafana/data'; + +const modulo = (a: number, n: number) => a - n * Math.floor(a / n); + +interface DataLinkInputProps { + value: string; + onChange: (url: string, callback?: () => void) => void; + suggestions: VariableSuggestion[]; + placeholder?: string; +} + +const datalinksSyntax: Grammar = { + builtInVariable: { + pattern: /(\${\S+?})/, + }, +}; + +const plugins = [ + SlatePrism( + { + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: () => 'links', + }, + { ...(Prism.languages as LanguageMap), links: datalinksSyntax } + ), +]; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + input: getFormStyles(theme, { variant: 'primary', size: 'md', invalid: false }).input.input, + editor: css` + .token.builtInVariable { + color: ${theme.palette.queryGreen}; + } + .token.variable { + color: ${theme.colors.textBlue}; + } + `, + // Wrapper with child selector needed. + // When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working + wrapperOverrides: css` + width: 100%; + > .slate-query-field__wrapper { + padding: 0; + background-color: transparent; + border: none; + } + `, +})); + +// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this +// was used and changes to different state were propagated here. +export const DataLinkInput: React.FC = memo( + ({ value, onChange, suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations' }) => { + const editorRef = useRef() as RefObject; + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + const [showingSuggestions, setShowingSuggestions] = useState(false); + const [suggestionsIndex, setSuggestionsIndex] = useState(0); + const [linkUrl, setLinkUrl] = useState(makeValue(value)); + const prevLinkUrl = usePrevious(linkUrl); + + // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 + const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); + stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; + + // SelectionReference is used to position the variables suggestion relatively to current DOM selection + const selectionRef = useMemo(() => new SelectionReference(), []); + + const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => { + if (!stateRef.current.showingSuggestions) { + if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { + return setShowingSuggestions(true); + } + return next(); + } + + switch (event.key) { + case 'Backspace': + case 'Escape': + setShowingSuggestions(false); + return setSuggestionsIndex(0); + + case 'Enter': + event.preventDefault(); + return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); + + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length)); + default: + return next(); + } + }, []); + + useEffect(() => { + // Update the state of the link in the parent. This is basically done on blur but we need to do it after + // our state have been updated. The duplicity of state is done for perf reasons and also because local + // state also contains things like selection and formating. + if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { + stateRef.current.onChange(Plain.serialize(linkUrl)); + } + }, [linkUrl, prevLinkUrl]); + + const onUrlChange = React.useCallback(({ value }: { value: Value }) => { + setLinkUrl(value); + }, []); + + const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { + const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$'; + if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { + editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); + } else { + editor.insertText(`\${${item.value}:queryparam}`); + } + + setLinkUrl(editor.value); + setShowingSuggestions(false); + + setSuggestionsIndex(0); + stateRef.current.onChange(Plain.serialize(editor.value)); + }; + + return ( +
+
+
+ {showingSuggestions && ( + + + {({ ref, style, placement }) => { + return ( +
+ setShowingSuggestions(false)} + activeIndex={suggestionsIndex} + /> +
+ ); + }} +
+
+ )} + onKeyDown(event as KeyboardEvent, next)} + plugins={plugins} + className={cx( + styles.editor, + styles.input, + css` + padding: 3px 8px; + ` + )} + /> +
+
+
+ ); + } +); + +DataLinkInput.displayName = 'DataLinkInput'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx new file mode 100644 index 0000000..c47b6a1 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -0,0 +1,143 @@ +import { ThemeContext } from '../../index'; +import { GrafanaTheme, VariableSuggestion } from '@grafana/data'; +import { css, cx } from 'emotion'; +import _ from 'lodash'; +import React, { useRef, useContext, useMemo } from 'react'; +import useClickAway from 'react-use/lib/useClickAway'; +import { List } from '../index'; +import { styleMixins, stylesFactory } from '../../themes'; + +interface DataLinkSuggestionsProps { + suggestions: VariableSuggestion[]; + activeIndex: number; + onSuggestionSelect: (suggestion: VariableSuggestion) => void; + onClose?: () => void; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const wrapperBg = theme.colors.bg1; + const wrapperShadow = theme.colors.dropdownShadow; + const itemColor = theme.colors.text; + const itemBgHover = styleMixins.hoverColor(theme.colors.bg1, theme); + const itemBgActive = theme.colors.bg2; + const separatorColor = theme.colors.border2; + + return { + list: css` + border-bottom: 1px solid ${separatorColor}; + &:last-child { + border: none; + } + `, + wrapper: css` + background: ${wrapperBg}; + z-index: 1; + width: 250px; + box-shadow: 0 5px 10px 0 ${wrapperShadow}; + `, + item: css` + background: none; + padding: 2px 8px; + color: ${itemColor}; + cursor: pointer; + &:hover { + background: ${itemBgHover}; + } + `, + label: css` + color: ${theme.colors.textWeak}; + `, + activeItem: css` + background: ${itemBgActive}; + &:hover { + background: ${itemBgActive}; + } + `, + itemValue: css` + font-family: ${theme.typography.fontFamily.monospace}; + font-size: ${theme.typography.size.sm}; + `, + }; +}); + +export const DataLinkSuggestions: React.FC = ({ suggestions, ...otherProps }) => { + const ref = useRef(null); + const theme = useContext(ThemeContext); + useClickAway(ref, () => { + if (otherProps.onClose) { + otherProps.onClose(); + } + }); + + const groupedSuggestions = useMemo(() => { + return _.groupBy(suggestions, (s) => s.origin); + }, [suggestions]); + + const styles = getStyles(theme); + return ( +
+ {Object.keys(groupedSuggestions).map((key, i) => { + const indexOffset = + i === 0 + ? 0 + : Object.keys(groupedSuggestions).reduce((acc, current, index) => { + if (index >= i) { + return acc; + } + return acc + groupedSuggestions[current].length; + }, 0); + + return ( + + ); + })} +
+ ); +}; + +DataLinkSuggestions.displayName = 'DataLinkSuggestions'; + +interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps { + label: string; + activeIndexOffset: number; +} + +const DataLinkSuggestionsList: React.FC = React.memo( + ({ activeIndex, activeIndexOffset, label, onClose, onSuggestionSelect, suggestions }) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + return ( + <> + { + return ( +
{ + onSuggestionSelect(item); + }} + title={item.documentation} + > + + {label} {item.label} + +
+ ); + }} + /> + + ); + } +); + +DataLinkSuggestionsList.displayName = 'DataLinkSuggestionsList'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx new file mode 100644 index 0000000..9ac75b3 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataLinksContextMenu } from './DataLinksContextMenu'; +import { selectors } from '@grafana/e2e-selectors'; + +const fakeAriaLabel = 'fake aria label'; +describe('DataLinksContextMenu', () => { + it('renders context menu when there are more than one data links', () => { + render( + [ + { + href: '/link1', + title: 'Link1', + target: '_blank', + origin: {}, + }, + { + href: '/link2', + title: 'Link2', + target: '_blank', + origin: {}, + }, + ]} + config={{ + links: [ + { + title: 'Link1', + url: '/link1', + }, + { + title: 'Link2', + url: '/link2', + }, + ], + }} + > + {() => { + return
; + }} + + ); + + expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument(); + expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0); + }); + + it('renders link when there is a single data link', () => { + render( + [ + { + href: '/link1', + title: 'Link1', + target: '_blank', + origin: {}, + }, + ]} + config={{ + links: [ + { + title: 'Link1', + url: '/link1', + }, + ], + }} + > + {() => { + return
; + }} + + ); + + expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument(); + expect(screen.getByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx new file mode 100644 index 0000000..7252dc9 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { FieldConfig, LinkModel } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { css } from 'emotion'; +import { WithContextMenu } from '../ContextMenu/WithContextMenu'; +import { linkModelToContextMenuItems } from '../../utils/dataLinks'; + +interface DataLinksContextMenuProps { + children: (props: DataLinksContextMenuApi) => JSX.Element; + links: () => LinkModel[]; + config: FieldConfig; +} + +export interface DataLinksContextMenuApi { + openMenu?: React.MouseEventHandler; + targetClassName?: string; +} + +export const DataLinksContextMenu: React.FC = ({ children, links, config }) => { + const linksCounter = config.links!.length; + const getDataLinksContextMenuItems = () => { + return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; + }; + + // Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization + const targetClassName = css` + cursor: context-menu; + `; + + if (linksCounter > 1) { + return ( + + {({ openMenu }) => { + return children({ openMenu, targetClassName }); + }} + + ); + } else { + const linkModel = links()[0]; + return ( + + {children({})} + + ); + } +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx new file mode 100644 index 0000000..b6818c2 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinkEditorModalContent.tsx @@ -0,0 +1,49 @@ +import { DataFrame, DataLink, VariableSuggestion } from '@grafana/data'; +import React, { FC, useState } from 'react'; +import { DataLinkEditor } from '../DataLinkEditor'; +import { HorizontalGroup } from '../../Layout/Layout'; +import { Button } from '../../Button'; + +interface DataLinkEditorModalContentProps { + link: DataLink; + index: number; + data: DataFrame[]; + suggestions: VariableSuggestion[]; + onSave: (index: number, ink: DataLink) => void; + onCancel: (index: number) => void; +} + +export const DataLinkEditorModalContent: FC = ({ + link, + index, + suggestions, + onSave, + onCancel, +}) => { + const [dirtyLink, setDirtyLink] = useState(link); + return ( + <> + { + setDirtyLink(link); + }} + /> + + + + + + ); +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx new file mode 100644 index 0000000..bad8b7a --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx @@ -0,0 +1,116 @@ +import { DataFrame, DataLink, GrafanaTheme, VariableSuggestion } from '@grafana/data'; +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { Button } from '../../Button/Button'; +import cloneDeep from 'lodash/cloneDeep'; +import { Modal } from '../../Modal/Modal'; +import { stylesFactory, useTheme } from '../../../themes'; +import { DataLinksListItem } from './DataLinksListItem'; +import { DataLinkEditorModalContent } from './DataLinkEditorModalContent'; + +interface DataLinksInlineEditorProps { + links?: DataLink[]; + onChange: (links: DataLink[]) => void; + suggestions: VariableSuggestion[]; + data: DataFrame[]; +} + +export const DataLinksInlineEditor: React.FC = ({ links, onChange, suggestions, data }) => { + const theme = useTheme(); + const [editIndex, setEditIndex] = useState(null); + const [isNew, setIsNew] = useState(false); + + const styles = getDataLinksInlineEditorStyles(theme); + const linksSafe: DataLink[] = links ?? []; + const isEditing = editIndex !== null; + + const onDataLinkChange = (index: number, link: DataLink) => { + if (isNew) { + if (link.title.trim() === '' && link.url.trim() === '') { + setIsNew(false); + setEditIndex(null); + return; + } else { + setEditIndex(null); + setIsNew(false); + } + } + const update = cloneDeep(linksSafe); + update[index] = link; + onChange(update); + setEditIndex(null); + }; + + const onDataLinkAdd = () => { + let update = cloneDeep(linksSafe); + setEditIndex(update.length); + setIsNew(true); + }; + + const onDataLinkCancel = (index: number) => { + if (isNew) { + setIsNew(false); + } + setEditIndex(null); + }; + + const onDataLinkRemove = (index: number) => { + const update = cloneDeep(linksSafe); + update.splice(index, 1); + onChange(update); + }; + + return ( + <> + {linksSafe.length > 0 && ( +
+ {linksSafe.map((l, i) => { + return ( + setEditIndex(i)} + onRemove={() => onDataLinkRemove(i)} + data={data} + suggestions={suggestions} + /> + ); + })} +
+ )} + + {isEditing && editIndex !== null && ( + { + onDataLinkCancel(editIndex); + }} + > + + + )} + + + + ); +}; + +const getDataLinksInlineEditorStyles = stylesFactory((theme: GrafanaTheme) => { + return { + wrapper: css` + margin-bottom: ${theme.spacing.md}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx new file mode 100644 index 0000000..f8df089 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataLinksListItem, DataLinksListItemProps } from './DataLinksListItem'; + +const baseLink = { + url: '', + title: '', + onBuildUrl: jest.fn(), + onClick: jest.fn(), +}; + +function setupTestContext(options: Partial) { + const defaults: DataLinksListItemProps = { + index: 0, + link: baseLink, + data: [], + onChange: jest.fn(), + onEdit: jest.fn(), + onRemove: jest.fn(), + suggestions: [], + }; + + const props = { ...defaults, ...options }; + const { rerender } = render(); + + return { rerender, props }; +} + +describe('DataLinksListItem', () => { + describe('when link has title', () => { + it('then the link title should be visible', () => { + const link = { + ...baseLink, + title: 'Some Data Link Title', + }; + setupTestContext({ link }); + + expect(screen.getByText(/some data link title/i)).toBeInTheDocument(); + }); + }); + + describe('when link has url', () => { + it('then the link url should be visible', () => { + const link = { + ...baseLink, + url: 'http://localhost:3000', + }; + setupTestContext({ link }); + + expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing title', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing url', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); + + describe('when link title is empty', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link url is empty', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx new file mode 100644 index 0000000..f3fdb13 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx @@ -0,0 +1,73 @@ +import React, { FC } from 'react'; +import { css, cx } from 'emotion'; +import { DataFrame, DataLink, GrafanaTheme, VariableSuggestion } from '@grafana/data'; +import { stylesFactory, useTheme } from '../../../themes'; +import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout'; +import { IconButton } from '../../IconButton/IconButton'; + +export interface DataLinksListItemProps { + index: number; + link: DataLink; + data: DataFrame[]; + onChange: (index: number, link: DataLink) => void; + onEdit: () => void; + onRemove: () => void; + suggestions: VariableSuggestion[]; + isEditing?: boolean; +} + +export const DataLinksListItem: FC = ({ link, onEdit, onRemove }) => { + const theme = useTheme(); + const styles = getDataLinkListItemStyles(theme); + const { title = '', url = '' } = link; + + const hasTitle = title.trim() !== ''; + const hasUrl = url.trim() !== ''; + + return ( +
+ + +
+ {hasTitle ? title : 'Data link title not provided'} +
+ + + + +
+
+ {hasUrl ? url : 'Data link url not provided'} +
+
+
+ ); +}; + +const getDataLinkListItemStyles = stylesFactory((theme: GrafanaTheme) => { + return { + wrapper: css` + margin-bottom: ${theme.spacing.md}; + width: 100%; + &:last-child { + margin-bottom: 0; + } + `, + notConfigured: css` + font-style: italic; + `, + title: css` + color: ${theme.colors.formLabel}; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.semibold}; + `, + url: css` + color: ${theme.colors.textWeak}; + font-size: ${theme.typography.size.sm}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90%; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx new file mode 100644 index 0000000..1822820 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx @@ -0,0 +1,71 @@ +import { Field, GrafanaTheme, LinkModel } from '@grafana/data'; +import { css } from 'emotion'; +import React from 'react'; +import { useStyles } from '../../themes'; +import { Icon } from '../Icon/Icon'; +import { DataLinkButton } from './DataLinkButton'; + +type Props = { + links: Array>; +}; + +/** + * @internal + */ +export function FieldLinkList({ links }: Props) { + const styles = useStyles(getStyles); + + if (links.length === 1) { + return ; + } + + const externalLinks = links.filter((link) => link.target === '_blank'); + const internalLinks = links.filter((link) => link.target === '_self'); + + return ( + <> + {internalLinks.map((link, i) => { + return ; + })} +
+

External links

+ {externalLinks.map((link, i) => ( + + + {link.title} + + ))} +
+ + ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + flex-basis: 150px; + width: 100px; + margin-top: ${theme.spacing.sm}; + `, + externalLinksHeading: css` + color: ${theme.colors.textWeak}; + font-weight: ${theme.typography.weight.regular}; + font-size: ${theme.typography.size.sm}; + margin: 0; + `, + externalLink: css` + color: ${theme.colors.linkExternal}; + font-weight: ${theme.typography.weight.regular}; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + + div { + margin-right: ${theme.spacing.sm}; + } + `, +}); diff --git a/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts new file mode 100644 index 0000000..fa6964f --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/SelectionReference.ts @@ -0,0 +1,28 @@ +export class SelectionReference { + getBoundingClientRect() { + const selection = window.getSelection(); + const node = selection && selection.anchorNode; + + if (node && node.parentElement) { + const rect = node.parentElement.getBoundingClientRect(); + return rect; + } + + return { + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }; + } + + get clientWidth() { + return this.getBoundingClientRect().width; + } + + get clientHeight() { + return this.getBoundingClientRect().height; + } +} diff --git a/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx new file mode 100644 index 0000000..a227f04 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/BasicAuthSettings.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { HttpSettingsProps } from './types'; +import { FormField } from '../FormField/FormField'; +import { SecretFormField } from '../SecretFormField/SecretFormField'; + +export const BasicAuthSettings: React.FC = ({ dataSourceConfig, onChange }) => { + const password = dataSourceConfig.secureJsonData ? dataSourceConfig.secureJsonData.basicAuthPassword : ''; + + const onPasswordReset = () => { + onChange({ + ...dataSourceConfig, + basicAuthPassword: '', + secureJsonData: { + ...dataSourceConfig.secureJsonData, + basicAuthPassword: '', + }, + secureJsonFields: { + ...dataSourceConfig.secureJsonFields, + basicAuthPassword: false, + }, + }); + }; + + const onPasswordChange = (event: React.SyntheticEvent) => { + onChange({ + ...dataSourceConfig, + secureJsonData: { + ...dataSourceConfig.secureJsonData, + basicAuthPassword: event.currentTarget.value, + }, + }); + }; + + return ( + <> +
+ onChange({ ...dataSourceConfig, basicAuthUser: event.currentTarget.value })} + /> +
+
+ +
+ + ); +}; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx new file mode 100644 index 0000000..5ed9a27 --- /dev/null +++ b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx @@ -0,0 +1,40 @@ +import React, { ChangeEvent, MouseEvent, FC } from 'react'; + +interface Props { + label: string; + hasCert: boolean; + placeholder: string; + + onChange: (event: ChangeEvent) => void; + onClick: (event: MouseEvent) => void; +} + +export const CertificationKey: FC = ({ hasCert, label, onChange, onClick, placeholder }) => { + return ( +
+
+ +
+ {!hasCert && ( +
+ + + If you want to apply templating to the alert rule name, you must use the following syntax - ${Label} + +
+
+ Tags +
+
+ + + +
+
+
+ + +
+
+ +
+
+
diff --git a/public/app/features/alerting/state/ThresholdMapper.test.ts b/public/app/features/alerting/state/ThresholdMapper.test.ts new file mode 100644 index 0000000..df70c65 --- /dev/null +++ b/public/app/features/alerting/state/ThresholdMapper.test.ts @@ -0,0 +1,138 @@ +import { hiddenReducerTypes, ThresholdMapper } from './ThresholdMapper'; +import alertDef from './alertDef'; + +const visibleReducerTypes = alertDef.reducerTypes + .filter(({ value }) => hiddenReducerTypes.indexOf(value) === -1) + .map(({ value }) => value); + +describe('ThresholdMapper', () => { + describe('with greater than evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'gt', params: [100] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('gt'); + expect(panel.thresholds[0].value).toBe(100); + }); + }); + + describe('with outside range evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'outside_range', params: [100, 200] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('lt'); + expect(panel.thresholds[0].value).toBe(100); + + expect(panel.thresholds[1].op).toBe('gt'); + expect(panel.thresholds[1].value).toBe(200); + }); + }); + + describe('with inside range evaluator', () => { + it('can map query conditions to thresholds', () => { + const panel: any = { + type: 'graph', + options: { alertThresholds: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'within_range', params: [100, 200] }, + }, + ], + }, + }; + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + expect(updated).toBe(true); + expect(panel.thresholds[0].op).toBe('gt'); + expect(panel.thresholds[0].value).toBe(100); + + expect(panel.thresholds[1].op).toBe('lt'); + expect(panel.thresholds[1].value).toBe(200); + }); + }); + + visibleReducerTypes.forEach((type) => { + describe(`with {${type}} reducer`, () => { + it('visible should be true', () => { + const panel = getPanel({ reducerType: type }); + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + + expect(updated).toBe(true); + expect(panel.thresholds[0]).toEqual({ + value: 100, + op: 'gt', + fill: true, + line: true, + colorMode: 'critical', + visible: true, + }); + }); + }); + }); + + hiddenReducerTypes.forEach((type) => { + describe(`with {${type}} reducer`, () => { + it('visible should be false', () => { + const panel = getPanel({ reducerType: type }); + + const updated = ThresholdMapper.alertToGraphThresholds(panel); + + expect(updated).toBe(true); + expect(panel.thresholds[0]).toEqual({ + value: 100, + op: 'gt', + fill: true, + line: true, + colorMode: 'critical', + visible: false, + }); + }); + }); + }); +}); + +function getPanel({ reducerType }: { reducerType?: string } = {}) { + const panel: any = { + type: 'graph', + options: { alertThreshold: true }, + alert: { + conditions: [ + { + type: 'query', + evaluator: { type: 'gt', params: [100] }, + reducer: { type: reducerType }, + }, + ], + }, + }; + + return panel; +} diff --git a/public/app/features/alerting/state/ThresholdMapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts new file mode 100644 index 0000000..e382919 --- /dev/null +++ b/public/app/features/alerting/state/ThresholdMapper.ts @@ -0,0 +1,71 @@ +import { PanelModel } from 'app/features/dashboard/state'; + +export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs']; +export class ThresholdMapper { + static alertToGraphThresholds(panel: PanelModel) { + if (!panel.alert) { + return false; // no update when no alerts + } + + for (let i = 0; i < panel.alert.conditions.length; i++) { + const condition = panel.alert.conditions[i]; + if (condition.type !== 'query') { + continue; + } + + const evaluator = condition.evaluator; + const thresholds: any[] = (panel.thresholds = []); + const visible = hiddenReducerTypes.indexOf(condition.reducer?.type) === -1; + + switch (evaluator.type) { + case 'gt': { + const value = evaluator.params[0]; + thresholds.push({ value: value, op: 'gt', visible }); + break; + } + case 'lt': { + const value = evaluator.params[0]; + thresholds.push({ value: value, op: 'lt', visible }); + break; + } + case 'outside_range': { + const value1 = evaluator.params[0]; + const value2 = evaluator.params[1]; + + if (value1 > value2) { + thresholds.push({ value: value1, op: 'gt', visible }); + thresholds.push({ value: value2, op: 'lt', visible }); + } else { + thresholds.push({ value: value1, op: 'lt', visible }); + thresholds.push({ value: value2, op: 'gt', visible }); + } + + break; + } + case 'within_range': { + const value1 = evaluator.params[0]; + const value2 = evaluator.params[1]; + + if (value1 > value2) { + thresholds.push({ value: value1, op: 'lt', visible }); + thresholds.push({ value: value2, op: 'gt', visible }); + } else { + thresholds.push({ value: value1, op: 'gt', visible }); + thresholds.push({ value: value2, op: 'lt', visible }); + } + break; + } + } + break; + } + + for (const t of panel.thresholds) { + t.fill = panel.options.alertThreshold; + t.line = panel.options.alertThreshold; + t.colorMode = 'critical'; + } + + const updated = true; + return updated; + } +} diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts new file mode 100644 index 0000000..4477031 --- /dev/null +++ b/public/app/features/alerting/state/actions.ts @@ -0,0 +1,284 @@ +import { + AppEvents, + applyFieldOverrides, + arrowTableToDataFrame, + base64StringToArrowTable, + DataSourceApi, + dateMath, +} from '@grafana/data'; +import { config, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { appEvents } from 'app/core/core'; +import { updateLocation } from 'app/core/actions'; +import store from 'app/core/store'; +import { + ALERT_DEFINITION_UI_STATE_STORAGE_KEY, + cleanUpState, + loadAlertRules, + loadedAlertRules, + notificationChannelLoaded, + setAlertDefinition, + setAlertDefinitions, + setInstanceData, + setNotificationChannels, + setQueryOptions, + setUiState, + updateAlertDefinitionOptions, +} from './reducers'; +import { + AlertDefinition, + AlertDefinitionState, + AlertDefinitionUiState, + AlertRuleDTO, + NotifierDTO, + QueryGroupDataSource, + QueryGroupOptions, + ThunkResult, +} from 'app/types'; +import { ExpressionDatasourceID } from '../../expressions/ExpressionDatasource'; +import { ExpressionQuery } from '../../expressions/types'; + +export function getAlertRulesAsync(options: { state: string }): ThunkResult { + return async (dispatch) => { + dispatch(loadAlertRules()); + const rules: AlertRuleDTO[] = await getBackendSrv().get('/api/alerts', options); + + if (config.featureToggles.ngalert) { + const ngAlertDefinitions = await getBackendSrv().get('/api/alert-definitions'); + dispatch(setAlertDefinitions(ngAlertDefinitions.results)); + } + + dispatch(loadedAlertRules(rules)); + }; +} + +export function togglePauseAlertRule(id: number, options: { paused: boolean }): ThunkResult { + return async (dispatch, getState) => { + await getBackendSrv().post(`/api/alerts/${id}/pause`, options); + const stateFilter = getState().location.query.state || 'all'; + dispatch(getAlertRulesAsync({ state: stateFilter.toString() })); + }; +} + +export function createNotificationChannel(data: any): ThunkResult { + return async (dispatch) => { + try { + await getBackendSrv().post(`/api/alert-notifications`, data); + appEvents.emit(AppEvents.alertSuccess, ['Notification created']); + dispatch(updateLocation({ path: 'alerting/notifications' })); + } catch (error) { + appEvents.emit(AppEvents.alertError, [error.data.error]); + } + }; +} + +export function updateNotificationChannel(data: any): ThunkResult { + return async (dispatch) => { + try { + await getBackendSrv().put(`/api/alert-notifications/${data.id}`, data); + appEvents.emit(AppEvents.alertSuccess, ['Notification updated']); + dispatch(updateLocation({ path: 'alerting/notifications' })); + } catch (error) { + appEvents.emit(AppEvents.alertError, [error.data.error]); + } + }; +} + +export function testNotificationChannel(data: any): ThunkResult { + return async (dispatch, getState) => { + const channel = getState().notificationChannel.notificationChannel; + await getBackendSrv().post('/api/alert-notifications/test', { id: channel.id, ...data }); + }; +} + +export function loadNotificationTypes(): ThunkResult { + return async (dispatch) => { + const alertNotifiers: NotifierDTO[] = await getBackendSrv().get(`/api/alert-notifiers`); + + const notificationTypes = alertNotifiers.sort((o1, o2) => { + if (o1.name > o2.name) { + return 1; + } + return -1; + }); + + dispatch(setNotificationChannels(notificationTypes)); + }; +} + +export function loadNotificationChannel(id: number): ThunkResult { + return async (dispatch) => { + await dispatch(loadNotificationTypes()); + const notificationChannel = await getBackendSrv().get(`/api/alert-notifications/${id}`); + dispatch(notificationChannelLoaded(notificationChannel)); + }; +} + +export function getAlertDefinition(id: string): ThunkResult { + return async (dispatch) => { + const alertDefinition = await getBackendSrv().get(`/api/alert-definitions/${id}`); + dispatch(setAlertDefinition(alertDefinition)); + }; +} + +export function createAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const alertDefinition = await buildAlertDefinition(getStore().alertDefinition); + + await getBackendSrv().post(`/api/alert-definitions`, alertDefinition); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition created']); + dispatch(updateLocation({ path: 'alerting/list' })); + }; +} + +export function updateAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const alertDefinition = await buildAlertDefinition(getStore().alertDefinition); + + const updatedAlertDefinition = await getBackendSrv().put( + `/api/alert-definitions/${alertDefinition.uid}`, + alertDefinition + ); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition updated']); + dispatch(setAlertDefinition(updatedAlertDefinition)); + }; +} + +export function updateAlertDefinitionUiState(uiState: Partial): ThunkResult { + return (dispatch, getStore) => { + const nextState = { ...getStore().alertDefinition.uiState, ...uiState }; + dispatch(setUiState(nextState)); + + try { + store.setObject(ALERT_DEFINITION_UI_STATE_STORAGE_KEY, nextState); + } catch (error) { + console.error(error); + } + }; +} + +export function updateAlertDefinitionOption(alertDefinition: Partial): ThunkResult { + return (dispatch) => { + dispatch(updateAlertDefinitionOptions(alertDefinition)); + }; +} + +export function queryOptionsChange(queryOptions: QueryGroupOptions): ThunkResult { + return (dispatch) => { + dispatch(setQueryOptions(queryOptions)); + }; +} + +export function onRunQueries(): ThunkResult { + return (dispatch, getStore) => { + const { queryRunner, getQueryOptions } = getStore().alertDefinition; + const timeRange = { from: 'now-1h', to: 'now' }; + const queryOptions = getQueryOptions(); + + queryRunner!.run({ + // if the queryRunner is undefined here somethings very wrong so it's ok to throw an unhandled error + timezone: 'browser', + timeRange: { from: dateMath.parse(timeRange.from)!, to: dateMath.parse(timeRange.to)!, raw: timeRange }, + maxDataPoints: queryOptions.maxDataPoints ?? 100, + minInterval: queryOptions.minInterval, + queries: queryOptions.queries, + datasource: queryOptions.dataSource.name!, + }); + }; +} + +export function evaluateAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const { alertDefinition } = getStore().alertDefinition; + + const response: { instances: string[] } = await getBackendSrv().get( + `/api/alert-definitions/eval/${alertDefinition.uid}` + ); + + const handledResponse = handleBase64Response(response.instances); + + dispatch(setInstanceData(handledResponse)); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition tested successfully']); + }; +} + +export function evaluateNotSavedAlertDefinition(): ThunkResult { + return async (dispatch, getStore) => { + const { alertDefinition, getQueryOptions } = getStore().alertDefinition; + const defaultDataSource = await getDataSourceSrv().get(null); + + const response: { instances: string[] } = await getBackendSrv().post('/api/alert-definitions/eval', { + condition: alertDefinition.condition, + data: buildDataQueryModel(getQueryOptions(), defaultDataSource), + }); + + const handledResponse = handleBase64Response(response.instances); + dispatch(setInstanceData(handledResponse)); + appEvents.emit(AppEvents.alertSuccess, ['Alert definition tested successfully']); + }; +} + +export function cleanUpDefinitionState(): ThunkResult { + return (dispatch) => { + dispatch(cleanUpState(undefined)); + }; +} + +async function buildAlertDefinition(state: AlertDefinitionState) { + const queryOptions = state.getQueryOptions(); + const currentAlertDefinition = state.alertDefinition; + const defaultDataSource = await getDataSourceSrv().get(null); + + return { + ...currentAlertDefinition, + data: buildDataQueryModel(queryOptions, defaultDataSource), + }; +} + +function handleBase64Response(frames: string[]) { + const dataFrames = frames.map((instance) => { + const table = base64StringToArrowTable(instance); + return arrowTableToDataFrame(table); + }); + + return applyFieldOverrides({ + data: dataFrames, + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (value: any) => value, + theme: config.theme, + }); +} + +function buildDataQueryModel(queryOptions: QueryGroupOptions, defaultDataSource: DataSourceApi) { + return queryOptions.queries.map((query) => { + let dataSource: QueryGroupDataSource; + const isExpression = query.datasource === ExpressionDatasourceID; + + if (isExpression) { + dataSource = { name: ExpressionDatasourceID, uid: ExpressionDatasourceID }; + } else { + const dataSourceSetting = getDataSourceSrv().getInstanceSettings(query.datasource); + + dataSource = { + name: dataSourceSetting?.name ?? defaultDataSource.name, + uid: dataSourceSetting?.uid ?? defaultDataSource.uid, + }; + } + + return { + model: { + ...query, + type: isExpression ? (query as ExpressionQuery).type : query.queryType, + datasource: dataSource.name, + datasourceUid: dataSource.uid, + }, + refId: query.refId, + relativeTimeRange: { + From: 500, + To: 0, + }, + }; + }); +} diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts new file mode 100644 index 0000000..489ad3b --- /dev/null +++ b/public/app/features/alerting/state/alertDef.ts @@ -0,0 +1,171 @@ +import _ from 'lodash'; +import { QueryPartDef, QueryPart } from 'app/core/components/query_part/query_part'; + +const alertQueryDef = new QueryPartDef({ + type: 'query', + params: [ + { name: 'queryRefId', type: 'string', dynamicLookup: true }, + { + name: 'from', + type: 'string', + options: ['10s', '1m', '5m', '10m', '15m', '1h', '2h', '6h', '12h', '24h', '48h'], + }, + { name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] }, + ], + defaultParams: ['#A', '15m', 'now', 'avg'], +}); + +const conditionTypes = [{ text: 'Query', value: 'query' }]; + +const alertStateSortScore = { + alerting: 1, + no_data: 2, + pending: 3, + ok: 4, + paused: 5, +}; + +const evalFunctions = [ + { text: 'IS ABOVE', value: 'gt' }, + { text: 'IS BELOW', value: 'lt' }, + { text: 'IS OUTSIDE RANGE', value: 'outside_range' }, + { text: 'IS WITHIN RANGE', value: 'within_range' }, + { text: 'HAS NO VALUE', value: 'no_value' }, +]; + +const evalOperators = [ + { text: 'OR', value: 'or' }, + { text: 'AND', value: 'and' }, +]; + +const reducerTypes = [ + { text: 'avg()', value: 'avg' }, + { text: 'min()', value: 'min' }, + { text: 'max()', value: 'max' }, + { text: 'sum()', value: 'sum' }, + { text: 'count()', value: 'count' }, + { text: 'last()', value: 'last' }, + { text: 'median()', value: 'median' }, + { text: 'diff()', value: 'diff' }, + { text: 'diff_abs()', value: 'diff_abs' }, + { text: 'percent_diff()', value: 'percent_diff' }, + { text: 'percent_diff_abs()', value: 'percent_diff_abs' }, + { text: 'count_non_null()', value: 'count_non_null' }, +]; + +const noDataModes = [ + { text: 'Alerting', value: 'alerting' }, + { text: 'No Data', value: 'no_data' }, + { text: 'Keep Last State', value: 'keep_state' }, + { text: 'Ok', value: 'ok' }, +]; + +const executionErrorModes = [ + { text: 'Alerting', value: 'alerting' }, + { text: 'Keep Last State', value: 'keep_state' }, +]; + +function createReducerPart(model: any) { + const def = new QueryPartDef({ type: model.type, defaultParams: [] }); + return new QueryPart(model, def); +} + +function getStateDisplayModel(state: string) { + switch (state) { + case 'ok': { + return { + text: 'OK', + iconClass: 'heart', + stateClass: 'alert-state-ok', + }; + } + case 'alerting': { + return { + text: 'ALERTING', + iconClass: 'heart-break', + stateClass: 'alert-state-critical', + }; + } + case 'no_data': { + return { + text: 'NO DATA', + iconClass: 'question-circle', + stateClass: 'alert-state-warning', + }; + } + case 'paused': { + return { + text: 'PAUSED', + iconClass: 'pause', + stateClass: 'alert-state-paused', + }; + } + case 'pending': { + return { + text: 'PENDING', + iconClass: 'exclamation-triangle', + stateClass: 'alert-state-warning', + }; + } + case 'unknown': { + return { + text: 'UNKNOWN', + iconClass: 'question-circle', + stateClass: 'alert-state-paused', + }; + } + } + + throw { message: 'Unknown alert state' }; +} + +function joinEvalMatches(matches: any, separator: string) { + return _.reduce( + matches, + (res, ev) => { + if (ev.metric !== undefined && ev.value !== undefined) { + res.push(ev.metric + '=' + ev.value); + } + + // For backwards compatibility . Should be be able to remove this after ~2017-06-01 + if (ev.Metric !== undefined && ev.Value !== undefined) { + res.push(ev.Metric + '=' + ev.Value); + } + + return res; + }, + [] as string[] + ).join(separator); +} + +function getAlertAnnotationInfo(ah: any) { + // backward compatibility, can be removed in grafana 5.x + // old way stored evalMatches in data property directly, + // new way stores it in evalMatches property on new data object + + if (_.isArray(ah.data)) { + return joinEvalMatches(ah.data, ', '); + } else if (_.isArray(ah.data.evalMatches)) { + return joinEvalMatches(ah.data.evalMatches, ', '); + } + + if (ah.data.error) { + return 'Error: ' + ah.data.error; + } + + return ''; +} + +export default { + alertQueryDef: alertQueryDef, + getStateDisplayModel: getStateDisplayModel, + conditionTypes: conditionTypes, + evalFunctions: evalFunctions, + evalOperators: evalOperators, + noDataModes: noDataModes, + executionErrorModes: executionErrorModes, + reducerTypes: reducerTypes, + createReducerPart: createReducerPart, + getAlertAnnotationInfo: getAlertAnnotationInfo, + alertStateSortScore: alertStateSortScore, +}; diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts new file mode 100644 index 0000000..13b9acc --- /dev/null +++ b/public/app/features/alerting/state/reducers.test.ts @@ -0,0 +1,423 @@ +import { dateTime } from '@grafana/data'; +import { + alertRulesReducer, + initialChannelState, + initialState, + loadAlertRules, + loadedAlertRules, + notificationChannelReducer, + setSearchQuery, + notificationChannelLoaded, +} from './reducers'; +import { AlertRuleDTO, AlertRulesState, NotificationChannelState, NotifierDTO } from 'app/types'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; + +describe('Alert rules', () => { + const realDateNow = Date.now.bind(global.Date); + const anchorUnix = dateTime('2019-09-04T10:01:01+02:00').valueOf(); + const dateNowStub = jest.fn(() => anchorUnix); + global.Date.now = dateNowStub; + + const newStateDate = dateTime().subtract(1, 'y'); + const newStateDateFormatted = newStateDate.format('YYYY-MM-DD'); + const newStateDateAge = newStateDate.fromNow(true); + const payload: AlertRuleDTO[] = [ + { + id: 2, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 4, + name: 'TestData - Always Alerting', + state: 'alerting', + newStateDate: `${newStateDateFormatted}T10:00:30+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: { evalMatches: [{ metric: 'A-series', tags: null, value: 215 }] }, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 1, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Always OK', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 3, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - ok', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 4, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Paused', + state: 'paused', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 5, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Ok', + state: 'ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + evalDate: '0001-01-01T00:00:00Z', + evalData: { + noData: true, + }, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ]; + + afterAll(() => { + global.Date.now = realDateNow; + }); + + describe('when loadAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(loadAlertRules()) + .thenStateShouldEqual({ ...initialState, isLoading: true }); + }); + }); + + describe('when setSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState }) + .whenActionIsDispatched(setSearchQuery('query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'query' }); + }); + }); + + describe('when loadedAlertRules is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(alertRulesReducer, { ...initialState, isLoading: true }) + .whenActionIsDispatched(loadedAlertRules(payload)) + .thenStateShouldEqual({ + ...initialState, + isLoading: false, + items: [ + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + evalMatches: [ + { + metric: 'A-series', + tags: null, + value: 215, + }, + ], + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 2, + name: 'TestData - Always Alerting', + newStateDate: `${newStateDateFormatted}T10:00:30+02:00`, + panelId: 4, + state: 'alerting', + stateAge: newStateDateAge, + stateClass: 'alert-state-critical', + stateIcon: 'heart-break', + stateText: 'ALERTING', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: '', + id: 1, + name: 'TestData - Always OK', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 3, + info: 'Execution Error: error', + name: 'TestData - ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: {}, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 4, + name: 'TestData - Paused', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'paused', + stateAge: newStateDateAge, + stateClass: 'alert-state-paused', + stateIcon: 'pause', + stateText: 'PAUSED', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + dashboardId: 7, + dashboardSlug: 'alerting-with-testdata', + dashboardUid: 'ggHbN42mk', + evalData: { + noData: true, + }, + evalDate: '0001-01-01T00:00:00Z', + executionError: 'error', + id: 5, + info: 'Query returned no data', + name: 'TestData - Ok', + newStateDate: `${newStateDateFormatted}T10:01:01+02:00`, + panelId: 3, + state: 'ok', + stateAge: newStateDateAge, + stateClass: 'alert-state-ok', + stateIcon: 'heart', + stateText: 'OK', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ], + }); + }); + }); +}); + +describe('Notification channel', () => { + const notifiers: NotifierDTO[] = [ + { + type: 'webhook', + name: 'webhook', + heading: 'Webhook settings', + description: 'Sends HTTP POST request to a URL', + info: '', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Url', + description: '', + placeholder: '', + propertyName: 'url', + showWhen: { field: '', is: '' }, + required: true, + validationRule: '', + secure: false, + }, + { + element: 'select', + inputType: '', + label: 'Http Method', + description: '', + placeholder: '', + propertyName: 'httpMethod', + selectOptions: [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + ], + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'text', + label: 'Username', + description: '', + placeholder: '', + propertyName: 'username', + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'password', + label: 'Password', + description: '', + placeholder: '', + propertyName: 'password', + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: true, + }, + ], + }, + ]; + + describe('Load notification channel', () => { + it('should migrate non secure settings to secure fields', () => { + const payload = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + settings: { + autoResolve: true, + httpMethod: 'POST', + password: 'asdf', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + const expected = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureSettings: { + password: 'asdf', + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + reducerTester() + .givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers }) + .whenActionIsDispatched(notificationChannelLoaded(payload)) + .thenStateShouldEqual({ + ...initialChannelState, + notifiers: notifiers, + notificationChannel: expected, + }); + }); + + it('should handle already secure field', () => { + const payload = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureFields: { + password: true, + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + const expected = { + id: 2, + uid: '9L3FrrHGk', + name: 'Webhook test', + type: 'webhook', + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T08:49:24Z', + updated: '2020-08-28T08:49:24Z', + secureFields: { + password: true, + }, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://localhost.webhook', + username: 'asdf', + }, + }; + + reducerTester() + .givenReducer(notificationChannelReducer, { ...initialChannelState, notifiers: notifiers }) + .whenActionIsDispatched(notificationChannelLoaded(payload)) + .thenStateShouldEqual({ + ...initialChannelState, + notifiers: notifiers, + notificationChannel: expected, + }); + }); + }); +}); diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts new file mode 100644 index 0000000..36ca8e9 --- /dev/null +++ b/public/app/features/alerting/state/reducers.ts @@ -0,0 +1,280 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ApplyFieldOverrideOptions, DataFrame, DataTransformerConfig, dateTime, FieldColorModeId } from '@grafana/data'; +import alertDef from './alertDef'; +import { + AlertDefinition, + AlertDefinitionDTO, + AlertDefinitionQueryModel, + AlertDefinitionState, + AlertDefinitionUiState, + AlertRule, + AlertRuleDTO, + AlertRulesState, + NotificationChannelOption, + NotificationChannelState, + NotifierDTO, + QueryGroupOptions, +} from 'app/types'; +import store from 'app/core/store'; +import { config } from '@grafana/runtime'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; + +export const ALERT_DEFINITION_UI_STATE_STORAGE_KEY = 'grafana.alerting.alertDefinition.ui'; +const DEFAULT_ALERT_DEFINITION_UI_STATE: AlertDefinitionUiState = { rightPaneSize: 400, topPaneSize: 0.45 }; + +export const initialState: AlertRulesState = { + items: [], + searchQuery: '', + isLoading: false, +}; + +export const initialChannelState: NotificationChannelState = { + notificationChannelTypes: [], + notificationChannel: {}, + notifiers: [], +}; + +const options: ApplyFieldOverrideOptions = { + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + overrides: [], + }, + replaceVariables: (v: string) => v, + theme: config.theme, +}; + +const dataConfig = { + getTransformations: () => [] as DataTransformerConfig[], + getFieldOverrideOptions: () => options, +}; + +export const initialAlertDefinitionState: AlertDefinitionState = { + alertDefinition: { + id: 0, + uid: '', + title: '', + description: '', + condition: '', + data: [], + intervalSeconds: 60, + }, + queryRunner: new PanelQueryRunner(dataConfig), + uiState: { ...store.getObject(ALERT_DEFINITION_UI_STATE_STORAGE_KEY, DEFAULT_ALERT_DEFINITION_UI_STATE) }, + data: [], + alertDefinitions: [] as AlertDefinition[], + /* These are functions as they are mutated later on and redux toolkit will Object.freeze state so + * we need to store these using functions instead */ + getInstances: () => [] as DataFrame[], + getQueryOptions: () => ({ maxDataPoints: 100, dataSource: { name: '-- Mixed --' }, queries: [] }), +}; + +function convertToAlertRule(dto: AlertRuleDTO, state: string): AlertRule { + const stateModel = alertDef.getStateDisplayModel(state); + + const rule: AlertRule = { + ...dto, + stateText: stateModel.text, + stateIcon: stateModel.iconClass, + stateClass: stateModel.stateClass, + stateAge: dateTime(dto.newStateDate).fromNow(true), + }; + + if (rule.state !== 'paused') { + if (rule.executionError) { + rule.info = 'Execution Error: ' + rule.executionError; + } + if (rule.evalData && rule.evalData.noData) { + rule.info = 'Query returned no data'; + } + } + + return rule; +} + +const alertRulesSlice = createSlice({ + name: 'alertRules', + initialState, + reducers: { + loadAlertRules: (state) => { + return { ...state, isLoading: true }; + }, + loadedAlertRules: (state, action: PayloadAction): AlertRulesState => { + const alertRules: AlertRuleDTO[] = action.payload; + + const alertRulesViewModel: AlertRule[] = alertRules.map((rule) => { + return convertToAlertRule(rule, rule.state); + }); + + return { ...state, items: alertRulesViewModel, isLoading: false }; + }, + setSearchQuery: (state, action: PayloadAction): AlertRulesState => { + return { ...state, searchQuery: action.payload }; + }, + }, +}); + +const notificationChannelSlice = createSlice({ + name: 'notificationChannel', + initialState: initialChannelState, + reducers: { + setNotificationChannels: (state, action: PayloadAction): NotificationChannelState => { + return { + ...state, + notificationChannelTypes: transformNotifiers(action.payload), + notifiers: action.payload, + }; + }, + notificationChannelLoaded: (state, action: PayloadAction): NotificationChannelState => { + const notificationChannel = action.payload; + const selectedType: NotifierDTO = state.notifiers.find((t) => t.type === notificationChannel.type)!; + const secureChannelOptions = selectedType.options.filter((o: NotificationChannelOption) => o.secure); + /* + If any secure field is in plain text we need to migrate it to use secure field instead. + */ + if ( + secureChannelOptions.length > 0 && + secureChannelOptions.some((o: NotificationChannelOption) => { + return notificationChannel.settings[o.propertyName] !== ''; + }) + ) { + return migrateSecureFields(state, action.payload, secureChannelOptions); + } + + return { ...state, notificationChannel: notificationChannel }; + }, + resetSecureField: (state, action: PayloadAction): NotificationChannelState => { + return { + ...state, + notificationChannel: { + ...state.notificationChannel, + secureFields: { ...state.notificationChannel.secureFields, [action.payload]: false }, + }, + }; + }, + }, +}); + +const alertDefinitionSlice = createSlice({ + name: 'alertDefinition', + initialState: initialAlertDefinitionState, + reducers: { + setAlertDefinition: (state: AlertDefinitionState, action: PayloadAction) => { + const currentOptions = state.getQueryOptions(); + + state.alertDefinition.title = action.payload.title; + state.alertDefinition.id = action.payload.id; + state.alertDefinition.uid = action.payload.uid; + state.alertDefinition.condition = action.payload.condition; + state.alertDefinition.intervalSeconds = action.payload.intervalSeconds; + state.alertDefinition.data = action.payload.data; + state.alertDefinition.description = action.payload.description; + state.getQueryOptions = () => ({ + ...currentOptions, + queries: action.payload.data.map((q: AlertDefinitionQueryModel) => ({ ...q.model })), + }); + }, + updateAlertDefinitionOptions: (state: AlertDefinitionState, action: PayloadAction>) => { + state.alertDefinition = { ...state.alertDefinition, ...action.payload }; + }, + setUiState: (state: AlertDefinitionState, action: PayloadAction) => { + state.uiState = { ...state.uiState, ...action.payload }; + }, + setQueryOptions: (state: AlertDefinitionState, action: PayloadAction) => { + state.getQueryOptions = () => action.payload; + }, + setAlertDefinitions: (state: AlertDefinitionState, action: PayloadAction) => { + state.alertDefinitions = action.payload; + }, + setInstanceData: (state: AlertDefinitionState, action: PayloadAction) => { + state.getInstances = () => action.payload; + }, + cleanUpState: (state: AlertDefinitionState, action: PayloadAction) => { + if (state.queryRunner) { + state.queryRunner.destroy(); + state.queryRunner = undefined; + delete state.queryRunner; + state.queryRunner = new PanelQueryRunner(dataConfig); + } + + state.alertDefinitions = initialAlertDefinitionState.alertDefinitions; + state.alertDefinition = initialAlertDefinitionState.alertDefinition; + state.data = initialAlertDefinitionState.data; + state.getInstances = initialAlertDefinitionState.getInstances; + state.getQueryOptions = initialAlertDefinitionState.getQueryOptions; + state.uiState = initialAlertDefinitionState.uiState; + }, + }, +}); + +export const { loadAlertRules, loadedAlertRules, setSearchQuery } = alertRulesSlice.actions; + +export const { + setNotificationChannels, + notificationChannelLoaded, + resetSecureField, +} = notificationChannelSlice.actions; + +export const { + setUiState, + updateAlertDefinitionOptions, + setQueryOptions, + setAlertDefinitions, + setAlertDefinition, + setInstanceData, + cleanUpState, +} = alertDefinitionSlice.actions; + +export const alertRulesReducer = alertRulesSlice.reducer; +export const notificationChannelReducer = notificationChannelSlice.reducer; +export const alertDefinitionsReducer = alertDefinitionSlice.reducer; + +export default { + alertRules: alertRulesReducer, + notificationChannel: notificationChannelReducer, + alertDefinition: alertDefinitionsReducer, +}; + +function migrateSecureFields( + state: NotificationChannelState, + notificationChannel: any, + secureChannelOptions: NotificationChannelOption[] +) { + const cleanedSettings: { [key: string]: string } = {}; + const secureSettings: { [key: string]: string } = {}; + + secureChannelOptions.forEach((option) => { + secureSettings[option.propertyName] = notificationChannel.settings[option.propertyName]; + cleanedSettings[option.propertyName] = ''; + }); + + return { + ...state, + notificationChannel: { + ...notificationChannel, + settings: { ...notificationChannel.settings, ...cleanedSettings }, + secureSettings: { ...secureSettings }, + }, + }; +} + +function transformNotifiers(notifiers: NotifierDTO[]) { + return notifiers + .map((option: NotifierDTO) => { + return { + value: option.type, + label: option.name, + ...option, + typeName: option.type, + }; + }) + .sort((o1, o2) => { + if (o1.name > o2.name) { + return 1; + } + return -1; + }); +} diff --git a/public/app/features/alerting/state/selectors.test.ts b/public/app/features/alerting/state/selectors.test.ts new file mode 100644 index 0000000..a38bb79 --- /dev/null +++ b/public/app/features/alerting/state/selectors.test.ts @@ -0,0 +1,98 @@ +import { getSearchQuery, getAlertRuleItems } from './selectors'; + +describe('Get search query', () => { + it('should get search query', () => { + const state = { searchQuery: 'dashboard' }; + const result = getSearchQuery(state as any); + + expect(result).toEqual(state.searchQuery); + }); +}); + +describe('Get alert rule items', () => { + it('should get alert rule items', () => { + const state = { + alertRules: { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: '', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: '', + }, + }; + + const result = getAlertRuleItems(state as any); + expect(result.length).toEqual(1); + }); + + it('should filter rule items based on search query', () => { + const state = { + alertRules: { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: 'dashboard', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 2, + dashboardId: 3, + panelId: 1, + name: 'dashboard2', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 3, + dashboardId: 5, + panelId: 1, + name: 'hello', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 4, + dashboardId: 7, + panelId: 1, + name: 'test', + state: '', + stateText: 'dashboard', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: 'dashboard', + }, + }; + + const result = getAlertRuleItems(state as any); + expect(result.length).toEqual(3); + }); +}); diff --git a/public/app/features/alerting/state/selectors.ts b/public/app/features/alerting/state/selectors.ts new file mode 100644 index 0000000..3db0c20 --- /dev/null +++ b/public/app/features/alerting/state/selectors.ts @@ -0,0 +1,33 @@ +import { AlertDefinition, AlertRule, AlertRulesState, NotificationChannelState, StoreState } from 'app/types'; +import { config } from '@grafana/runtime'; + +export const getSearchQuery = (state: AlertRulesState) => state.searchQuery; + +export const getAlertRuleItems = (state: StoreState) => { + const regex = new RegExp(state.alertRules.searchQuery, 'i'); + const result: Array = []; + + result.push( + ...state.alertRules.items.filter((item) => { + return regex.test(item.name) || regex.test(item.stateText) || regex.test(item.info!); + }) + ); + + if (config.featureToggles.ngalert) { + result.push( + ...state.alertDefinition.alertDefinitions.filter((item) => { + return regex.test(item.title); + }) + ); + } + + return result; +}; + +export const getNotificationChannel = (state: NotificationChannelState, channelId: number) => { + if (state.notificationChannel.id === channelId) { + return state.notificationChannel; + } + + return null; +}; diff --git a/public/app/features/alerting/utils/notificationChannel.test.ts b/public/app/features/alerting/utils/notificationChannel.test.ts new file mode 100644 index 0000000..1ef7042 --- /dev/null +++ b/public/app/features/alerting/utils/notificationChannel.test.ts @@ -0,0 +1,209 @@ +import { transformSubmitData } from './notificationChannels'; +import { NotificationChannelDTO } from '../../../types'; + +const basicFormData: NotificationChannelDTO = { + id: 1, + uid: 'pX7fbbHGk', + name: 'Pete discord', + type: { + value: 'discord', + label: 'Discord', + type: 'discord', + name: 'Discord', + heading: 'Discord settings', + description: 'Sends notifications to Discord', + info: '', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Message Content', + description: 'Mention a group using @ or a user using <@ID> when notifying in a channel', + placeholder: '', + propertyName: 'content', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'text', + label: 'Webhook URL', + description: '', + placeholder: 'Discord webhook URL', + propertyName: 'url', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: true, + validationRule: '', + secure: false, + }, + ], + typeName: 'discord', + }, + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-24T10:46:43+02:00', + updated: '2020-09-02T14:08:27+02:00', + settings: { + url: 'https://discordapp.com/api/webhooks/', + uploadImage: true, + content: '', + autoResolve: true, + httpMethod: 'POST', + severity: 'critical', + }, + secureFields: {}, + secureSettings: {}, +}; + +const selectFormData: NotificationChannelDTO = { + id: 23, + uid: 'BxEN9rNGk', + name: 'Webhook', + type: { + value: 'webhook', + label: 'webhook', + type: 'webhook', + name: 'webhook', + heading: 'Webhook settings', + description: 'Sends HTTP POST request to a URL', + info: '', + options: [ + { + element: 'input', + inputType: 'text', + label: 'Url', + description: '', + placeholder: '', + propertyName: 'url', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: true, + validationRule: '', + secure: false, + }, + { + element: 'select', + inputType: '', + label: 'Http Method', + description: '', + placeholder: '', + propertyName: 'httpMethod', + selectOptions: [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + ], + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'text', + label: 'Username', + description: '', + placeholder: '', + propertyName: 'username', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, + { + element: 'input', + inputType: 'password', + label: 'Password', + description: '', + placeholder: '', + propertyName: 'password', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: true, + }, + ], + typeName: 'webhook', + }, + isDefault: false, + sendReminder: false, + disableResolveMessage: false, + frequency: '', + created: '2020-08-28T10:47:37+02:00', + updated: '2020-09-03T09:37:21+02:00', + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://asdf', + username: 'asdf', + }, + secureFields: { password: true }, + secureSettings: {}, +}; + +describe('Transform submit data', () => { + it('basic transform', () => { + const expected = { + id: 1, + name: 'Pete discord', + type: 'discord', + sendReminder: false, + disableResolveMessage: false, + frequency: '15m', + settings: { + uploadImage: true, + autoResolve: true, + httpMethod: 'POST', + severity: 'critical', + url: 'https://discordapp.com/api/webhooks/', + content: '', + }, + secureSettings: {}, + secureFields: {}, + isDefault: false, + uid: 'pX7fbbHGk', + created: '2020-08-24T10:46:43+02:00', + updated: '2020-09-02T14:08:27+02:00', + }; + + expect(transformSubmitData(basicFormData)).toEqual(expected); + }); + + it('should transform form data with selects', () => { + const expected = { + created: '2020-08-28T10:47:37+02:00', + disableResolveMessage: false, + frequency: '15m', + id: 23, + isDefault: false, + name: 'Webhook', + secureFields: { password: true }, + secureSettings: {}, + sendReminder: false, + settings: { + autoResolve: true, + httpMethod: 'POST', + password: '', + severity: 'critical', + uploadImage: true, + url: 'http://asdf', + username: 'asdf', + }, + type: 'webhook', + uid: 'BxEN9rNGk', + updated: '2020-09-03T09:37:21+02:00', + }; + + expect(transformSubmitData(selectFormData)).toEqual(expected); + }); +}); diff --git a/public/app/features/alerting/utils/notificationChannels.ts b/public/app/features/alerting/utils/notificationChannels.ts new file mode 100644 index 0000000..1aa3e36 --- /dev/null +++ b/public/app/features/alerting/utils/notificationChannels.ts @@ -0,0 +1,71 @@ +import memoizeOne from 'memoize-one'; +import { SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { NotificationChannelDTO, NotificationChannelType } from 'app/types'; + +export const defaultValues: NotificationChannelDTO = { + id: -1, + name: '', + type: { value: 'email', label: 'Email' }, + sendReminder: false, + disableResolveMessage: false, + frequency: '15m', + settings: { + uploadImage: config.rendererAvailable, + autoResolve: true, + httpMethod: 'POST', + severity: 'critical', + }, + secureSettings: {}, + secureFields: {}, + isDefault: false, +}; + +export const mapChannelsToSelectableValue = memoizeOne( + (notificationChannels: NotificationChannelType[], includeDescription: boolean): Array> => { + return notificationChannels.map((channel) => { + if (includeDescription) { + return { + value: channel.value, + label: channel.label, + description: channel.description, + }; + } + return { + value: channel.value, + label: channel.label, + }; + }); + } +); + +export const transformSubmitData = (formData: NotificationChannelDTO) => { + /* + Some settings can be options in a select, in order to not save a SelectableValue + we need to use check if it is a SelectableValue and use its value. + */ + const settings = Object.fromEntries( + Object.entries(formData.settings).map(([key, value]) => { + return [key, value && value.hasOwnProperty('value') ? value.value : value]; + }) + ); + + return { + ...defaultValues, + ...formData, + frequency: formData.frequency === '' ? defaultValues.frequency : formData.frequency, + type: formData.type.value, + settings: { ...defaultValues.settings, ...settings }, + secureSettings: { ...formData.secureSettings }, + }; +}; + +export const transformTestData = (formData: NotificationChannelDTO) => { + return { + name: formData.name, + type: formData.type.value, + frequency: formData.frequency ?? defaultValues.frequency, + settings: { ...Object.assign(defaultValues.settings, formData.settings) }, + secureSettings: { ...formData.secureSettings }, + }; +}; diff --git a/public/app/features/all.ts b/public/app/features/all.ts new file mode 100644 index 0000000..2f2edbb --- /dev/null +++ b/public/app/features/all.ts @@ -0,0 +1,11 @@ +import './annotations/all'; +import './templating/all'; +import './plugins/all'; +import './dashboard'; +import './playlist/all'; +import './panel/all'; +import './admin'; +import './manage-dashboards'; +import './profile/all'; +import './datasources/settings/HttpSettingsCtrl'; +import './datasources/settings/TlsAuthSettingsCtrl'; diff --git a/public/app/features/annotations/all.ts b/public/app/features/annotations/all.ts new file mode 100644 index 0000000..ed2c650 --- /dev/null +++ b/public/app/features/annotations/all.ts @@ -0,0 +1,5 @@ +import { AnnotationsSrv } from './annotations_srv'; +import { eventEditor } from './event_editor'; +import { EventManager } from './event_manager'; +import { annotationTooltipDirective } from './annotation_tooltip'; +export { AnnotationsSrv, eventEditor, EventManager, annotationTooltipDirective }; diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts new file mode 100644 index 0000000..df6fb9e --- /dev/null +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from 'app/core/core_module'; +import alertDef from '../alerting/state/alertDef'; +import { DashboardSrv } from '../dashboard/services/DashboardSrv'; +import { ContextSrv } from 'app/core/services/context_srv'; + +/** @ngInject */ +export function annotationTooltipDirective( + $sanitize: any, + dashboardSrv: DashboardSrv, + contextSrv: ContextSrv, + $compile: any +) { + function sanitizeString(str: string) { + try { + return $sanitize(str); + } catch (err) { + console.log('Could not sanitize annotation string, html escaping instead'); + return _.escape(str); + } + } + + return { + restrict: 'E', + scope: { + event: '=', + onEdit: '&', + }, + link: (scope: any, element: JQuery) => { + const event = scope.event; + let title = event.title; + let text = event.text; + const dashboard = dashboardSrv.getCurrent(); + + let tooltip = '
'; + let titleStateClass = ''; + + if (event.alertId) { + const stateModel = alertDef.getStateDisplayModel(event.newState); + titleStateClass = stateModel.stateClass; + title = ` ${stateModel.text}`; + text = alertDef.getAlertAnnotationInfo(event); + if (event.text) { + text = text + '
' + event.text; + } + } else if (title) { + text = title + '
' + (_.isString(text) ? text : ''); + title = ''; + } + + let header = `
`; + if (event.login) { + header += `
`; + } + header += ` + ${sanitizeString(title)} + ${dashboard.formatDate(event.min)} + `; + + // Show edit icon only for users with at least Editor role + if (event.id && dashboard.canAddAnnotations()) { + header += ` + + + + `; + } + + header += `
`; + tooltip += header; + tooltip += '
'; + + if (text) { + tooltip += '
' + sanitizeString(text.replace(/\n/g, '
')) + '
'; + } + + const tags = event.tags; + + if (tags && tags.length) { + scope.tags = tags; + tooltip += + '{{tag}}
'; + } + + tooltip += '
'; + tooltip += '
'; + + const $tooltip = $(tooltip); + $tooltip.appendTo(element); + + $compile(element.contents())(scope); + }, + }; +} + +coreModule.directive('annotationTooltip', annotationTooltipDirective); diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts new file mode 100644 index 0000000..0d93568 --- /dev/null +++ b/public/app/features/annotations/annotations_srv.ts @@ -0,0 +1,277 @@ +// Libaries +import flattenDeep from 'lodash/flattenDeep'; +import cloneDeep from 'lodash/cloneDeep'; +// Components +import './editor_ctrl'; +import coreModule from 'app/core/core_module'; +// Utils & Services +import { dedupAnnotations } from './events_processing'; +// Types +import { DashboardModel } from '../dashboard/state'; +import { + AnnotationEvent, + AppEvents, + CoreApp, + DataQueryRequest, + DataSourceApi, + rangeUtil, + ScopedVars, +} from '@grafana/data'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { appEvents } from 'app/core/core'; +import { getTimeSrv } from '../dashboard/services/TimeSrv'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { AnnotationQueryOptions, AnnotationQueryResponse } from './types'; +import { standardAnnotationSupport } from './standardAnnotationSupport'; +import { runRequest } from '../query/state/runRequest'; +import { RefreshEvent } from 'app/types/events'; + +let counter = 100; +function getNextRequestId() { + return 'AQ' + counter++; +} + +export class AnnotationsSrv { + globalAnnotationsPromise: any; + alertStatesPromise: any; + datasourcePromises: any; + + init(dashboard: DashboardModel) { + // always clearPromiseCaches when loading new dashboard + this.clearPromiseCaches(); + // clear promises on refresh events + dashboard.events.subscribe(RefreshEvent, this.clearPromiseCaches.bind(this)); + } + + clearPromiseCaches() { + this.globalAnnotationsPromise = null; + this.alertStatesPromise = null; + this.datasourcePromises = null; + } + + getAnnotations(options: AnnotationQueryOptions) { + return Promise.all([this.getGlobalAnnotations(options), this.getAlertStates(options)]) + .then((results) => { + // combine the annotations and flatten results + let annotations: AnnotationEvent[] = flattenDeep(results[0]); + // when in edit mode we need to use this function to get the saved id + let panelFilterId = options.panel.getSavedId(); + + // filter out annotations that do not belong to requesting panel + annotations = annotations.filter((item) => { + // if event has panel id and query is of type dashboard then panel and requesting panel id must match + if (item.panelId && item.source.type === 'dashboard') { + return item.panelId === panelFilterId; + } + return true; + }); + + annotations = dedupAnnotations(annotations); + + // look for alert state for this panel + const alertState: any = results[1].find((res: any) => res.panelId === panelFilterId); + + return { + annotations: annotations, + alertState: alertState, + }; + }) + .catch((err) => { + if (err.cancelled) { + return []; + } + + if (!err.message && err.data && err.data.message) { + err.message = err.data.message; + } + + console.error('AnnotationSrv.query error', err); + appEvents.emit(AppEvents.alertError, ['Annotation Query Failed', err.message || err]); + return []; + }); + } + + getAlertStates(options: any) { + if (!options.dashboard.id) { + return Promise.resolve([]); + } + + // ignore if no alerts + if (options.panel && !options.panel.alert) { + return Promise.resolve([]); + } + + if (options.range.raw.to !== 'now') { + return Promise.resolve([]); + } + + if (this.alertStatesPromise) { + return this.alertStatesPromise; + } + + this.alertStatesPromise = getBackendSrv().get( + '/api/alerts/states-for-dashboard', + { + dashboardId: options.dashboard.id, + }, + `get-alert-states-${options.dashboard.id}` + ); + + return this.alertStatesPromise; + } + + getGlobalAnnotations(options: AnnotationQueryOptions) { + const dashboard = options.dashboard; + + if (this.globalAnnotationsPromise) { + return this.globalAnnotationsPromise; + } + + const range = getTimeSrv().timeRange(); + const promises = []; + const dsPromises = []; + + for (const annotation of dashboard.annotations.list) { + if (!annotation.enable) { + continue; + } + + if (annotation.snapshotData) { + return this.translateQueryResult(annotation, annotation.snapshotData); + } + const datasourcePromise = getDataSourceSrv().get(annotation.datasource); + dsPromises.push(datasourcePromise); + promises.push( + datasourcePromise + .then((datasource: DataSourceApi) => { + // Use the legacy annotationQuery unless annotation support is explicitly defined + if (datasource.annotationQuery && !datasource.annotations) { + return datasource.annotationQuery({ + range, + rangeRaw: range.raw, + annotation: annotation, + dashboard: dashboard, + }); + } + // Note: future annotatoin lifecycle will use observables directly + return executeAnnotationQuery(options, datasource, annotation) + .toPromise() + .then((res) => { + return res.events ?? []; + }); + }) + .then((results) => { + // store response in annotation object if this is a snapshot call + if (dashboard.snapshot) { + annotation.snapshotData = cloneDeep(results); + } + // translate result + return this.translateQueryResult(annotation, results); + }) + ); + } + this.datasourcePromises = Promise.all(dsPromises); + this.globalAnnotationsPromise = Promise.all(promises); + return this.globalAnnotationsPromise; + } + + saveAnnotationEvent(annotation: AnnotationEvent) { + this.globalAnnotationsPromise = null; + return getBackendSrv().post('/api/annotations', annotation); + } + + updateAnnotationEvent(annotation: AnnotationEvent) { + this.globalAnnotationsPromise = null; + return getBackendSrv().put(`/api/annotations/${annotation.id}`, annotation); + } + + deleteAnnotationEvent(annotation: AnnotationEvent) { + this.globalAnnotationsPromise = null; + const deleteUrl = `/api/annotations/${annotation.id}`; + + return getBackendSrv().delete(deleteUrl); + } + + translateQueryResult(annotation: any, results: any) { + // if annotation has snapshotData + // make clone and remove it + if (annotation.snapshotData) { + annotation = cloneDeep(annotation); + delete annotation.snapshotData; + } + + for (const item of results) { + item.source = annotation; + item.color = annotation.iconColor; + item.type = annotation.name; + item.isRegion = item.timeEnd && item.time !== item.timeEnd; + } + + return results; + } +} + +export function executeAnnotationQuery( + options: AnnotationQueryOptions, + datasource: DataSourceApi, + savedJsonAnno: any +): Observable { + const processor = { + ...standardAnnotationSupport, + ...datasource.annotations, + }; + + const annotation = processor.prepareAnnotation!(savedJsonAnno); + if (!annotation) { + return of({}); + } + + const query = processor.prepareQuery!(annotation); + if (!query) { + return of({}); + } + + // No more points than pixels + const maxDataPoints = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; + + // Add interval to annotation queries + const interval = rangeUtil.calculateInterval(options.range, maxDataPoints, datasource.interval); + + const scopedVars: ScopedVars = { + __interval: { text: interval.interval, value: interval.interval }, + __interval_ms: { text: interval.intervalMs.toString(), value: interval.intervalMs }, + __annotation: { text: annotation.name, value: annotation }, + }; + + const queryRequest: DataQueryRequest = { + startTime: Date.now(), + requestId: getNextRequestId(), + range: options.range, + maxDataPoints, + scopedVars, + ...interval, + app: CoreApp.Dashboard, + + timezone: options.dashboard.timezone, + + targets: [ + { + ...query, + refId: 'Anno', + }, + ], + }; + + return runRequest(datasource, queryRequest).pipe( + mergeMap((panelData) => { + if (!panelData.series) { + return of({ panelData, events: [] }); + } + + return processor.processEvents!(annotation, panelData.series).pipe(map((events) => ({ panelData, events }))); + }) + ); +} + +coreModule.service('annotationsSrv', AnnotationsSrv); diff --git a/public/app/features/annotations/components/AnnotationResultMapper.tsx b/public/app/features/annotations/components/AnnotationResultMapper.tsx new file mode 100644 index 0000000..5405d92 --- /dev/null +++ b/public/app/features/annotations/components/AnnotationResultMapper.tsx @@ -0,0 +1,195 @@ +import React, { PureComponent } from 'react'; + +import { + SelectableValue, + getFieldDisplayName, + AnnotationEvent, + AnnotationEventMappings, + AnnotationEventFieldMapping, + formattedValueToString, + AnnotationEventFieldSource, + getValueFormat, +} from '@grafana/data'; + +import { annotationEventNames, AnnotationFieldInfo } from '../standardAnnotationSupport'; +import { Select, Tooltip, Icon } from '@grafana/ui'; +import { AnnotationQueryResponse } from '../types'; + +// const valueOptions: Array> = [ +// { value: AnnotationEventFieldSource.Field, label: 'Field', description: 'Set the field value from a response field' }, +// { value: AnnotationEventFieldSource.Text, label: 'Text', description: 'Enter direct text for the value' }, +// { value: AnnotationEventFieldSource.Skip, label: 'Skip', description: 'Hide this field' }, +// ]; + +interface Props { + response?: AnnotationQueryResponse; + + mappings?: AnnotationEventMappings; + + change: (mappings?: AnnotationEventMappings) => void; +} + +interface State { + fieldNames: Array>; +} + +export class AnnotationFieldMapper extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + fieldNames: [], + }; + } + + updateFields = () => { + const frame = this.props.response?.panelData?.series[0]; + if (frame && frame.fields) { + const fieldNames = frame.fields.map((f) => { + const name = getFieldDisplayName(f, frame); + + let description = ''; + for (let i = 0; i < frame.length; i++) { + if (i > 0) { + description += ', '; + } + if (i > 2) { + description += '...'; + break; + } + description += f.values.get(i); + } + + if (description.length > 50) { + description = description.substring(0, 50) + '...'; + } + + return { + label: `${name} (${f.type})`, + value: name, + description, + }; + }); + this.setState({ fieldNames }); + } + }; + + componentDidMount() { + this.updateFields(); + } + + componentDidUpdate(oldProps: Props) { + if (oldProps.response !== this.props.response) { + this.updateFields(); + } + } + + onFieldSourceChange = (k: keyof AnnotationEvent, v: SelectableValue) => { + const mappings = this.props.mappings || {}; + const mapping = mappings[k] || {}; + + this.props.change({ + ...mappings, + [k]: { + ...mapping, + source: v.value || AnnotationEventFieldSource.Field, + }, + }); + }; + + onFieldNameChange = (k: keyof AnnotationEvent, v: SelectableValue) => { + const mappings = this.props.mappings || {}; + const mapping = mappings[k] || {}; + + this.props.change({ + ...mappings, + [k]: { + ...mapping, + value: v.value, + source: AnnotationEventFieldSource.Field, + }, + }); + }; + + renderRow(row: AnnotationFieldInfo, mapping: AnnotationEventFieldMapping, first?: AnnotationEvent) { + const { fieldNames } = this.state; + + let picker = fieldNames; + const current = mapping.value; + let currentValue = fieldNames.find((f) => current === f.value); + if (current) { + picker = [...fieldNames]; + if (!currentValue) { + picker.push({ + label: current, + value: current, + }); + } + } + + let value = first ? first[row.key] : ''; + if (value && row.key.startsWith('time')) { + const fmt = getValueFormat('dateTimeAsIso'); + value = formattedValueToString(fmt(value as number)); + } + if (value === null || value === undefined) { + value = ''; // empty string + } + + return ( + + + {row.key}{' '} + {row.help && ( + + + + )} + + {/* + ) => { + this.onFieldNameChange(row.key, v); + }} + noOptionsMessage="Unknown field names" + allowCustomValue={true} + /> + + {`${value}`} + + ); + } + + render() { + const first = this.props.response?.events?.[0]; + const mappings = this.props.mappings || {}; + + return ( + + + + + + + + + + {annotationEventNames.map((row) => { + return this.renderRow(row, mappings[row.key] || {}, first); + })} + +
AnnotationFromFirst Value
+ ); + } +} diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx new file mode 100644 index 0000000..7a1cb23 --- /dev/null +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -0,0 +1,188 @@ +import React, { PureComponent } from 'react'; + +import { AnnotationEventMappings, DataQuery, LoadingState, DataSourceApi, AnnotationQuery } from '@grafana/data'; +import { Spinner, Icon, IconName, Button } from '@grafana/ui'; + +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { cx, css } from 'emotion'; +import { standardAnnotationSupport } from '../standardAnnotationSupport'; +import { executeAnnotationQuery } from '../annotations_srv'; +import { PanelModel } from 'app/features/dashboard/state'; +import { AnnotationQueryResponse } from '../types'; +import { AnnotationFieldMapper } from './AnnotationResultMapper'; +import coreModule from 'app/core/core_module'; + +interface Props { + datasource: DataSourceApi; + annotation: AnnotationQuery; + change: (annotation: AnnotationQuery) => void; +} + +interface State { + running?: boolean; + response?: AnnotationQueryResponse; +} + +export default class StandardAnnotationQueryEditor extends PureComponent { + state = {} as State; + + componentDidMount() { + this.verifyDataSource(); + } + + componentDidUpdate(oldProps: Props) { + if (this.props.annotation !== oldProps.annotation) { + this.verifyDataSource(); + } + } + + verifyDataSource() { + const { datasource, annotation } = this.props; + + // Handle any migration issues + const processor = { + ...standardAnnotationSupport, + ...datasource.annotations, + }; + + const fixed = processor.prepareAnnotation!(annotation); + if (fixed !== annotation) { + this.props.change(fixed); + } else { + this.onRunQuery(); + } + } + + onRunQuery = async () => { + const { datasource, annotation } = this.props; + this.setState({ + running: true, + }); + const response = await executeAnnotationQuery( + { + range: getTimeSrv().timeRange(), + panel: {} as PanelModel, + dashboard: getDashboardSrv().getCurrent(), + }, + datasource, + annotation + ).toPromise(); + this.setState({ + running: false, + response, + }); + }; + + onQueryChange = (target: DataQuery) => { + this.props.change({ + ...this.props.annotation, + target, + }); + }; + + onMappingChange = (mappings: AnnotationEventMappings) => { + this.props.change({ + ...this.props.annotation, + mappings, + }); + }; + + renderStatus() { + const { response, running } = this.state; + let rowStyle = 'alert-info'; + let text = '...'; + let icon: IconName | undefined = undefined; + + if (running || response?.panelData?.state === LoadingState.Loading || !response) { + text = 'loading...'; + } else { + const { events, panelData } = response; + + if (panelData?.error) { + rowStyle = 'alert-error'; + icon = 'exclamation-triangle'; + text = panelData.error.message ?? 'error'; + } else if (!events?.length) { + rowStyle = 'alert-warning'; + icon = 'exclamation-triangle'; + text = 'No events found'; + } else { + const frame = panelData?.series[0]; + + text = `${events.length} events (from ${frame?.fields.length} fields)`; + } + } + return ( +
+
+ {icon && ( + <> + +   + + )} + {text} +
+
+ {running ? ( + + ) : ( + + )} +
+
+ ); + } + + render() { + const { datasource, annotation } = this.props; + const { response } = this.state; + + // Find the annotaiton runner + let QueryEditor = datasource.annotations?.QueryEditor || datasource.components?.QueryEditor; + if (!QueryEditor) { + return
Annotations are not supported. This datasource needs to export a QueryEditor
; + } + + const query = annotation.target ?? { refId: 'Anno' }; + return ( + <> + + {this.renderStatus()} + + +
+ + ); + } +} + +// Careful to use a unique directive name! many plugins already use "annotationEditor" and have conflicts +coreModule.directive('standardAnnotationEditor', [ + 'reactDirective', + (reactDirective: any) => { + return reactDirective(StandardAnnotationQueryEditor, ['annotation', 'datasource', 'change']); + }, +]); diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts new file mode 100644 index 0000000..eaf1008 --- /dev/null +++ b/public/app/features/annotations/editor_ctrl.ts @@ -0,0 +1,156 @@ +import angular from 'angular'; +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from 'app/core/core_module'; +import { DashboardModel } from 'app/features/dashboard/state'; +import DatasourceSrv from '../plugins/datasource_srv'; +import appEvents from 'app/core/app_events'; +import { AnnotationQuery, AppEvents } from '@grafana/data'; + +// Registeres the angular directive +import './components/StandardAnnotationQueryEditor'; + +export class AnnotationsEditorCtrl { + mode: any; + datasources: any; + currentAnnotation: any; + currentDatasource: any; + currentIsNew: any; + dashboard: DashboardModel; + + annotationDefaults: any = { + name: '', + datasource: null, + iconColor: 'rgba(255, 96, 96, 1)', + enable: true, + showIn: 0, + hide: false, + }; + + emptyListCta = { + title: 'There are no custom annotation queries added yet', + buttonIcon: 'comment-alt', + buttonTitle: 'Add Annotation Query', + infoBox: { + __html: `

Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines + and icons on all graph panels. When you hover over an annotation icon you can get event text & tags for + the event. You can add annotation events directly from grafana by holding CTRL or CMD + click on graph (or + drag region). These will be stored in Grafana's annotation database. +

+ Checkout the + Annotations documentation + for more information.`, + }, + infoBoxTitle: 'What are annotations?', + }; + + showOptions: any = [ + { text: 'All Panels', value: 0 }, + { text: 'Specific Panels', value: 1 }, + ]; + + /** @ngInject */ + constructor(private $scope: any, private datasourceSrv: DatasourceSrv) { + $scope.ctrl = this; + + this.dashboard = $scope.dashboard; + this.mode = 'list'; + this.datasources = datasourceSrv.getAnnotationSources(); + this.dashboard.annotations.list = this.dashboard.annotations.list ?? []; + this.reset(); + + this.onColorChange = this.onColorChange.bind(this); + } + + async datasourceChanged() { + const newDatasource = await this.datasourceSrv.get(this.currentAnnotation.datasource); + this.$scope.$apply(() => { + this.currentDatasource = newDatasource; + }); + } + + /** + * Called from the react editor + */ + onAnnotationChange = (annotation: AnnotationQuery) => { + let replaced = false; + + this.dashboard.annotations.list = this.dashboard.annotations.list.map((a) => { + if (a.name !== annotation.name) { + return a; + } + replaced = true; + return annotation; + }); + + if (!replaced) { + console.warn('updating annotatoin, but not in the dashboard', annotation); + } + + this.currentAnnotation = annotation; + }; + + edit(annotation: AnnotationQuery) { + this.currentAnnotation = annotation; + this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0; + this.currentIsNew = false; + this.datasourceChanged(); + this.mode = 'edit'; + $('.tooltip.in').remove(); + } + + reset() { + this.currentAnnotation = angular.copy(this.annotationDefaults); + this.currentAnnotation.datasource = this.datasources[0].name; + this.currentIsNew = true; + this.datasourceChanged(); + } + + update() { + this.dashboard.annotations.list = [...this.dashboard.annotations.list]; + this.reset(); + this.mode = 'list'; + } + + setupNew = () => { + this.mode = 'new'; + this.reset(); + }; + + backToList() { + this.mode = 'list'; + } + + move(index: number, dir: number) { + const list = [...this.dashboard.annotations.list]; + Array.prototype.splice.call(list, index + dir, 0, Array.prototype.splice.call(list, index, 1)[0]); + this.dashboard.annotations.list = list; + } + + add() { + const sameName: any = _.find(this.dashboard.annotations.list, { name: this.currentAnnotation.name }); + if (sameName) { + appEvents.emit(AppEvents.alertWarning, ['Validation', 'Annotations with the same name already exists']); + return; + } + this.dashboard.annotations.list = [...this.dashboard.annotations.list, this.currentAnnotation]; + this.reset(); + this.mode = 'list'; + this.dashboard.updateSubmenuVisibility(); + } + + removeAnnotation(annotation: AnnotationQuery) { + this.dashboard.annotations.list = this.dashboard.annotations.list.filter((a) => { + return a.name !== annotation.name; + }); + this.dashboard.updateSubmenuVisibility(); + } + + onColorChange(newColor: string) { + this.currentAnnotation.iconColor = newColor; + } +} + +coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl); diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts new file mode 100644 index 0000000..8dae652 --- /dev/null +++ b/public/app/features/annotations/event_editor.ts @@ -0,0 +1,112 @@ +import _ from 'lodash'; +import { coreModule } from 'app/core/core'; +import { MetricsPanelCtrl } from 'app/plugins/sdk'; +import { AnnotationEvent, dateTime } from '@grafana/data'; +import { AnnotationsSrv } from './all'; + +export class EventEditorCtrl { + panelCtrl: MetricsPanelCtrl; + event: AnnotationEvent; + timeRange: { from: number; to: number }; + form: any; + close: any; + timeFormated: string; + + /** @ngInject */ + constructor(private annotationsSrv: AnnotationsSrv) {} + + $onInit() { + this.event.panelId = this.panelCtrl.panel.id; + this.event.dashboardId = this.panelCtrl.dashboard.id; + + // Annotations query returns time as Unix timestamp in milliseconds + this.event.time = tryEpochToMoment(this.event.time); + if (this.event.isRegion) { + this.event.timeEnd = tryEpochToMoment(this.event.timeEnd); + } + + this.timeFormated = this.panelCtrl.dashboard.formatDate(this.event.time!); + } + + save() { + if (!this.form.$valid) { + return; + } + + const saveModel = _.cloneDeep(this.event); + saveModel.time = saveModel.time!.valueOf(); + saveModel.timeEnd = 0; + + if (saveModel.isRegion) { + saveModel.timeEnd = this.event.timeEnd!.valueOf(); + + if (saveModel.timeEnd < saveModel.time) { + console.log('invalid time'); + return; + } + } + + if (saveModel.id) { + this.annotationsSrv + .updateAnnotationEvent(saveModel) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { + this.panelCtrl.refresh(); + this.close(); + }); + } else { + this.annotationsSrv + .saveAnnotationEvent(saveModel) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { + this.panelCtrl.refresh(); + this.close(); + }); + } + } + + delete() { + return this.annotationsSrv + .deleteAnnotationEvent(this.event) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { + this.panelCtrl.refresh(); + this.close(); + }); + } +} + +function tryEpochToMoment(timestamp: any) { + if (timestamp && _.isNumber(timestamp)) { + const epoch = Number(timestamp); + return dateTime(epoch); + } else { + return timestamp; + } +} + +export function eventEditor() { + return { + restrict: 'E', + controller: EventEditorCtrl, + bindToController: true, + controllerAs: 'ctrl', + templateUrl: 'public/app/features/annotations/partials/event_editor.html', + scope: { + panelCtrl: '=', + event: '=', + close: '&', + }, + }; +} + +coreModule.directive('eventEditor', eventEditor); diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts new file mode 100644 index 0000000..16f1451 --- /dev/null +++ b/public/app/features/annotations/event_manager.ts @@ -0,0 +1,184 @@ +import _ from 'lodash'; +import tinycolor from 'tinycolor2'; +import { + OK_COLOR, + ALERTING_COLOR, + NO_DATA_COLOR, + PENDING_COLOR, + DEFAULT_ANNOTATION_COLOR, + REGION_FILL_ALPHA, +} from '@grafana/ui'; + +import { MetricsPanelCtrl } from 'app/plugins/sdk'; +import { AnnotationEvent } from '@grafana/data'; + +export class EventManager { + event: AnnotationEvent | null; + editorOpen: boolean; + + constructor(private panelCtrl: MetricsPanelCtrl) {} + + editorClosed() { + this.event = null; + this.editorOpen = false; + this.panelCtrl.render(); + } + + editorOpened() { + this.editorOpen = true; + } + + updateTime(range: { from: any; to: any }) { + if (!this.event) { + this.event = {}; + this.event.dashboardId = this.panelCtrl.dashboard.id; + this.event.panelId = this.panelCtrl.panel.id; + } + + // update time + this.event.time = range.from; + this.event.isRegion = false; + + if (range.to) { + this.event.timeEnd = range.to; + this.event.isRegion = true; + } + + this.panelCtrl.render(); + } + + editEvent(event: AnnotationEvent, elem?: any) { + this.event = event; + this.panelCtrl.render(); + } + + addFlotEvents(annotations: any, flotOptions: any) { + if (!this.event && annotations.length === 0) { + return; + } + + const types: any = { + $__alerting: { + color: ALERTING_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + $__ok: { + color: OK_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + $__no_data: { + color: NO_DATA_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + $__pending: { + color: PENDING_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + $__editing: { + color: DEFAULT_ANNOTATION_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + }; + + if (this.event) { + if (this.event.isRegion) { + annotations = [ + { + isRegion: true, + min: this.event.time, + timeEnd: this.event.timeEnd, + text: this.event.text, + eventType: '$__editing', + editModel: this.event, + }, + ]; + } else { + annotations = [ + { + min: this.event.time, + text: this.event.text, + editModel: this.event, + eventType: '$__editing', + }, + ]; + } + } else { + // annotations from query + for (let i = 0; i < annotations.length; i++) { + const item = annotations[i]; + + // add properties used by jquery flot events + item.min = item.time; + item.max = item.time; + item.eventType = item.type; + + if (item.newState) { + item.eventType = '$__' + item.newState; + continue; + } + + if (!types[item.type]) { + types[item.type] = { + color: item.color, + position: 'BOTTOM', + markerSize: 5, + }; + } + } + } + + const regions = getRegions(annotations); + addRegionMarking(regions, flotOptions); + + const eventSectionHeight = 20; + const eventSectionMargin = 7; + flotOptions.grid.eventSectionHeight = eventSectionMargin; + flotOptions.xaxis.eventSectionHeight = eventSectionHeight; + + flotOptions.events = { + levels: _.keys(types).length + 1, + data: annotations, + types: types, + manager: this, + }; + } +} + +function getRegions(events: AnnotationEvent[]) { + return _.filter(events, 'isRegion'); +} + +function addRegionMarking(regions: any[], flotOptions: { grid: { markings: any } }) { + const markings = flotOptions.grid.markings; + const defaultColor = DEFAULT_ANNOTATION_COLOR; + let fillColor; + + _.each(regions, (region) => { + if (region.source) { + fillColor = region.source.iconColor || defaultColor; + } else { + fillColor = defaultColor; + } + + fillColor = addAlphaToRGB(fillColor, REGION_FILL_ALPHA); + markings.push({ + xaxis: { from: region.min, to: region.timeEnd }, + color: fillColor, + }); + }); +} + +function addAlphaToRGB(colorString: string, alpha: number): string { + const color = tinycolor(colorString); + if (color.isValid()) { + color.setAlpha(alpha); + return color.toRgbString(); + } else { + return colorString; + } +} diff --git a/public/app/features/annotations/events_processing.ts b/public/app/features/annotations/events_processing.ts new file mode 100644 index 0000000..e06f5ab --- /dev/null +++ b/public/app/features/annotations/events_processing.ts @@ -0,0 +1,27 @@ +import _ from 'lodash'; + +export function dedupAnnotations(annotations: any) { + let dedup = []; + + // Split events by annotationId property existence + const events = _.partition(annotations, 'id'); + + const eventsById = _.groupBy(events[0], 'id'); + dedup = _.map(eventsById, (eventGroup) => { + if (eventGroup.length > 1 && !_.every(eventGroup, isPanelAlert)) { + // Get first non-panel alert + return _.find(eventGroup, (event) => { + return event.eventType !== 'panel-alert'; + }); + } else { + return _.head(eventGroup); + } + }); + + dedup = _.concat(dedup, events[1]); + return dedup; +} + +function isPanelAlert(event: { eventType: string }) { + return event.eventType === 'panel-alert'; +} diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html new file mode 100644 index 0000000..3c2df73 --- /dev/null +++ b/public/app/features/annotations/partials/editor.html @@ -0,0 +1,147 @@ +
+
+

+ Annotations + New + Edit +

+ +
+ + + New + +
+ +
+ + + + + + + + + + + + + + + + + + +
Query nameData source
+   {{ annotation.name }} + +   + {{ annotation.name }} (Built-in) + + {{ annotation.datasource || 'Default' }} + + + + + + + + +
+ + +
+ +
+
+ +
+
+
General
+
+
+ Name + +
+
+ Data source +
+ +
+
+
+
+ +
+
+ + + + +
+ + + + +
+
+
+ +
Query
+ + + + + + + + +
+
+ + +
+
+
+
diff --git a/public/app/features/annotations/partials/event_editor.html b/public/app/features/annotations/partials/event_editor.html new file mode 100644 index 0000000..286decb --- /dev/null +++ b/public/app/features/annotations/partials/event_editor.html @@ -0,0 +1,35 @@ + +
+
+
+
+ +
+ Add Annotation + Edit Annotation +
+ +
{{ctrl.timeFormated}}
+
+ +
+
+
+ Description + +
+ +
+ Tags + + +
+ +
+ + + Cancel +
+
+
+
diff --git a/public/app/features/annotations/specs/annotations_srv.test.ts b/public/app/features/annotations/specs/annotations_srv.test.ts new file mode 100644 index 0000000..5329a14 --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv.test.ts @@ -0,0 +1,30 @@ +import { AnnotationsSrv } from '../annotations_srv'; + +describe('AnnotationsSrv', () => { + const annotationsSrv = new AnnotationsSrv(); + + describe('When translating the query result', () => { + const annotationSource = { + datasource: '-- Grafana --', + enable: true, + hide: false, + limit: 200, + name: 'test', + scope: 'global', + tags: ['test'], + type: 'event', + }; + + const time = 1507039543000; + const annotations = [{ id: 1, panelId: 1, text: 'text', time: time }]; + let translatedAnnotations: any; + + beforeEach(() => { + translatedAnnotations = annotationsSrv.translateQueryResult(annotationSource, annotations); + }); + + it('should set defaults', () => { + expect(translatedAnnotations[0].source).toEqual(annotationSource); + }); + }); +}); diff --git a/public/app/features/annotations/specs/annotations_srv_specs.test.ts b/public/app/features/annotations/specs/annotations_srv_specs.test.ts new file mode 100644 index 0000000..825b992 --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv_specs.test.ts @@ -0,0 +1,39 @@ +import { dedupAnnotations } from '../events_processing'; + +describe('Annotations deduplication', () => { + it('should remove duplicated annotations', () => { + const testAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + { id: 5, time: 5 }, + ]; + const expectedAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + ]; + + const deduplicated = dedupAnnotations(testAnnotations); + expect(deduplicated).toEqual(expectedAnnotations); + }); + + it('should leave non "panel-alert" event if present', () => { + const testAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 2, time: 2, eventType: 'panel-alert' }, + { id: 5, time: 5 }, + { id: 5, time: 5 }, + ]; + const expectedAnnotations = [ + { id: 1, time: 1 }, + { id: 2, time: 2 }, + { id: 5, time: 5 }, + ]; + + const deduplicated = dedupAnnotations(testAnnotations); + expect(deduplicated).toEqual(expectedAnnotations); + }); +}); diff --git a/public/app/features/annotations/standardAnnotationSupport.test.ts b/public/app/features/annotations/standardAnnotationSupport.test.ts new file mode 100644 index 0000000..4be61ab --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.test.ts @@ -0,0 +1,97 @@ +import { FieldType, toDataFrame } from '@grafana/data'; +import { getAnnotationsFromData } from './standardAnnotationSupport'; + +describe('DataFrame to annotations', () => { + test('simple conversion', async () => { + const frame = toDataFrame({ + fields: [ + { type: FieldType.time, values: [1, 2, 3, 4, 5] }, + { name: 'first string field', values: ['t1', 't2', 't3', null, undefined] }, + { name: 'tags', values: ['aaa,bbb', 'bbb,ccc', 'zyz', null, undefined] }, + ], + }); + + await expect(getAnnotationsFromData([frame])).toEmitValues([ + [ + { + color: 'red', + tags: ['aaa', 'bbb'], + text: 't1', + time: 1, + type: 'default', + }, + { + color: 'red', + tags: ['bbb', 'ccc'], + text: 't2', + time: 2, + type: 'default', + }, + { + color: 'red', + tags: ['zyz'], + text: 't3', + time: 3, + type: 'default', + }, + { + color: 'red', + time: 4, + type: 'default', + }, + { + color: 'red', + time: 5, + type: 'default', + }, + ], + ]); + }); + + test('explicit mappins', async () => { + const frame = toDataFrame({ + fields: [ + { name: 'time1', values: [111, 222, 333] }, + { name: 'time2', values: [100, 200, 300] }, + { name: 'aaaaa', values: ['a1', 'a2', 'a3'] }, + { name: 'bbbbb', values: ['b1', 'b2', 'b3'] }, + ], + }); + + const observable = getAnnotationsFromData([frame], { + text: { value: 'bbbbb' }, + time: { value: 'time2' }, + timeEnd: { value: 'time1' }, + title: { value: 'aaaaa' }, + }); + + await expect(observable).toEmitValues([ + [ + { + color: 'red', + text: 'b1', + time: 100, + timeEnd: 111, + title: 'a1', + type: 'default', + }, + { + color: 'red', + text: 'b2', + time: 200, + timeEnd: 222, + title: 'a2', + type: 'default', + }, + { + color: 'red', + text: 'b3', + time: 300, + timeEnd: 333, + title: 'a3', + type: 'default', + }, + ], + ]); + }); +}); diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts new file mode 100644 index 0000000..388fd2a --- /dev/null +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -0,0 +1,212 @@ +import { Observable, of, OperatorFunction } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { + AnnotationEvent, + AnnotationEventFieldSource, + AnnotationEventMappings, + AnnotationQuery, + AnnotationSupport, + DataFrame, + Field, + FieldType, + getFieldDisplayName, + KeyValue, + standardTransformers, +} from '@grafana/data'; + +import isString from 'lodash/isString'; + +export const standardAnnotationSupport: AnnotationSupport = { + /** + * Assume the stored value is standard model. + */ + prepareAnnotation: (json: any) => { + if (isString(json?.query)) { + const { query, ...rest } = json; + return { + ...rest, + target: { + query, + }, + mappings: {}, + }; + } + return json as AnnotationQuery; + }, + + /** + * Convert the stored JSON model and environment to a standard datasource query object. + * This query will be executed in the datasource and the results converted into events. + * Returning an undefined result will quietly skip query execution + */ + prepareQuery: (anno: AnnotationQuery) => anno.target, + + /** + * When the standard frame > event processing is insufficient, this allows explicit control of the mappings + */ + processEvents: (anno: AnnotationQuery, data: DataFrame[]) => { + return getAnnotationsFromData(data, anno.mappings); + }, +}; + +/** + * Flatten all panel data into a single frame + */ + +export function singleFrameFromPanelData(): OperatorFunction { + return (source) => + source.pipe( + mergeMap((data) => { + if (!data?.length) { + return of(undefined); + } + + if (data.length === 1) { + return of(data[0]); + } + + return of(data).pipe( + standardTransformers.mergeTransformer.operator({}), + map((d) => d[0]) + ); + }) + ); +} + +interface AnnotationEventFieldSetter { + key: keyof AnnotationEvent; + field?: Field; + text?: string; + regex?: RegExp; + split?: string; // for tags +} + +export interface AnnotationFieldInfo { + key: keyof AnnotationEvent; + + split?: string; + field?: (frame: DataFrame) => Field | undefined; + placeholder?: string; + help?: string; +} + +export const annotationEventNames: AnnotationFieldInfo[] = [ + { + key: 'time', + field: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.time), + placeholder: 'time, or the first time field', + }, + { key: 'timeEnd', help: 'When this field is defined, the annotation will be treated as a range' }, + { + key: 'title', + }, + { + key: 'text', + field: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.string), + placeholder: 'text, or the first text field', + }, + { key: 'tags', split: ',', help: 'The results will be split on comma (,)' }, + // { key: 'userId' }, + // { key: 'login' }, + // { key: 'email' }, +]; + +export function getAnnotationsFromData( + data: DataFrame[], + options?: AnnotationEventMappings +): Observable { + return of(data).pipe( + singleFrameFromPanelData(), + map((frame) => { + if (!frame?.length) { + return []; + } + + let hasTime = false; + let hasText = false; + const byName: KeyValue = {}; + + for (const f of frame.fields) { + const name = getFieldDisplayName(f, frame); + byName[name.toLowerCase()] = f; + } + + if (!options) { + options = {}; + } + + const fields: AnnotationEventFieldSetter[] = []; + + for (const evts of annotationEventNames) { + const opt = options[evts.key] || {}; //AnnotationEventFieldMapping + + if (opt.source === AnnotationEventFieldSource.Skip) { + continue; + } + + const setter: AnnotationEventFieldSetter = { key: evts.key, split: evts.split }; + + if (opt.source === AnnotationEventFieldSource.Text) { + setter.text = opt.value; + } else { + const lower = (opt.value || evts.key).toLowerCase(); + setter.field = byName[lower]; + + if (!setter.field && evts.field) { + setter.field = evts.field(frame); + } + } + + if (setter.field || setter.text) { + fields.push(setter); + if (setter.key === 'time') { + hasTime = true; + } else if (setter.key === 'text') { + hasText = true; + } + } + } + + if (!hasTime || !hasText) { + return []; // throw an error? + } + + // Add each value to the string + const events: AnnotationEvent[] = []; + + for (let i = 0; i < frame.length; i++) { + const anno: AnnotationEvent = { + type: 'default', + color: 'red', + }; + + for (const f of fields) { + let v: any = undefined; + + if (f.text) { + v = f.text; // TODO support templates! + } else if (f.field) { + v = f.field.values.get(i); + if (v !== undefined && f.regex) { + const match = f.regex.exec(v); + if (match) { + v = match[1] ? match[1] : match[0]; + } + } + } + + if (v !== null && v !== undefined) { + if (f.split && typeof v === 'string') { + v = v.split(','); + } + (anno as any)[f.key] = v; + } + } + + events.push(anno); + } + + return events; + }) + ); +} diff --git a/public/app/features/annotations/types.ts b/public/app/features/annotations/types.ts new file mode 100644 index 0000000..8eae21c --- /dev/null +++ b/public/app/features/annotations/types.ts @@ -0,0 +1,20 @@ +import { PanelData, AnnotationEvent, TimeRange } from '@grafana/data'; +import { DashboardModel, PanelModel } from '../dashboard/state'; + +export interface AnnotationQueryOptions { + dashboard: DashboardModel; + panel: PanelModel; + range: TimeRange; +} + +export interface AnnotationQueryResponse { + /** + * The processed annotation events + */ + events?: AnnotationEvent[]; + + /** + * The original panel response + */ + panelData?: PanelData; +} diff --git a/public/app/features/api-keys/ApiKeysActionBar.tsx b/public/app/features/api-keys/ApiKeysActionBar.tsx new file mode 100644 index 0000000..706470c --- /dev/null +++ b/public/app/features/api-keys/ApiKeysActionBar.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; + +import { FilterInput } from '../../core/components/FilterInput/FilterInput'; + +interface Props { + searchQuery: string; + disabled: boolean; + onAddClick: () => void; + onSearchChange: (value: string) => void; +} + +export const ApiKeysActionBar: FC = ({ searchQuery, disabled, onAddClick, onSearchChange }) => { + return ( +
+
+ +
+ +
+ +
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysAddedModal.test.tsx b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx new file mode 100644 index 0000000..160418a --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal'; + +const setup = (propOverrides?: object) => { + const props: Props = { + apiKey: 'api key test', + rootPath: 'test/path', + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + + return { + wrapper, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/api-keys/ApiKeysAddedModal.tsx b/public/app/features/api-keys/ApiKeysAddedModal.tsx new file mode 100644 index 0000000..0cb2073 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Icon } from '@grafana/ui'; + +export interface Props { + apiKey: string; + rootPath: string; +} + +export const ApiKeysAddedModal = (props: Props) => { + return ( +
+
+

+ + API Key Created +

+ + + + +
+ +
+
+
+ Key + {props.apiKey} +
+
+ +
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now. +
+
+ You can authenticate request using the Authorization HTTP header, example: +
+
+
+            curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
+          
+
+
+
+ ); +}; + +export default ApiKeysAddedModal; diff --git a/public/app/features/api-keys/ApiKeysController.tsx b/public/app/features/api-keys/ApiKeysController.tsx new file mode 100644 index 0000000..69ac055 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysController.tsx @@ -0,0 +1,19 @@ +import { FC, useCallback, useState } from 'react'; + +interface Api { + isAdding: boolean; + toggleIsAdding: () => void; +} + +interface Props { + children: (props: Api) => JSX.Element; +} + +export const ApiKeysController: FC = ({ children }) => { + const [isAdding, setIsAdding] = useState(false); + const toggleIsAdding = useCallback(() => { + setIsAdding(!isAdding); + }, [isAdding]); + + return children({ isAdding, toggleIsAdding }); +}; diff --git a/public/app/features/api-keys/ApiKeysForm.tsx b/public/app/features/api-keys/ApiKeysForm.tsx new file mode 100644 index 0000000..ae60d38 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysForm.tsx @@ -0,0 +1,111 @@ +import React, { ChangeEvent, FC, FormEvent, useEffect, useState } from 'react'; +import { EventsWithValidation, Icon, InlineFormLabel, LegacyForms, ValidationEvents } from '@grafana/ui'; +import { NewApiKey, OrgRole } from '../../types'; +import { rangeUtil } from '@grafana/data'; +import { SlideDown } from '../../core/components/Animations/SlideDown'; + +const { Input } = LegacyForms; + +interface Props { + show: boolean; + onClose: () => void; + onKeyAdded: (apiKey: NewApiKey) => void; +} + +function isValidInterval(value: string): boolean { + if (!value) { + return true; + } + try { + rangeUtil.intervalToSeconds(value); + return true; + } catch {} + return false; +} + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: isValidInterval, + errorMessage: 'Not a valid duration', + }, + ], +}; + +const tooltipText = + 'The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y'; + +export const ApiKeysForm: FC = ({ show, onClose, onKeyAdded }) => { + const [name, setName] = useState(''); + const [role, setRole] = useState(OrgRole.Viewer); + const [secondsToLive, setSecondsToLive] = useState(''); + useEffect(() => { + setName(''); + setRole(OrgRole.Viewer); + setSecondsToLive(''); + }, [show]); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isValidInterval(secondsToLive)) { + onKeyAdded({ name, role, secondsToLive }); + onClose(); + } + }; + const onNameChange = (event: ChangeEvent) => { + setName(event.currentTarget.value); + }; + const onRoleChange = (event: ChangeEvent) => { + setRole(event.currentTarget.value as OrgRole); + }; + const onSecondsToLiveChange = (event: ChangeEvent) => { + setSecondsToLive(event.currentTarget.value); + }; + + return ( + +
+ +
+
Add API Key
+
+
+ Key name + +
+
+ Role + + + +
+
+ Time to live + +
+
+ +
+
+
+
+
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx new file mode 100644 index 0000000..9aa088f --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { ApiKeysPageUnconnected, Props } from './ApiKeysPage'; +import { ApiKey, OrgRole } from 'app/types'; +import { NavModel } from '@grafana/data'; +import { setSearchQuery } from './state/reducers'; +import { mockToolkitActionCreator } from '../../../test/core/redux/mocks'; +import { getMultipleMockKeys } from './__mocks__/apiKeysMock'; +import { selectors } from '@grafana/e2e-selectors'; +import userEvent from '@testing-library/user-event'; +import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; + +const setup = (propOverrides: Partial) => { + const loadApiKeysMock = jest.fn(); + const deleteApiKeyMock = jest.fn(); + const addApiKeyMock = jest.fn(); + const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery); + const props: Props = { + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Api Keys', + }, + } as NavModel, + apiKeys: [] as ApiKey[], + searchQuery: '', + hasFetched: false, + loadApiKeys: loadApiKeysMock, + deleteApiKey: deleteApiKeyMock, + setSearchQuery: setSearchQueryMock, + addApiKey: addApiKeyMock, + apiKeysCount: 0, + timeZone: 'utc', + }; + + Object.assign(props, propOverrides); + + const { rerender } = render(); + return { rerender, props, loadApiKeysMock, setSearchQueryMock, deleteApiKeyMock, addApiKeyMock }; +}; + +describe('ApiKeysPage', () => { + silenceConsoleOutput(); + describe('when mounted', () => { + it('then it should call loadApiKeys without expired', () => { + const { loadApiKeysMock } = setup({}); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); + }); + }); + + describe('when loading', () => { + it('then should show Loading message', () => { + setup({ hasFetched: false }); + expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument(); + }); + }); + + describe('when there are no API keys', () => { + it('then it should render CTA', () => { + setup({ apiKeys: getMultipleMockKeys(0), apiKeysCount: 0, hasFetched: true }); + expect(screen.getByLabelText(selectors.components.CallToActionCard.button('New API Key'))).toBeInTheDocument(); + }); + }); + + describe('when there are API keys', () => { + it('then it should render API keys table', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toBe(4); + expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /third viewer no expiration date cancel delete/i })).toBeInTheDocument(); + }); + }); + + describe('when a user toggles the Show expired toggle', () => { + it('then it should call loadApiKeys with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { loadApiKeysMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(true); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); + }); + }); + + describe('when a user searches for an api key', () => { + it('then it should dispatch setSearchQuery with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { setSearchQueryMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + setSearchQueryMock.mockClear(); + expect(screen.getByPlaceholderText(/search keys/i)).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText(/search keys/i), 'First'); + expect(setSearchQueryMock).toHaveBeenCalledTimes(5); + }); + }); + + describe('when a user deletes an api key', () => { + it('then it should dispatch deleteApi with correct parameters', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i }); + const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i }); + + deleteApiKeyMock.mockClear(); + expect(within(firstRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(firstRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(1, false); + + toggleShowExpired(); + + deleteApiKeyMock.mockClear(); + expect(within(secondRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(secondRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(2, true); + }); + }); + + describe('when a user adds an api key from CTA', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys: any[] = []; + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByLabelText(selectors.components.CallToActionCard.button('New API Key'))); + await addAndVerifyApiKey(addApiKeyMock, false); + }); + }); + + describe('when a user adds an api key from Add Api Key', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, false); + + toggleShowExpired(); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, true); + }); + }); + + describe('when a user adds an api key with an invalid expiration', () => { + it('then it should display a message', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60x'); + expect(screen.queryByText(/not a valid duration/i)).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(screen.getByText(/not a valid duration/i)).toBeInTheDocument(); + expect(addApiKeyMock).toHaveBeenCalledTimes(0); + }); + }); +}); + +function toggleShowExpired() { + expect(screen.getByText(/show expired/i)).toBeInTheDocument(); + userEvent.click(screen.getByText(/show expired/i)); +} + +async function addAndVerifyApiKey(addApiKeyMock: jest.Mock, includeExpired: boolean) { + expect(screen.getByRole('heading', { name: /add api key/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/1d/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^add$/i })).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60s'); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(addApiKeyMock).toHaveBeenCalledTimes(1); + expect(addApiKeyMock).toHaveBeenCalledWith( + { name: 'Test', role: 'Viewer', secondsToLive: 60 }, + expect.anything(), + includeExpired + ); +} diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx new file mode 100644 index 0000000..0415686 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -0,0 +1,168 @@ +import React, { PureComponent } from 'react'; +import ReactDOMServer from 'react-dom/server'; +import { connect, ConnectedProps } from 'react-redux'; +import { hot } from 'react-hot-loader'; +// Utils +import { ApiKey, CoreEvents, NewApiKey, StoreState } from 'app/types'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getApiKeys, getApiKeysCount } from './state/selectors'; +import { addApiKey, deleteApiKey, loadApiKeys } from './state/actions'; +import Page from 'app/core/components/Page/Page'; +import ApiKeysAddedModal from './ApiKeysAddedModal'; +import config from 'app/core/config'; +import appEvents from 'app/core/app_events'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { LegacyForms } from '@grafana/ui'; +import { rangeUtil } from '@grafana/data'; +import { getTimeZone } from 'app/features/profile/state/selectors'; +import { setSearchQuery } from './state/reducers'; +import { ApiKeysForm } from './ApiKeysForm'; +import { ApiKeysActionBar } from './ApiKeysActionBar'; +import { ApiKeysTable } from './ApiKeysTable'; +import { ApiKeysController } from './ApiKeysController'; + +const { Switch } = LegacyForms; + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'apikeys'), + apiKeys: getApiKeys(state.apiKeys), + searchQuery: state.apiKeys.searchQuery, + apiKeysCount: getApiKeysCount(state.apiKeys), + hasFetched: state.apiKeys.hasFetched, + timeZone: getTimeZone(state.user), + }; +} + +const mapDispatchToProps = { + loadApiKeys, + deleteApiKey, + setSearchQuery, + addApiKey, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +interface OwnProps {} + +export type Props = OwnProps & ConnectedProps; + +interface State { + includeExpired: boolean; + hasFetched: boolean; +} + +export class ApiKeysPageUnconnected extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { includeExpired: false, hasFetched: false }; + } + + componentDidMount() { + this.fetchApiKeys(); + } + + async fetchApiKeys() { + await this.props.loadApiKeys(this.state.includeExpired); + } + + onDeleteApiKey = (key: ApiKey) => { + this.props.deleteApiKey(key.id!, this.state.includeExpired); + }; + + onSearchQueryChange = (value: string) => { + this.props.setSearchQuery(value); + }; + + onIncludeExpiredChange = (event: React.SyntheticEvent) => { + this.setState({ hasFetched: false, includeExpired: event.currentTarget.checked }, this.fetchApiKeys); + }; + + onAddApiKey = (newApiKey: NewApiKey) => { + const openModal = (apiKey: string) => { + const rootPath = window.location.origin + config.appSubUrl; + const modalTemplate = ReactDOMServer.renderToString(); + + appEvents.emit(CoreEvents.showModal, { + templateHtml: modalTemplate, + }); + }; + + const secondsToLive = newApiKey.secondsToLive; + try { + const secondsToLiveAsNumber = secondsToLive ? rangeUtil.intervalToSeconds(secondsToLive) : null; + const apiKey: ApiKey = { + ...newApiKey, + secondsToLive: secondsToLiveAsNumber, + }; + this.props.addApiKey(apiKey, openModal, this.state.includeExpired); + this.setState((prevState: State) => { + return { + ...prevState, + isAdding: false, + }; + }); + } catch (err) { + console.error(err); + } + }; + + render() { + const { hasFetched, navModel, apiKeysCount, apiKeys, searchQuery, timeZone } = this.props; + const { includeExpired } = this.state; + + if (!hasFetched) { + return ( + + {} + + ); + } + + return ( + + + + {({ isAdding, toggleIsAdding }) => { + const showCTA = !isAdding && apiKeysCount === 0; + const showTable = apiKeysCount > 0; + return ( + <> + {showCTA ? ( + + ) : null} + {showTable ? ( + + ) : null} + + {showTable ? ( + <> +

Existing Keys

+ + + + ) : null} + + ); + }} +
+
+
+ ); + } +} + +const ApiKeysPage = connector(ApiKeysPageUnconnected); +export default hot(module)(ApiKeysPage); diff --git a/public/app/features/api-keys/ApiKeysTable.tsx b/public/app/features/api-keys/ApiKeysTable.tsx new file mode 100644 index 0000000..5620385 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysTable.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { DeleteButton } from '@grafana/ui'; +import { dateTimeFormat, TimeZone } from '@grafana/data'; + +import { ApiKey } from '../../types'; + +interface Props { + apiKeys: ApiKey[]; + timeZone: TimeZone; + onDelete: (apiKey: ApiKey) => void; +} + +export const ApiKeysTable: FC = ({ apiKeys, timeZone, onDelete }) => { + return ( + + + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map((key) => { + return ( + + + + + + + ); + })} + + ) : null} +
NameRoleExpires +
{key.name}{key.role}{formatDate(key.expiration, timeZone)} + onDelete(key)} /> +
+ ); +}; + +function formatDate(expiration: string | undefined, timeZone: TimeZone): string { + if (!expiration) { + return 'No expiration date'; + } + return dateTimeFormat(expiration, { timeZone }); +} diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts new file mode 100644 index 0000000..c244cb6 --- /dev/null +++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts @@ -0,0 +1,27 @@ +import { ApiKey, OrgRole } from 'app/types'; + +export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => { + const keys: ApiKey[] = []; + + for (let i = 1; i <= numberOfKeys; i++) { + keys.push({ + id: i, + name: `test-${i}`, + role: OrgRole.Viewer, + secondsToLive: 100, + expiration: '2019-06-04', + }); + } + + return keys; +}; + +export const getMockKey = (): ApiKey => { + return { + id: 1, + name: 'test', + role: OrgRole.Admin, + secondsToLive: 200, + expiration: '2019-06-04', + }; +}; diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap new file mode 100644 index 0000000..fb53f89 --- /dev/null +++ b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+

+ + + API Key Created + +

+ + + +
+
+
+
+ + Key + + + api key test + +
+
+
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now. +
+
+ You can authenticate request using the Authorization HTTP header, example: +
+
+
+        curl -H "Authorization: Bearer 
+        api key test
+        " 
+        test/path
+        /api/dashboards/home
+      
+
+
+
+`; diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts new file mode 100644 index 0000000..5256ee3 --- /dev/null +++ b/public/app/features/api-keys/state/actions.ts @@ -0,0 +1,31 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { ApiKey, ThunkResult } from 'app/types'; +import { apiKeysLoaded, setSearchQuery } from './reducers'; + +export function addApiKey( + apiKey: ApiKey, + openModal: (key: string) => void, + includeExpired: boolean +): ThunkResult { + return async (dispatch) => { + const result = await getBackendSrv().post('/api/auth/keys', apiKey); + dispatch(setSearchQuery('')); + dispatch(loadApiKeys(includeExpired)); + openModal(result.key); + }; +} + +export function loadApiKeys(includeExpired: boolean): ThunkResult { + return async (dispatch) => { + const response = await getBackendSrv().get('/api/auth/keys?includeExpired=' + includeExpired); + dispatch(apiKeysLoaded(response)); + }; +} + +export function deleteApiKey(id: number, includeExpired: boolean): ThunkResult { + return async (dispatch) => { + getBackendSrv() + .delete(`/api/auth/keys/${id}`) + .then(() => dispatch(loadApiKeys(includeExpired))); + }; +} diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts new file mode 100644 index 0000000..4ff9559 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.test.ts @@ -0,0 +1,27 @@ +import { apiKeysLoaded, apiKeysReducer, initialApiKeysState, setSearchQuery } from './reducers'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { ApiKeysState } from '../../../types'; + +describe('API Keys reducer', () => { + it('should set keys', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(apiKeysLoaded(getMultipleMockKeys(4))) + .thenStateShouldEqual({ + ...initialApiKeysState, + keys: getMultipleMockKeys(4), + hasFetched: true, + }); + }); + + it('should set search query', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(setSearchQuery('test query')) + .thenStateShouldEqual({ + ...initialApiKeysState, + searchQuery: 'test query', + }); + }); +}); diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts new file mode 100644 index 0000000..b76d268 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.ts @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { ApiKeysState } from 'app/types'; + +export const initialApiKeysState: ApiKeysState = { + keys: [], + searchQuery: '', + hasFetched: false, +}; + +const apiKeysSlice = createSlice({ + name: 'apiKeys', + initialState: initialApiKeysState, + reducers: { + apiKeysLoaded: (state, action): ApiKeysState => { + return { ...state, hasFetched: true, keys: action.payload }; + }, + setSearchQuery: (state, action): ApiKeysState => { + return { ...state, searchQuery: action.payload }; + }, + }, +}); + +export const { setSearchQuery, apiKeysLoaded } = apiKeysSlice.actions; + +export const apiKeysReducer = apiKeysSlice.reducer; + +export default { + apiKeys: apiKeysReducer, +}; diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts new file mode 100644 index 0000000..5e9ba51 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.test.ts @@ -0,0 +1,25 @@ +import { getApiKeys } from './selectors'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { ApiKeysState } from 'app/types'; + +describe('API Keys selectors', () => { + describe('Get API Keys', () => { + const mockKeys = getMultipleMockKeys(5); + + it('should return all keys if no search query', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false }; + + const keys = getApiKeys(mockState); + + expect(keys).toEqual(mockKeys); + }); + + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false }; + + const keys = getApiKeys(mockState); + + expect(keys.length).toEqual(1); + }); + }); +}); diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts new file mode 100644 index 0000000..1fbfe80 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.ts @@ -0,0 +1,11 @@ +import { ApiKeysState } from 'app/types'; + +export const getApiKeysCount = (state: ApiKeysState) => state.keys.length; + +export const getApiKeys = (state: ApiKeysState) => { + const regex = RegExp(state.searchQuery, 'i'); + + return state.keys.filter((key) => { + return regex.test(key.name) || regex.test(key.role); + }); +}; diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx new file mode 100644 index 0000000..716aa5e --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddPanelWidgetUnconnected as AddPanelWidget, Props } from './AddPanelWidget'; +import { DashboardModel, PanelModel } from '../../state'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboard: {} as DashboardModel, + panel: {} as PanelModel, + addPanel: jest.fn() as any, + updateLocation: jest.fn() as any, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx new file mode 100644 index 0000000..05af463 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -0,0 +1,305 @@ +import React, { useMemo, useState } from 'react'; +import { connect, MapDispatchToProps } from 'react-redux'; +import { css, cx, keyframes } from 'emotion'; +import _ from 'lodash'; +import tinycolor from 'tinycolor2'; +import { LocationUpdate } from '@grafana/runtime'; +import { Icon, IconButton, styleMixins, useStyles } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { DateTimeInput, GrafanaTheme } from '@grafana/data'; + +import config from 'app/core/config'; +import store from 'app/core/store'; +import { updateLocation } from 'app/core/actions'; +import { addPanel } from 'app/features/dashboard/state/reducers'; +import { DashboardModel, PanelModel } from '../../state'; +import { LibraryPanelsView } from '../../../library-panels/components/LibraryPanelsView/LibraryPanelsView'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LibraryPanelDTO } from '../../../library-panels/types'; +import { toPanelModelLibraryPanel } from '../../../library-panels/utils'; + +export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } }; + +export interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; +} + +export interface DispatchProps { + addPanel: typeof addPanel; + updateLocation: typeof updateLocation; +} + +export type Props = OwnProps & DispatchProps; + +const getCopiedPanelPlugins = () => { + const panels = _.chain(config.panels) + .filter({ hideFromList: false }) + .map((item) => item) + .value(); + const copiedPanels = []; + + const copiedPanelJson = store.get(LS_PANEL_COPY_KEY); + if (copiedPanelJson) { + const copiedPanel = JSON.parse(copiedPanelJson); + const pluginInfo: any = _.find(panels, { id: copiedPanel.type }); + if (pluginInfo) { + const pluginCopy = _.cloneDeep(pluginInfo); + pluginCopy.name = copiedPanel.title; + pluginCopy.sort = -1; + pluginCopy.defaults = copiedPanel; + copiedPanels.push(pluginCopy); + } + } + + return _.sortBy(copiedPanels, 'sort'); +}; + +export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard, updateLocation }) => { + const [addPanelView, setAddPanelView] = useState(false); + + const onCancelAddPanel = (evt: React.MouseEvent) => { + evt.preventDefault(); + dashboard.removePanel(panel); + }; + + const onBack = () => { + setAddPanelView(false); + }; + + const onCreateNewPanel = () => { + const { gridPos } = panel; + + const newPanel: Partial = { + type: 'graph', + title: 'Panel Title', + gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h }, + }; + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + + const location: LocationUpdate = { + query: { + editPanel: newPanel.id, + }, + partial: true, + }; + + updateLocation(location); + }; + + const onPasteCopiedPanel = (panelPluginInfo: PanelPluginInfo) => { + const { gridPos } = panel; + + const newPanel: any = { + type: panelPluginInfo.id, + title: 'Panel Title', + gridPos: { + x: gridPos.x, + y: gridPos.y, + w: panelPluginInfo.defaults.gridPos.w, + h: panelPluginInfo.defaults.gridPos.h, + }, + }; + + // apply panel template / defaults + if (panelPluginInfo.defaults) { + _.defaults(newPanel, panelPluginInfo.defaults); + newPanel.title = panelPluginInfo.defaults.title; + store.delete(LS_PANEL_COPY_KEY); + } + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + }; + + const onAddLibraryPanel = (panelInfo: LibraryPanelDTO) => { + const { gridPos } = panel; + + const newPanel: PanelModel = { + ...panelInfo.model, + gridPos, + libraryPanel: toPanelModelLibraryPanel(panelInfo), + }; + + dashboard.addPanel(newPanel); + dashboard.removePanel(panel); + }; + + const onCreateNewRow = () => { + const newRow: any = { + type: 'row', + title: 'Row title', + gridPos: { x: 0, y: 0 }, + }; + + dashboard.addPanel(newRow); + dashboard.removePanel(panel); + }; + + const styles = useStyles(getStyles); + const copiedPanelPlugins = useMemo(() => getCopiedPanelPlugins(), []); + + return ( +
+ + {addPanelView ? 'Add panel from panel library' : 'Add panel'} + + {addPanelView ? ( + dashboard.formatDate(dateString, 'L')} + onClickCard={(panel) => onAddLibraryPanel(panel)} + showSecondaryActions={false} + /> + ) : ( +
+
+
onCreateNewPanel()} aria-label={selectors.pages.AddDashboard.addNewPanel}> + + Add an empty panel +
+
+ + Add a new row +
+
+ {(config.featureToggles.panelLibrary || copiedPanelPlugins.length === 1) && ( +
+ {config.featureToggles.panelLibrary && ( +
setAddPanelView(true)}> + + Add a panel from the panel library +
+ )} + {copiedPanelPlugins.length === 1 && ( +
onPasteCopiedPanel(copiedPanelPlugins[0])}> + + Paste panel from clipboard +
+ )} +
+ )} +
+ )} +
+ ); +}; + +const mapDispatchToProps: MapDispatchToProps = { addPanel, updateLocation }; + +export const AddPanelWidget = connect(undefined, mapDispatchToProps)(AddPanelWidgetUnconnected); + +interface AddPanelWidgetHandleProps { + onCancel: (e: React.MouseEvent) => void; + onBack?: () => void; + children?: string; + styles: AddPanelStyles; +} + +const AddPanelWidgetHandle: React.FC = ({ children, onBack, onCancel, styles }) => { + return ( +
+ {onBack && ( +
+ +
+ )} + {!onBack && ( +
+ +
+ )} + {children && {children}} +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + const pulsate = keyframes` + 0% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline};} + 50% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${tinycolor(theme.colors.formFocusOutline) + .darken(20) + .toHexString()};} + 100% {box-shadow: 0 0 0 2px ${theme.colors.bodyBg}, 0 0 0px 4px ${theme.colors.formFocusOutline};} + `; + + return { + wrapper: css` + overflow: hidden; + outline: 2px dotted transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4; + animation: ${pulsate} 2s ease infinite; + `, + actionsRow: css` + display: flex; + flex-direction: row; + column-gap: ${theme.spacing.sm}; + height: 100%; + + > div { + justify-self: center; + cursor: pointer; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + color: ${theme.colors.text}; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + &:hover { + background: ${styleMixins.hoverColor(theme.colors.bg2, theme)}; + } + + &:hover > #book-icon { + background: linear-gradient(#f05a28 30%, #fbca0a 99%); + } + } + `, + actionsWrapper: css` + display: flex; + flex-direction: column; + row-gap: ${theme.spacing.sm}; + padding: 0 ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; + height: 100%; + `, + libraryPanelsWrapper: css` + padding: ${theme.spacing.sm}; + `, + headerRow: css` + display: flex; + align-items: center; + height: 38px; + flex-shrink: 0; + width: 100%; + font-size: ${theme.typography.size.md}; + font-weight: ${theme.typography.weight.semibold}; + padding-left: ${theme.spacing.sm}; + transition: background-color 0.1s ease-in-out; + cursor: move; + + &:hover { + background: ${theme.colors.bg2}; + } + `, + backButton: css` + display: flex; + align-items: center; + cursor: pointer; + padding-left: ${theme.spacing.xs}; + width: ${theme.spacing.xl}; + `, + noMargin: css` + margin: 0; + `, + }; +}; + +type AddPanelStyles = ReturnType; diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss new file mode 100644 index 0000000..ccb5d7f --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -0,0 +1,78 @@ +.add-panel-widget-container { + height: 100%; +} + +.add-panel-widget { + height: 100%; +} + +.add-panel-widget__header { + top: 0; + position: absolute; + padding: 0 8px; + display: flex; + align-items: center; + width: 100%; + cursor: move; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; + + .gicon { + font-size: 30px; + margin-right: $space-md; + } + + &:hover { + transition: background-color 0.1s ease-in-out; + background-color: $panel-header-hover-bg; + } +} + +.add-panel-widget__title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin-right: $space-xl; +} + +.add-panel-widget__link { + margin: 0 $space-sm; + width: 170px; + height: 88px !important; + flex-direction: column !important; +} + +.add-panel-widget__icon { + margin-bottom: $space-sm; + + .gicon { + color: white; + height: 44px; + width: 53px; + position: relative; + left: 5px; + } +} + +.add-panel-widget__create { + display: inherit; + margin-bottom: $space-lg; + // this is to have the big button appear centered + margin-top: 55px; +} + +.add-panel-widget__actions { + display: inherit; +} + +.add-panel-widget__action { + margin: 0 $space-xs; +} + +.add-panel-widget__btn-container { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap new file mode 100644 index 0000000..96bf6c3 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ + Add panel + +
+
+
+ + Add an empty panel +
+
+ + Add a new row +
+
+
+
+`; diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts new file mode 100644 index 0000000..b96948a --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/index.ts @@ -0,0 +1 @@ +export { AddPanelWidget } from './AddPanelWidget'; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts new file mode 100644 index 0000000..29c5e94 --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -0,0 +1,276 @@ +import _ from 'lodash'; +import config from 'app/core/config'; +import { DashboardExporter } from './DashboardExporter'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelPluginMeta } from '@grafana/data'; +import { variableAdapters } from '../../../variables/adapters'; +import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; +import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; + +function getStub(arg: string) { + return Promise.resolve(stubs[arg || 'gfdb']); +} + +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + getObject: jest.fn(), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getDataSourceSrv: () => ({ + get: jest.fn((arg) => getStub(arg)), + }), + config: { + buildInfo: {}, + panels: {}, + featureToggles: { + newVariables: false, + }, + }, +})); + +variableAdapters.register(createQueryVariableAdapter()); +variableAdapters.register(createConstantVariableAdapter()); +variableAdapters.register(createDataSourceVariableAdapter()); + +describe('given dashboard with repeated panels', () => { + let dash: any, exported: any; + + beforeEach((done) => { + dash = { + templating: { + list: [ + { + name: 'apps', + type: 'query', + datasource: 'gfdb', + current: { value: 'Asd', text: 'Asd' }, + options: [{ value: 'Asd', text: 'Asd' }], + }, + { + name: 'prefix', + type: 'constant', + current: { value: 'collectd', text: 'collectd' }, + options: [], + query: 'collectd', + }, + { + name: 'ds', + type: 'datasource', + query: 'other2', + current: { value: 'other2', text: 'other2' }, + options: [], + }, + ], + }, + annotations: { + list: [ + { + name: 'logs', + datasource: 'gfdb', + }, + ], + }, + panels: [ + { id: 6, datasource: 'gfdb', type: 'graph' }, + { id: 7 }, + { + id: 8, + datasource: '-- Mixed --', + targets: [{ datasource: 'other' }], + }, + { id: 9, datasource: '$ds' }, + { + id: 2, + repeat: 'apps', + datasource: 'gfdb', + type: 'graph', + }, + { id: 3, repeat: null, repeatPanelId: 2 }, + { + id: 4, + collapsed: true, + panels: [ + { id: 10, datasource: 'gfdb', type: 'table' }, + { id: 11 }, + { + id: 12, + datasource: '-- Mixed --', + targets: [{ datasource: 'other' }], + }, + { id: 13, datasource: '$ds' }, + { + id: 14, + repeat: 'apps', + datasource: 'gfdb', + type: 'heatmap', + }, + { id: 15, repeat: null, repeatPanelId: 14 }, + ], + }, + ], + }; + + config.buildInfo.version = '3.0.2'; + + config.panels['graph'] = { + id: 'graph', + name: 'Graph', + info: { version: '1.1.0' }, + } as PanelPluginMeta; + + config.panels['table'] = { + id: 'table', + name: 'Table', + info: { version: '1.1.1' }, + } as PanelPluginMeta; + + config.panels['heatmap'] = { + id: 'heatmap', + name: 'Heatmap', + info: { version: '1.1.2' }, + } as PanelPluginMeta; + + dash = new DashboardModel(dash, {}, () => dash.templating.list); + const exporter = new DashboardExporter(); + exporter.makeExportable(dash).then((clean) => { + exported = clean; + done(); + }); + }); + + it('should replace datasource refs', () => { + const panel = exported.panels[0]; + expect(panel.datasource).toBe('${DS_GFDB}'); + }); + + it('should replace datasource refs in collapsed row', () => { + const panel = exported.panels[5].panels[0]; + expect(panel.datasource).toBe('${DS_GFDB}'); + }); + + it('should replace datasource in variable query', () => { + expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}'); + expect(exported.templating.list[0].options.length).toBe(0); + expect(exported.templating.list[0].current.value).toBe(undefined); + expect(exported.templating.list[0].current.text).toBe(undefined); + }); + + it('should replace datasource in annotation query', () => { + expect(exported.annotations.list[1].datasource).toBe('${DS_GFDB}'); + }); + + it('should add datasource as input', () => { + expect(exported.__inputs[0].name).toBe('DS_GFDB'); + expect(exported.__inputs[0].pluginId).toBe('testdb'); + expect(exported.__inputs[0].type).toBe('datasource'); + }); + + it('should add datasource to required', () => { + const require: any = _.find(exported.__requires, { name: 'TestDB' }); + expect(require.name).toBe('TestDB'); + expect(require.id).toBe('testdb'); + expect(require.type).toBe('datasource'); + expect(require.version).toBe('1.2.1'); + }); + + it('should not add built in datasources to required', () => { + const require: any = _.find(exported.__requires, { name: 'Mixed' }); + expect(require).toBe(undefined); + }); + + it('should add datasources used in mixed mode', () => { + const require: any = _.find(exported.__requires, { name: 'OtherDB' }); + expect(require).not.toBe(undefined); + }); + + it('should add graph panel to required', () => { + const require: any = _.find(exported.__requires, { name: 'Graph' }); + expect(require.name).toBe('Graph'); + expect(require.id).toBe('graph'); + expect(require.version).toBe('1.1.0'); + }); + + it('should add table panel to required', () => { + const require: any = _.find(exported.__requires, { name: 'Table' }); + expect(require.name).toBe('Table'); + expect(require.id).toBe('table'); + expect(require.version).toBe('1.1.1'); + }); + + it('should add heatmap panel to required', () => { + const require: any = _.find(exported.__requires, { name: 'Heatmap' }); + expect(require.name).toBe('Heatmap'); + expect(require.id).toBe('heatmap'); + expect(require.version).toBe('1.1.2'); + }); + + it('should add grafana version', () => { + const require: any = _.find(exported.__requires, { name: 'Grafana' }); + expect(require.type).toBe('grafana'); + expect(require.id).toBe('grafana'); + expect(require.version).toBe('3.0.2'); + }); + + it('should add constant template variables as inputs', () => { + const input: any = _.find(exported.__inputs, { name: 'VAR_PREFIX' }); + expect(input.type).toBe('constant'); + expect(input.label).toBe('prefix'); + expect(input.value).toBe('collectd'); + }); + + it('should templatize constant variables', () => { + const variable: any = _.find(exported.templating.list, { name: 'prefix' }); + expect(variable.query).toBe('${VAR_PREFIX}'); + expect(variable.current.text).toBe('${VAR_PREFIX}'); + expect(variable.current.value).toBe('${VAR_PREFIX}'); + expect(variable.options[0].text).toBe('${VAR_PREFIX}'); + expect(variable.options[0].value).toBe('${VAR_PREFIX}'); + }); + + it('should add datasources only use via datasource variable to requires', () => { + const require: any = _.find(exported.__requires, { name: 'OtherDB_2' }); + expect(require.id).toBe('other2'); + }); +}); + +// Stub responses +const stubs: { [key: string]: {} } = {}; +stubs['gfdb'] = { + name: 'gfdb', + meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, +}; + +stubs['other'] = { + name: 'other', + meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, +}; + +stubs['other2'] = { + name: 'other2', + meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, +}; + +stubs['-- Mixed --'] = { + name: 'mixed', + meta: { + id: 'mixed', + info: { version: '1.2.1' }, + name: 'Mixed', + builtIn: true, + }, +}; + +stubs['-- Grafana --'] = { + name: '-- Grafana --', + meta: { + id: 'grafana', + info: { version: '1.2.1' }, + name: 'grafana', + builtIn: true, + }, +}; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts new file mode 100644 index 0000000..0b2a343 --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -0,0 +1,214 @@ +import _ from 'lodash'; + +import config from 'app/core/config'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state'; +import { PanelPluginMeta } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { VariableOption, VariableRefresh } from '../../../variables/types'; +import { isConstant, isQuery } from '../../../variables/guard'; + +interface Input { + name: string; + type: string; + label: string; + value: any; + description: string; +} + +interface Requires { + [key: string]: { + type: string; + id: string; + name: string; + version: string; + }; +} + +interface DataSources { + [key: string]: { + name: string; + label: string; + description: string; + type: string; + pluginId: string; + pluginName: string; + }; +} + +export class DashboardExporter { + makeExportable(dashboard: DashboardModel) { + // clean up repeated rows and panels, + // this is done on the live real dashboard instance, not on a clone + // so we need to undo this + // this is pretty hacky and needs to be changed + dashboard.cleanUpRepeats(); + + const saveModel = dashboard.getSaveModelClone(); + saveModel.id = null; + + // undo repeat cleanup + dashboard.processRepeats(); + + const inputs: Input[] = []; + const requires: Requires = {}; + const datasources: DataSources = {}; + const promises: Array> = []; + const variableLookup: { [key: string]: any } = {}; + + for (const variable of saveModel.getVariables()) { + variableLookup[variable.name] = variable; + } + + const templateizeDatasourceUsage = (obj: any) => { + let datasource: string = obj.datasource; + let datasourceVariable: any = null; + + // ignore data source properties that contain a variable + if (datasource && datasource.indexOf('$') === 0) { + datasourceVariable = variableLookup[datasource.substring(1)]; + if (datasourceVariable && datasourceVariable.current) { + datasource = datasourceVariable.current.value; + } + } + + promises.push( + getDataSourceSrv() + .get(datasource) + .then((ds) => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta.id, + name: ds.meta.name, + version: ds.meta.info.version || '1.0.0', + }; + + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + }; + + obj.datasource = '${' + refName + '}'; + }) + ); + }; + + const processPanel = (panel: PanelModel) => { + if (panel.datasource !== undefined && panel.datasource !== null) { + templateizeDatasourceUsage(panel); + } + + if (panel.targets) { + for (const target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); + } + } + } + + const panelDef: PanelPluginMeta = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; + } + }; + + // check up panel data sources + for (const panel of saveModel.panels) { + processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processPanel(rowPanel); + } + } + } + + // templatize template vars + for (const variable of saveModel.getVariables()) { + if (isQuery(variable)) { + templateizeDatasourceUsage(variable); + variable.options = []; + variable.current = ({} as unknown) as VariableOption; + variable.refresh = + variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; + } + } + + // templatize annotations vars + for (const annotationDef of saveModel.annotations.list) { + templateizeDatasourceUsage(annotationDef); + } + + // add grafana version + requires['grafana'] = { + type: 'grafana', + id: 'grafana', + name: 'Grafana', + version: config.buildInfo.version, + }; + + return Promise.all(promises) + .then(() => { + _.each(datasources, (value: any) => { + inputs.push(value); + }); + + // templatize constants + for (const variable of saveModel.getVariables()) { + if (isConstant(variable)) { + const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); + inputs.push({ + name: refName, + type: 'constant', + label: variable.label || variable.name, + value: variable.query, + description: '', + }); + // update current and option + variable.query = '${' + refName + '}'; + variable.current = { + value: variable.query, + text: variable.query, + selected: false, + }; + variable.options = [variable.current]; + } + } + + // make inputs and requires a top thing + const newObj: { [key: string]: {} } = {}; + newObj['__inputs'] = inputs; + newObj['__requires'] = _.sortBy(requires, ['id']); + + _.defaults(newObj, saveModel); + return newObj; + }) + .catch((err) => { + console.error('Export failed:', err); + return { + error: err, + }; + }); + } +} diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts new file mode 100644 index 0000000..f9afc4f --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/index.ts @@ -0,0 +1 @@ +export { DashboardExporter } from './DashboardExporter'; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx new file mode 100644 index 0000000..03833dd --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -0,0 +1,298 @@ +// Libaries +import React, { PureComponent, FC, ReactNode } from 'react'; +import { connect, MapDispatchToProps } from 'react-redux'; +// Utils & Services +import { appEvents } from 'app/core/app_events'; +import { PlaylistSrv } from 'app/features/playlist/playlist_srv'; +// Components +import { DashNavButton } from './DashNavButton'; +import { DashNavTimeControls } from './DashNavTimeControls'; +import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar } from '@grafana/ui'; +import { textUtil } from '@grafana/data'; +// State +import { updateLocation } from 'app/core/actions'; +import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; +// Types +import { DashboardModel } from '../../state'; +import { CoreEvents, StoreState } from 'app/types'; +import { ShareModal } from 'app/features/dashboard/components/ShareModal'; +import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; + +export interface OwnProps { + dashboard: DashboardModel; + isFullscreen: boolean; + $injector: any; + onAddPanel: () => void; +} + +interface DispatchProps { + updateTimeZoneForSession: typeof updateTimeZoneForSession; + updateLocation: typeof updateLocation; +} + +interface DashNavButtonModel { + show: (props: Props) => boolean; + component: FC>; + index?: number | 'end'; +} + +const customLeftActions: DashNavButtonModel[] = []; +const customRightActions: DashNavButtonModel[] = []; + +export function addCustomLeftAction(content: DashNavButtonModel) { + customLeftActions.push(content); +} + +export function addCustomRightAction(content: DashNavButtonModel) { + customRightActions.push(content); +} + +export interface StateProps { + location: any; +} + +type Props = StateProps & OwnProps & DispatchProps; + +class DashNav extends PureComponent { + playlistSrv: PlaylistSrv; + + constructor(props: Props) { + super(props); + this.playlistSrv = this.props.$injector.get('playlistSrv'); + } + + onFolderNameClick = () => { + this.props.updateLocation({ + query: { search: 'open', folder: 'current' }, + partial: true, + }); + }; + + onClose = () => { + this.props.updateLocation({ + query: { viewPanel: null }, + partial: true, + }); + }; + + onToggleTVMode = () => { + appEvents.emit(CoreEvents.toggleKioskMode); + }; + + onOpenSettings = () => { + this.props.updateLocation({ + query: { editview: 'settings' }, + partial: true, + }); + }; + + onStarDashboard = () => { + const { dashboard, $injector } = this.props; + const dashboardSrv = $injector.get('dashboardSrv'); + + dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then((newState: any) => { + dashboard.meta.isStarred = newState; + this.forceUpdate(); + }); + }; + + onPlaylistPrev = () => { + this.playlistSrv.prev(); + }; + + onPlaylistNext = () => { + this.playlistSrv.next(); + }; + + onPlaylistStop = () => { + this.playlistSrv.stop(); + this.forceUpdate(); + }; + + onDashboardNameClick = () => { + this.props.updateLocation({ + query: { search: 'open' }, + partial: true, + }); + }; + + addCustomContent(actions: DashNavButtonModel[], buttons: ReactNode[]) { + actions.map((action, index) => { + const Component = action.component; + const element = ; + typeof action.index === 'number' ? buttons.splice(action.index, 0, element) : buttons.push(element); + }); + } + + isInKioskMode() { + return !!this.props.location.query.kiosk; + } + + isPlaylistRunning() { + return this.playlistSrv.isPlaying; + } + + renderLeftActionsButton() { + const { dashboard } = this.props; + const { canStar, canShare, isStarred } = dashboard.meta; + const buttons: ReactNode[] = []; + + if (this.isInKioskMode() || this.isPlaylistRunning()) { + return []; + } + + if (canStar) { + buttons.push( + + ); + } + + if (canShare) { + buttons.push( + + {({ showModal, hideModal }) => ( + { + showModal(ShareModal, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} + + ); + } + + this.addCustomContent(customLeftActions, buttons); + return buttons; + } + + renderPlaylistControls() { + return ( + + + Stop playlist + + + ); + } + + renderRightActionsButton() { + const { dashboard, onAddPanel, location, updateTimeZoneForSession, isFullscreen } = this.props; + const { canEdit, showSettings } = dashboard.meta; + const { snapshot } = dashboard; + const snapshotUrl = snapshot && snapshot.originalUrl; + const buttons: ReactNode[] = []; + const tvButton = ( + + ); + + let timeControls: React.ReactNode | null; + + if (!dashboard.timepicker.hidden) { + timeControls = ( + + ); + } + + if (this.isPlaylistRunning()) { + return [this.renderPlaylistControls(), timeControls]; + } + + if (this.isInKioskMode()) { + return [timeControls, tvButton]; + } + + if (canEdit && !isFullscreen) { + buttons.push(); + buttons.push( + + {({ showModal, hideModal }) => ( + { + showModal(SaveDashboardModalProxy, { + dashboard, + onDismiss: hideModal, + }); + }} + /> + )} + + ); + } + + if (snapshotUrl) { + buttons.push( + this.gotoSnapshotOrigin(snapshotUrl)} + icon="link" + key="button-snapshot" + /> + ); + } + + if (showSettings) { + buttons.push( + + ); + } + + this.addCustomContent(customRightActions, buttons); + buttons.push(timeControls); + buttons.push(tvButton); + return buttons; + } + + gotoSnapshotOrigin(snapshotUrl: string) { + window.location.href = textUtil.sanitizeUrl(snapshotUrl); + } + + render() { + const { dashboard, isFullscreen } = this.props; + const onGoBack = isFullscreen ? this.onClose : undefined; + + return ( + + {this.renderRightActionsButton()} + + ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + location: state.location, +}); + +const mapDispatchToProps: MapDispatchToProps = { + updateLocation, + updateTimeZoneForSession, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DashNav); diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx new file mode 100644 index 0000000..c1f1377 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -0,0 +1,44 @@ +// Libraries +import React, { FunctionComponent } from 'react'; +import { css } from 'emotion'; +// Components +import { IconName, IconType, IconSize, IconButton, useTheme, stylesFactory } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; + +interface Props { + icon?: IconName; + tooltip: string; + onClick?: () => void; + href?: string; + children?: React.ReactNode; + iconType?: IconType; + iconSize?: IconSize; +} + +export const DashNavButton: FunctionComponent = ({ icon, iconType, iconSize, tooltip, onClick, children }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
+ {icon && ( + + )} + {children} +
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + noBorderContainer: css` + padding: 0 ${theme.spacing.xs}; + display: flex; + `, +})); diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx new file mode 100644 index 0000000..3706eb2 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -0,0 +1,127 @@ +// Libraries +import React, { Component } from 'react'; +import { dateMath, GrafanaTheme, TimeZone, TimeRange } from '@grafana/data'; +import { css } from 'emotion'; + +// Types +import { DashboardModel } from '../../state'; +import { LocationState, CoreEvents } from 'app/types'; + +// Components +import { RefreshPicker, withTheme, stylesFactory, Themeable, defaultIntervals } from '@grafana/ui'; +import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; + +// Utils & Services +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { appEvents } from 'app/core/core'; + +export interface Props extends Themeable { + dashboard: DashboardModel; + location: LocationState; + onChangeTimeZone: (timeZone: TimeZone) => void; +} +class UnthemedDashNavTimeControls extends Component { + componentDidMount() { + // Only reason for this is that sometimes time updates can happen via redux location changes + // and this happens before timeSrv has had chance to update state (as it listens to angular route-updated) + // This can be removed after timeSrv listens redux location + this.props.dashboard.on(CoreEvents.timeRangeUpdated, this.triggerForceUpdate); + } + + componentWillUnmount() { + this.props.dashboard.off(CoreEvents.timeRangeUpdated, this.triggerForceUpdate); + } + + triggerForceUpdate = () => { + this.forceUpdate(); + }; + + get refreshParamInUrl(): string { + return this.props.location.query.refresh as string; + } + + onChangeRefreshInterval = (interval: string) => { + getTimeSrv().setAutoRefresh(interval); + this.forceUpdate(); + }; + + onRefresh = () => { + getTimeSrv().refreshDashboard(); + return Promise.resolve(); + }; + + onMoveBack = () => { + appEvents.emit(CoreEvents.shiftTime, -1); + }; + + onMoveForward = () => { + appEvents.emit(CoreEvents.shiftTime, 1); + }; + + onChangeTimePicker = (timeRange: TimeRange) => { + const { dashboard } = this.props; + const panel = dashboard.timepicker; + const hasDelay = panel.nowDelay && timeRange.raw.to === 'now'; + + const adjustedFrom = dateMath.isMathString(timeRange.raw.from) ? timeRange.raw.from : timeRange.from; + const adjustedTo = dateMath.isMathString(timeRange.raw.to) ? timeRange.raw.to : timeRange.to; + const nextRange = { + from: adjustedFrom, + to: hasDelay ? 'now-' + panel.nowDelay : adjustedTo, + }; + + getTimeSrv().setTime(nextRange); + }; + + onChangeTimeZone = (timeZone: TimeZone) => { + this.props.dashboard.timezone = timeZone; + this.props.onChangeTimeZone(timeZone); + this.onRefresh(); + }; + + onZoom = () => { + appEvents.emit(CoreEvents.zoomOut, 2); + }; + + render() { + const { dashboard, theme } = this.props; + const { refresh_intervals } = dashboard.timepicker; + const intervals = getTimeSrv().getValidIntervals(refresh_intervals || defaultIntervals); + + const timePickerValue = getTimeSrv().timeRange(); + const timeZone = dashboard.getTimezone(); + const styles = getStyles(theme); + + return ( +
+ + +
+ ); + } +} + +export const DashNavTimeControls = withTheme(UnthemedDashNavTimeControls); + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + container: css` + position: relative; + display: flex; + `, + }; +}); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts new file mode 100644 index 0000000..be07fd0 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -0,0 +1,2 @@ +import DashNav from './DashNav'; +export { DashNav }; diff --git a/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx new file mode 100644 index 0000000..f0005e2 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx @@ -0,0 +1,122 @@ +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { Tooltip, Icon, Button } from '@grafana/ui'; +import { SlideDown } from 'app/core/components/Animations/SlideDown'; +import { StoreState } from 'app/types'; +import { DashboardAcl, PermissionLevel, NewDashboardAclItem } from 'app/types/acl'; +import { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +} from '../../state/actions'; +import { DashboardModel } from '../../state/DashboardModel'; +import PermissionList from 'app/core/components/PermissionList/PermissionList'; +import AddPermission from 'app/core/components/PermissionList/AddPermission'; +import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; + +const mapStateToProps = (state: StoreState) => ({ + permissions: state.dashboard.permissions, +}); + +const mapDispatchToProps = { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export interface OwnProps { + dashboard: DashboardModel; +} + +export type Props = OwnProps & ConnectedProps; + +export interface State { + isAdding: boolean; +} + +export class DashboardPermissionsUnconnected extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + isAdding: false, + }; + } + + componentDidMount() { + this.props.getDashboardPermissions(this.props.dashboard.id); + } + + onOpenAddPermissions = () => { + this.setState({ isAdding: true }); + }; + + onRemoveItem = (item: DashboardAcl) => { + this.props.removeDashboardPermission(this.props.dashboard.id, item); + }; + + onPermissionChanged = (item: DashboardAcl, level: PermissionLevel) => { + this.props.updateDashboardPermission(this.props.dashboard.id, item, level); + }; + + onAddPermission = (newItem: NewDashboardAclItem) => { + return this.props.addDashboardPermission(this.props.dashboard.id, newItem); + }; + + onCancelAddPermission = () => { + this.setState({ isAdding: false }); + }; + + getFolder() { + const { dashboard } = this.props; + + return { + id: dashboard.meta.folderId, + title: dashboard.meta.folderTitle, + url: dashboard.meta.folderUrl, + }; + } + + render() { + const { + permissions, + dashboard: { + meta: { hasUnsavedFolderChange }, + }, + } = this.props; + const { isAdding } = this.state; + + return hasUnsavedFolderChange ? ( +
You have changed folder, please save to view permissions.
+ ) : ( +
+
+

Permissions

+ }> + + +
+ +
+ + + + +
+ ); + } +} + +export const DashboardPermissions = connector(DashboardPermissionsUnconnected); diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx new file mode 100644 index 0000000..df944b0 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DashboardRow } from './DashboardRow'; +import { PanelModel } from '../../state/PanelModel'; + +describe('DashboardRow', () => { + let wrapper: any, panel: PanelModel, dashboardMock: any; + + beforeEach(() => { + dashboardMock = { + toggleRow: jest.fn(), + on: jest.fn(), + meta: { + canEdit: true, + }, + }; + + panel = new PanelModel({ collapsed: false }); + wrapper = mount(); + }); + + it('Should not have collapsed class when collaped is false', () => { + expect(wrapper.find('.dashboard-row')).toHaveLength(1); + expect(wrapper.find('.dashboard-row--collapsed')).toHaveLength(0); + }); + + it('Should collapse after clicking title', () => { + wrapper.find('.dashboard-row__title').simulate('click'); + + expect(wrapper.find('.dashboard-row--collapsed')).toHaveLength(1); + expect(dashboardMock.toggleRow.mock.calls).toHaveLength(1); + }); + + it('should have two actions as admin', () => { + expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2); + }); + + it('should not show row drag handle when cannot edit', () => { + dashboardMock.meta.canEdit = false; + wrapper = mount(); + expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0); + }); + + it('should have zero actions when cannot edit', () => { + dashboardMock.meta.canEdit = false; + panel = new PanelModel({ collapsed: false }); + wrapper = mount(); + expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx new file mode 100644 index 0000000..7ec347d --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Icon } from '@grafana/ui'; +import { PanelModel } from '../../state/PanelModel'; +import { DashboardModel } from '../../state/DashboardModel'; +import appEvents from 'app/core/app_events'; +import { CoreEvents } from 'app/types'; +import { RowOptionsButton } from '../RowOptions/RowOptionsButton'; +import { getTemplateSrv } from '@grafana/runtime'; + +export interface DashboardRowProps { + panel: PanelModel; + dashboard: DashboardModel; +} + +export class DashboardRow extends React.Component { + constructor(props: DashboardRowProps) { + super(props); + + this.state = { + collapsed: this.props.panel.collapsed, + }; + + this.props.dashboard.on(CoreEvents.templateVariableValueUpdated, this.onVariableUpdated); + } + + componentWillUnmount() { + this.props.dashboard.off(CoreEvents.templateVariableValueUpdated, this.onVariableUpdated); + } + + onVariableUpdated = () => { + this.forceUpdate(); + }; + + onToggle = () => { + this.props.dashboard.toggleRow(this.props.panel); + + this.setState((prevState: any) => { + return { collapsed: !prevState.collapsed }; + }); + }; + + onUpdate = (title: string, repeat: string | undefined) => { + this.props.panel['title'] = title; + this.props.panel['repeat'] = repeat; + this.props.panel.render(); + this.props.dashboard.processRepeats(); + this.forceUpdate(); + }; + + onDelete = () => { + appEvents.emit(CoreEvents.showConfirmModal, { + title: 'Delete Row', + text: 'Are you sure you want to remove this row and all its panels?', + altActionText: 'Delete row only', + icon: 'trash-alt', + onConfirm: () => { + this.props.dashboard.removeRow(this.props.panel, true); + }, + onAltAction: () => { + this.props.dashboard.removeRow(this.props.panel, false); + }, + }); + }; + + render() { + const classes = classNames({ + 'dashboard-row': true, + 'dashboard-row--collapsed': this.state.collapsed, + }); + + const title = getTemplateSrv().replace(this.props.panel.title, this.props.panel.scopedVars, 'text'); + const count = this.props.panel.panels ? this.props.panel.panels.length : 0; + const panels = count === 1 ? 'panel' : 'panels'; + const canEdit = this.props.dashboard.meta.canEdit === true; + + return ( +
+ + + {title} + + ({count} {panels}) + + + {canEdit && ( +
+ + + + +
+ )} + {this.state.collapsed === true && ( +
+   +
+ )} + {canEdit &&
} +
+ ); + } +} diff --git a/public/app/features/dashboard/components/DashboardRow/index.ts b/public/app/features/dashboard/components/DashboardRow/index.ts new file mode 100644 index 0000000..3f71e03 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardRow/index.ts @@ -0,0 +1 @@ +export { DashboardRow } from './DashboardRow'; diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx new file mode 100644 index 0000000..4be19d5 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx @@ -0,0 +1,30 @@ +import React, { PureComponent } from 'react'; +import { DashboardModel } from '../../state/DashboardModel'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; + +interface Props { + dashboard: DashboardModel; +} + +export class AnnotationsSettings extends PureComponent { + element?: HTMLElement | null; + angularCmp?: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = '
'; + const scopeProps = { dashboard: this.props.dashboard }; + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
(this.element = ref)} />; + } +} diff --git a/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx new file mode 100644 index 0000000..d400a42 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.test.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { defaultIntervals } from '@grafana/ui'; + +import { AutoRefreshIntervals, getValidIntervals, Props, validateIntervals } from './AutoRefreshIntervals'; +import { TimeSrv } from '../../services/TimeSrv'; + +const setupTestContext = (options: Partial) => { + const defaults: Props = { + refreshIntervals: ['1s', '5s', '10s'], + onRefreshIntervalChange: jest.fn(), + getIntervalsFunc: (intervals) => intervals, + validateIntervalsFunc: () => null, + }; + + const props = { ...defaults, ...options }; + const { rerender } = render(); + + return { rerender, props }; +}; + +describe('AutoRefreshIntervals', () => { + describe('when component is mounted with refreshIntervals', () => { + it('then supplied intervals should be shown', () => { + setupTestContext({ getIntervalsFunc: () => ['5s', '10s'] }); // remove 1s entry to validate we're calling getIntervalsFunc + + expect(screen.getByRole('textbox')).toHaveValue('5s,10s'); + }); + }); + + describe('when component is mounted without refreshIntervals', () => { + it('then default intervals should be shown', () => { + setupTestContext({ refreshIntervals: (null as unknown) as string[] }); + + expect(screen.getByRole('textbox')).toHaveValue('5s,10s,30s,1m,5m,15m,30m,1h,2h,1d'); + }); + }); + + describe('when component is updated from Angular', () => { + it('then intervals should be updated', () => { + const { rerender, props } = setupTestContext({}); + const newProps = { ...props, renderCount: 1, refreshIntervals: ['2s', '6s', '11s'] }; + + rerender(); + + expect(screen.getByRole('textbox')).toHaveValue('2s,6s,11s'); + }); + }); + + describe('when input loses focus and intervals are valid', () => { + it('then onRefreshIntervalChange should be called', () => { + const { props } = setupTestContext({ validateIntervalsFunc: () => null }); + + userEvent.type(screen.getByRole('textbox'), ',30s'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30s'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(1); + expect(props.onRefreshIntervalChange).toHaveBeenCalledWith(['1s', '5s', '10s', '30s']); + }); + }); + + describe('when input loses focus and intervals are invalid', () => { + it('then onRefreshIntervalChange should not be called', () => { + const { props } = setupTestContext({ validateIntervalsFunc: () => 'Not valid' }); + + userEvent.type(screen.getByRole('textbox'), ',30q'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30q'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(0); + }); + }); + + describe('when input loses focus and previous intervals were invalid', () => { + it('then onRefreshIntervalChange should be called', () => { + const validateIntervalsFunc = jest.fn().mockReturnValueOnce('Not valid').mockReturnValue(null); + const { props } = setupTestContext({ validateIntervalsFunc }); + + userEvent.type(screen.getByRole('textbox'), ',30q'); + userEvent.tab(); + userEvent.type(screen.getByRole('textbox'), '{backspace}s'); + userEvent.tab(); + + expect(screen.getByRole('textbox')).toHaveValue('1s,5s,10s,30s'); + expect(props.onRefreshIntervalChange).toHaveBeenCalledTimes(1); + expect(props.onRefreshIntervalChange).toHaveBeenCalledWith(['1s', '5s', '10s', '30s']); + }); + }); +}); + +describe('getValidIntervals', () => { + describe('when called with empty intervals', () => { + it('then is should all non empty intervals', () => { + const emptyIntervals = ['', '5s', ' ', '10s', ' ']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(emptyIntervals, dependencies); + + expect(result).toEqual(['5s', '10s']); + }); + }); + + describe('when called with duplicate intervals', () => { + it('then is should return no duplicates', () => { + const duplicateIntervals = ['5s', '10s', '1m', '5s', '30s', '10s', '5s', '2m']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(duplicateIntervals, dependencies); + + expect(result).toEqual(['5s', '10s', '1m', '30s', '2m']); + }); + }); + + describe('when called with untrimmed intervals', () => { + it('then is should return trimmed intervals', () => { + const duplicateIntervals = [' 5s', '10s ', ' 1m ', ' 3 0 s ', ' 2 m ']; + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = getValidIntervals(duplicateIntervals, dependencies); + + expect(result).toEqual(['5s', '10s', '1m', '30s', '2m']); + }); + }); +}); + +describe('validateIntervals', () => { + describe('when getValidIntervals does not throw', () => { + it('then it should return null', () => { + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: (intervals: any) => intervals, + } as unknown) as TimeSrv), + }; + + const result = validateIntervals(defaultIntervals, dependencies); + + expect(result).toBe(null); + }); + }); + + describe('when getValidIntervals throws', () => { + it('then it should return the exception message', () => { + const dependencies = { + getTimeSrv: () => + (({ + getValidIntervals: () => { + throw new Error('Some error'); + }, + } as unknown) as TimeSrv), + }; + + const result = validateIntervals(defaultIntervals, dependencies); + + expect(result).toEqual('Some error'); + }); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx new file mode 100644 index 0000000..3527542 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AutoRefreshIntervals.tsx @@ -0,0 +1,93 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { Input, defaultIntervals, Field } from '@grafana/ui'; + +import { getTimeSrv } from '../../services/TimeSrv'; + +export interface Props { + refreshIntervals: string[]; + onRefreshIntervalChange: (interval: string[]) => void; + getIntervalsFunc?: typeof getValidIntervals; + validateIntervalsFunc?: typeof validateIntervals; +} + +export const AutoRefreshIntervals: FC = ({ + refreshIntervals, + onRefreshIntervalChange, + getIntervalsFunc = getValidIntervals, + validateIntervalsFunc = validateIntervals, +}) => { + const [intervals, setIntervals] = useState(getIntervalsFunc(refreshIntervals ?? defaultIntervals)); + const [invalidIntervalsMessage, setInvalidIntervalsMessage] = useState(null); + + useEffect(() => { + const intervals = getIntervalsFunc(refreshIntervals ?? defaultIntervals); + setIntervals(intervals); + }, [refreshIntervals]); + + const intervalsString = useMemo(() => { + if (!Array.isArray(intervals)) { + return ''; + } + + return intervals.join(','); + }, [intervals]); + + const onIntervalsChange = useCallback( + (event: React.FormEvent) => { + const newIntervals = event.currentTarget.value ? event.currentTarget.value.split(',') : []; + + setIntervals(newIntervals); + }, + [setIntervals] + ); + + const onIntervalsBlur = useCallback( + (event: React.FormEvent) => { + const invalidMessage = validateIntervalsFunc(intervals); + + if (invalidMessage === null) { + // only refresh dashboard JSON if intervals are valid + onRefreshIntervalChange(getIntervalsFunc(intervals)); + } + + setInvalidIntervalsMessage(invalidMessage); + }, + [intervals, onRefreshIntervalChange, setInvalidIntervalsMessage] + ); + + return ( + + + + ); +}; + +export const validateIntervals = ( + intervals: string[], + dependencies: { getTimeSrv: typeof getTimeSrv } = { getTimeSrv } +): string | null => { + try { + getValidIntervals(intervals, dependencies); + return null; + } catch (err) { + return err.message; + } +}; + +export const getValidIntervals = ( + intervals: string[], + dependencies: { getTimeSrv: typeof getTimeSrv } = { getTimeSrv } +) => { + const cleanIntervals = intervals.filter((i) => i.trim() !== '').map((interval) => interval.replace(/\s+/g, '')); + return [...new Set(dependencies.getTimeSrv().getValidIntervals(cleanIntervals))]; +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx new file mode 100644 index 0000000..03db9cf --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -0,0 +1,199 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; +import { selectors } from '@grafana/e2e-selectors'; +import { Button, CustomScrollbar, Icon, IconName, PageToolbar, stylesFactory } from '@grafana/ui'; +import config from 'app/core/config'; +import { contextSrv } from 'app/core/services/context_srv'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { updateLocation } from 'app/core/actions'; +import { DashboardModel } from '../../state/DashboardModel'; +import { SaveDashboardButton, SaveDashboardAsButton } from '../SaveDashboard/SaveDashboardButton'; +import { VariableEditorContainer } from '../../../variables/editor/VariableEditorContainer'; +import { DashboardPermissions } from '../DashboardPermissions/DashboardPermissions'; +import { GeneralSettings } from './GeneralSettings'; +import { AnnotationsSettings } from './AnnotationsSettings'; +import { LinksSettings } from './LinksSettings'; +import { VersionsSettings } from './VersionsSettings'; +import { JsonEditorSettings } from './JsonEditorSettings'; +import { GrafanaTheme } from '@grafana/data'; +export interface Props { + dashboard: DashboardModel; + updateLocation: typeof updateLocation; + editview: string; +} + +export interface SettingsPage { + id: string; + title: string; + icon: IconName; + render: () => React.ReactNode; +} + +export class DashboardSettings extends PureComponent { + onClose = () => { + this.props.updateLocation({ + query: { editview: null }, + partial: true, + }); + }; + + onChangePage = (id: string) => { + this.props.updateLocation({ + query: { editview: id }, + partial: true, + }); + }; + + getPages(): SettingsPage[] { + const { dashboard } = this.props; + const pages: SettingsPage[] = []; + + if (dashboard.meta.canEdit) { + pages.push(this.getGeneralPage()); + + pages.push({ + title: 'Annotations', + id: 'annotations', + icon: 'comment-alt', + render: () => , + }); + + pages.push({ + title: 'Variables', + id: 'templating', + icon: 'calculator-alt', + render: () => , + }); + + pages.push({ + title: 'Links', + id: 'links', + icon: 'link', + render: () => , + }); + } + + if (dashboard.meta.canMakeEditable) { + pages.push({ + title: 'General', + icon: 'sliders-v-alt', + id: 'settings', + render: () => this.renderMakeEditable(), + }); + } + + if (dashboard.id && dashboard.meta.canSave) { + pages.push({ + title: 'Versions', + id: 'versions', + icon: 'history', + render: () => , + }); + } + + if (dashboard.id && dashboard.meta.canAdmin) { + pages.push({ + title: 'Permissions', + id: 'permissions', + icon: 'lock', + render: () => , + }); + } + + pages.push({ + title: 'JSON Model', + id: 'dashboard_json', + icon: 'arrow', + render: () => , + }); + + return pages; + } + + onMakeEditable = () => { + const { dashboard } = this.props; + dashboard.editable = true; + dashboard.meta.canMakeEditable = false; + dashboard.meta.canEdit = true; + dashboard.meta.canSave = true; + this.forceUpdate(); + }; + + onPostSave = () => { + this.props.dashboard.meta.hasUnsavedFolderChange = false; + dashboardWatcher.reloadPage(); + }; + + renderMakeEditable(): React.ReactNode { + return ( +
+
Dashboard not editable
+ +
+ ); + } + + getGeneralPage(): SettingsPage { + return { + title: 'General', + id: 'settings', + icon: 'sliders-v-alt', + render: () => , + }; + } + + render() { + const { dashboard, editview } = this.props; + const folderTitle = dashboard.meta.folderTitle; + const pages = this.getPages(); + const currentPage = pages.find((page) => page.id === editview) ?? pages[0]; + const canSaveAs = contextSrv.hasEditPermissionInFolders; + const canSave = dashboard.meta.canSave; + const styles = getStyles(config.theme); + + return ( +
+ + +
+
+ +
{currentPage.render()}
+
+
+
+
+ ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + scrollInner: css` + min-width: 100%; + min-height: 100%; + `, + settingsWrapper: css` + background: ${theme.colors.bg1}; + display: flex; + min-height: 100%; + width: 100%; + `, +})); diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx new file mode 100644 index 0000000..0038457 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { SelectableValue, TimeZone } from '@grafana/data'; +import { Select, TagsInput, Input, Field, CollapsableSection, RadioButtonGroup } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { DashboardModel } from '../../state/DashboardModel'; +import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'; +import { TimePickerSettings } from './TimePickerSettings'; + +interface Props { + dashboard: DashboardModel; +} + +const GRAPH_TOOLTIP_OPTIONS = [ + { value: 0, label: 'Default' }, + { value: 1, label: 'Shared crosshair' }, + { value: 2, label: 'Shared Tooltip' }, +]; + +export const GeneralSettings: React.FC = ({ dashboard }) => { + const [renderCounter, setRenderCounter] = useState(0); + + const onFolderChange = (folder: { id: number; title: string }) => { + dashboard.meta.folderId = folder.id; + dashboard.meta.folderTitle = folder.title; + dashboard.meta.hasUnsavedFolderChange = true; + }; + + const onBlur = (event: React.FocusEvent) => { + dashboard[event.currentTarget.name as 'title' | 'description'] = event.currentTarget.value; + }; + + const onTooltipChange = (graphTooltip: SelectableValue) => { + dashboard.graphTooltip = graphTooltip.value; + setRenderCounter(renderCounter + 1); + }; + + const onRefreshIntervalChange = (intervals: string[]) => { + dashboard.timepicker.refresh_intervals = intervals.filter((i) => i.trim() !== ''); + }; + + const onNowDelayChange = (nowDelay: string) => { + dashboard.timepicker.nowDelay = nowDelay; + }; + + const onHideTimePickerChange = (hide: boolean) => { + dashboard.timepicker.hidden = hide; + setRenderCounter(renderCounter + 1); + }; + + const onTimeZoneChange = (timeZone: TimeZone) => { + dashboard.timezone = timeZone; + setRenderCounter(renderCounter + 1); + }; + + const onTagsChange = (tags: string[]) => { + dashboard.tags = tags; + setRenderCounter(renderCounter + 1); + }; + + const onEditableChange = (value: boolean) => { + dashboard.editable = value; + setRenderCounter(renderCounter + 1); + }; + + const editableOptions = [ + { label: 'Editable', value: true }, + { label: 'Read-only', value: false }, + ]; + + return ( +
+

+ General +

+
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + ); + } +} diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx new file mode 100644 index 0000000..9fe0e5b --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { historySrv } from '../VersionHistory/HistorySrv'; +import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings'; +import { versions } from './__mocks__/versions'; + +jest.mock('../VersionHistory/HistorySrv'); + +describe('VersionSettings', () => { + const dashboard: any = { + id: 74, + version: 7, + formatDate: jest.fn(() => 'date'), + getRelativeTime: jest.fn(() => 'time ago'), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('renders a header and a loading indicator followed by results in a table', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions); + render(); + + expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument(); + expect(screen.queryByText(/fetching history list/i)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); + + expect(tableBodyRows.length).toBe(versions.length); + + const firstRow = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row')[0]; + + expect(within(firstRow).getByText(/latest/i)).toBeInTheDocument(); + expect(within(screen.getByRole('table')).getAllByText(/latest/i)).toHaveLength(1); + }); + + test('does not render buttons if versions === 1', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + }); + + test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions|/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).toBeInTheDocument(); + }); + + test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + render(); + + expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + const compareButton = screen.getByRole('button', { name: /compare versions/i }); + const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); + + expect(showMoreButton).toBeInTheDocument(); + expect(showMoreButton).toBeEnabled(); + + expect(compareButton).toBeInTheDocument(); + expect(compareButton).toBeDisabled(); + }); + + test('clicking show more appends results to the table', async () => { + historySrv.getHistoryList + // @ts-ignore + .mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) + .mockImplementationOnce(() => Promise.resolve(versions.slice(VERSIONS_FETCH_LIMIT, versions.length))); + + render(); + + expect(historySrv.getHistoryList).toBeCalledTimes(1); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(VERSIONS_FETCH_LIMIT); + + const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); + userEvent.click(showMoreButton); + + expect(historySrv.getHistoryList).toBeCalledTimes(2); + expect(screen.queryByText(/Fetching more entries/i)).toBeInTheDocument(); + + await waitFor(() => + expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(versions.length) + ); + }); + + test('selecting two versions and clicking compare button should render compare view', async () => { + // @ts-ignore + historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); + // @ts-ignore + historySrv.calculateDiff.mockResolvedValue('
'); + + render(); + + expect(historySrv.getHistoryList).toBeCalledTimes(1); + + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); + + const compareButton = screen.getByRole('button', { name: /compare versions/i }); + const tableBody = screen.getAllByRole('rowgroup')[1]; + userEvent.click(within(tableBody).getAllByRole('checkbox')[1]); + userEvent.click(within(tableBody).getAllByRole('checkbox')[4]); + + expect(compareButton).toBeEnabled(); + + userEvent.click(within(tableBody).getAllByRole('checkbox')[0]); + + expect(compareButton).toBeDisabled(); + // TODO: currently blows up due to angularLoader.load would be nice to assert the header... + // userEvent.click(compareButton); + // expect(historySrv.calculateDiff).toBeCalledTimes(1); + // await waitFor(() => expect(screen.getByTestId('angular-history-comparison')).toBeInTheDocument()); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx new file mode 100644 index 0000000..6d0b06f --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -0,0 +1,210 @@ +import React, { PureComponent } from 'react'; +import { Spinner, HorizontalGroup } from '@grafana/ui'; +import { DashboardModel } from '../../state/DashboardModel'; +import { historySrv, RevisionsModel, CalculateDiffOptions } from '../VersionHistory/HistorySrv'; +import { VersionHistoryTable } from '../VersionHistory/VersionHistoryTable'; +import { VersionHistoryHeader } from '../VersionHistory/VersionHistoryHeader'; +import { VersionsHistoryButtons } from '../VersionHistory/VersionHistoryButtons'; +import { VersionHistoryComparison } from '../VersionHistory/VersionHistoryComparison'; +interface Props { + dashboard: DashboardModel; +} + +type State = { + isLoading: boolean; + isAppending: boolean; + versions: DecoratedRevisionModel[]; + viewMode: 'list' | 'compare'; + delta: { basic: string; json: string }; + newInfo?: DecoratedRevisionModel; + baseInfo?: DecoratedRevisionModel; + isNewLatest: boolean; +}; + +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; + checked: boolean; +}; + +export const VERSIONS_FETCH_LIMIT = 10; + +export class VersionsSettings extends PureComponent { + limit: number; + start: number; + + constructor(props: Props) { + super(props); + this.limit = VERSIONS_FETCH_LIMIT; + this.start = 0; + this.state = { + delta: { + basic: '', + json: '', + }, + isAppending: true, + isLoading: true, + versions: [], + viewMode: 'list', + isNewLatest: false, + }; + } + + componentDidMount() { + this.getVersions(); + } + + getVersions = (append = false) => { + this.setState({ isAppending: append }); + historySrv + .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) + .then((res) => { + this.setState({ + isLoading: false, + versions: [...this.state.versions, ...this.decorateVersions(res)], + }); + this.start += this.limit; + }) + .catch((err) => console.log(err)) + .finally(() => this.setState({ isAppending: false })); + }; + + getDiff = (diff: string) => { + const selectedVersions = this.state.versions.filter((version) => version.checked); + const [newInfo, baseInfo] = selectedVersions; + const isNewLatest = newInfo.version === this.props.dashboard.version; + + this.setState({ + baseInfo, + isLoading: true, + isNewLatest, + newInfo, + viewMode: 'compare', + }); + + const options: CalculateDiffOptions = { + new: { + dashboardId: this.props.dashboard.id, + version: newInfo.version, + }, + base: { + dashboardId: this.props.dashboard.id, + version: baseInfo.version, + }, + diffType: diff, + }; + + return historySrv + .calculateDiff(options) + .then((response: any) => { + this.setState({ + // @ts-ignore + delta: { + [diff]: response, + }, + }); + }) + .catch(() => { + this.setState({ + viewMode: 'list', + }); + }) + .finally(() => { + this.setState({ + isLoading: false, + }); + }); + }; + + decorateVersions = (versions: RevisionsModel[]) => + versions.map((version) => ({ + ...version, + createdDateString: this.props.dashboard.formatDate(version.created), + ageString: this.props.dashboard.getRelativeTime(version.created), + checked: false, + })); + + isLastPage() { + return this.state.versions.find((rev) => rev.version === 1); + } + + onCheck = (ev: React.FormEvent, versionId: number) => { + this.setState({ + versions: this.state.versions.map((version) => + version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version + ), + }); + }; + + reset = () => { + this.setState({ + baseInfo: undefined, + delta: { basic: '', json: '' }, + isNewLatest: false, + newInfo: undefined, + versions: this.state.versions.map((version) => ({ ...version, checked: false })), + viewMode: 'list', + }); + }; + + render() { + const { versions, viewMode, baseInfo, newInfo, isNewLatest, isLoading, delta } = this.state; + const canCompare = versions.filter((version) => version.checked).length !== 2; + const showButtons = versions.length > 1; + const hasMore = versions.length >= this.limit; + + if (viewMode === 'compare') { + return ( +
+ + {isLoading ? ( + + ) : ( + + )} +
+ ); + } + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + {this.state.isAppending && } + {showButtons && ( + + )} +
+ ); + } +} + +const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( + + + {msg} + +); diff --git a/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts new file mode 100644 index 0000000..cfe1a8c --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/__mocks__/versions.ts @@ -0,0 +1,112 @@ +export const versions = [ + { + id: 249, + dashboardId: 74, + parentVersion: 10, + restoredFrom: 0, + version: 11, + created: '2021-01-15T14:44:44+01:00', + createdBy: 'admin', + message: 'Another day another change...', + }, + { + id: 247, + dashboardId: 74, + parentVersion: 9, + restoredFrom: 0, + version: 10, + created: '2021-01-15T10:19:17+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 246, + dashboardId: 74, + parentVersion: 8, + restoredFrom: 0, + version: 9, + created: '2021-01-15T10:18:12+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 245, + dashboardId: 74, + parentVersion: 7, + restoredFrom: 0, + version: 8, + created: '2021-01-15T10:11:16+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 239, + dashboardId: 74, + parentVersion: 6, + restoredFrom: 0, + version: 7, + created: '2021-01-14T15:14:25+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 237, + dashboardId: 74, + parentVersion: 5, + restoredFrom: 0, + version: 6, + created: '2021-01-14T14:55:29+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 236, + dashboardId: 74, + parentVersion: 4, + restoredFrom: 0, + version: 5, + created: '2021-01-14T14:28:01+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 218, + dashboardId: 74, + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2021-01-08T10:45:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 217, + dashboardId: 74, + parentVersion: 2, + restoredFrom: 0, + version: 3, + created: '2021-01-05T15:41:33+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 216, + dashboardId: 74, + parentVersion: 1, + restoredFrom: 0, + version: 2, + created: '2021-01-05T15:01:50+01:00', + createdBy: 'admin', + message: '', + }, + { + id: 215, + dashboardId: 74, + parentVersion: 1, + restoredFrom: 0, + version: 1, + created: '2021-01-05T14:59:15+01:00', + createdBy: 'admin', + message: '', + }, +]; diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts new file mode 100644 index 0000000..92fde59 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -0,0 +1 @@ +export { DashboardSettings } from './DashboardSettings'; diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx new file mode 100644 index 0000000..a32207f --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { DeleteDashboardModal } from './DeleteDashboardModal'; +import { Button, ModalsController } from '@grafana/ui'; +import { DashboardModel } from '../../state'; + +type Props = { + dashboard: DashboardModel; +}; + +export const DeleteDashboardButton = ({ dashboard }: Props) => ( + + {({ showModal, hideModal }) => ( + + )} + +); diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx new file mode 100644 index 0000000..8123b2e --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { css } from 'emotion'; +import sumBy from 'lodash/sumBy'; +import { Modal, ConfirmModal, HorizontalGroup, Button } from '@grafana/ui'; +import { DashboardModel, PanelModel } from '../../state'; +import { useDashboardDelete } from './useDashboardDelete'; + +type DeleteDashboardModalProps = { + hideModal(): void; + dashboard: DashboardModel; +}; + +export const DeleteDashboardModal: React.FC = ({ hideModal, dashboard }) => { + const isProvisioned = dashboard.meta.provisioned; + const { onRestoreDashboard } = useDashboardDelete(dashboard.uid); + const modalBody = getModalBody(dashboard.panels, dashboard.title); + + if (isProvisioned) { + return ; + } + + return ( + + ); +}; + +const getModalBody = (panels: PanelModel[], title: string) => { + const totalAlerts = sumBy(panels, (panel) => (panel.alert ? 1 : 0)); + return totalAlerts > 0 ? ( + <> +

Do you want to delete this dashboard?

+

+ This dashboard contains {totalAlerts} alert{totalAlerts > 1 ? 's' : ''}. Deleting this dashboard will also + delete those alerts +

+ + ) : ( + <> +

Do you want to delete this dashboard?

+

{title}

+ + ); +}; + +const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => ( + +

+ This dashboard is managed by Grafanas provisioning and cannot be deleted. Remove the dashboard from the config + file to delete it. +

+

+ + See{' '} + + documentation + {' '} + for more information about provisioning. + +
+ File path: {provisionedId} +

+ + + +
+); diff --git a/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx new file mode 100644 index 0000000..91a0429 --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { useAsyncFn } from 'react-use'; +import { AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { updateLocation } from 'app/core/reducers/location'; +import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; + +export const useDashboardDelete = (uid: string) => { + const dispatch = useDispatch(); + const [state, onRestoreDashboard] = useAsyncFn(() => deleteDashboard(uid, false), []); + useEffect(() => { + if (state.value) { + dispatch( + updateLocation({ + path: '/', + replace: true, + query: {}, + }) + ); + appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', state.value.title + ' has been deleted']); + } + }, [state]); + return { state, onRestoreDashboard }; +}; diff --git a/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts new file mode 100644 index 0000000..28cf2de --- /dev/null +++ b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts @@ -0,0 +1,205 @@ +import _ from 'lodash'; +import { IScope } from 'angular'; +import { AppEvents } from '@grafana/data'; + +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ValidationSrv } from 'app/features/manage-dashboards'; +import { ContextSrv } from 'app/core/services/context_srv'; +import { promiseToDigest } from '../../../../core/utils/promiseToDigest'; +import { createFolder } from 'app/features/manage-dashboards/state/actions'; + +export class FolderPickerCtrl { + initialTitle: string; + initialFolderId?: number; + labelClass: string; + onChange: any; + onLoad: any; + onCreateFolder: any; + enterFolderCreation: any; + exitFolderCreation: any; + enableCreateNew: boolean; + enableReset: boolean; + rootName = 'General'; + folder: any; + createNewFolder: boolean; + newFolderName: string; + newFolderNameTouched: boolean; + hasValidationError: boolean; + validationError: any; + isEditor: boolean; + dashboardId?: number; + + /** @ngInject */ + constructor(private validationSrv: ValidationSrv, private contextSrv: ContextSrv, private $scope: IScope) { + this.isEditor = this.contextSrv.isEditor; + + if (!this.labelClass) { + this.labelClass = 'width-7'; + } + + this.loadInitialValue(); + } + + getOptions(query: string) { + const params = { + query, + type: 'dash-folder', + permission: 'Edit', + }; + + return promiseToDigest(this.$scope)( + backendSrv.get('api/search', params).then((result: any) => { + if ( + this.isEditor && + (query === '' || + query.toLowerCase() === 'g' || + query.toLowerCase() === 'ge' || + query.toLowerCase() === 'gen' || + query.toLowerCase() === 'gene' || + query.toLowerCase() === 'gener' || + query.toLowerCase() === 'genera' || + query.toLowerCase() === 'general') + ) { + result.unshift({ title: this.rootName, id: 0 }); + } + + if (this.isEditor && this.enableCreateNew && query === '') { + result.unshift({ title: '-- New Folder --', id: -1 }); + } + + if (this.enableReset && query === '' && this.initialTitle !== '') { + result.unshift({ title: this.initialTitle, id: null }); + } + + return _.map(result, (item) => { + return { text: item.title, value: item.id }; + }); + }) + ); + } + + onFolderChange(option: { value: number; text: string }) { + if (!option) { + option = { value: 0, text: this.rootName }; + } else if (option.value === -1) { + this.createNewFolder = true; + this.enterFolderCreation(); + return; + } + this.onChange({ $folder: { id: option.value, title: option.text } }); + } + + newFolderNameChanged() { + this.newFolderNameTouched = true; + + this.validationSrv + .validateNewFolderName(this.newFolderName) + .then(() => { + this.hasValidationError = false; + }) + .catch((err: any) => { + this.hasValidationError = true; + this.validationError = err.message; + }); + } + + createFolder(evt: any) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + return promiseToDigest(this.$scope)( + createFolder({ title: this.newFolderName }).then((result: { title: string; id: number }) => { + appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); + + this.closeCreateFolder(); + this.folder = { text: result.title, value: result.id }; + this.onFolderChange(this.folder); + }) + ); + } + + cancelCreateFolder(evt: any) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + this.closeCreateFolder(); + this.loadInitialValue(); + } + + private closeCreateFolder() { + this.exitFolderCreation(); + this.createNewFolder = false; + this.hasValidationError = false; + this.validationError = null; + this.newFolderName = ''; + this.newFolderNameTouched = false; + } + + private loadInitialValue() { + const resetFolder: { text: string; value: any } = { text: this.initialTitle, value: null }; + const rootFolder: { text: string; value: any } = { text: this.rootName, value: 0 }; + + this.getOptions('').then((result: any[]) => { + let folder: { text: string; value: any } | undefined; + + if (this.initialFolderId) { + // @ts-ignore + folder = _.find(result, { value: this.initialFolderId }); + } else if (this.enableReset && this.initialTitle && this.initialFolderId === null) { + folder = resetFolder; + } + + if (!folder) { + if (this.isEditor) { + folder = rootFolder; + } else { + // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard + const isPersistedDashBoard = this.dashboardId ? true : false; + if (isPersistedDashBoard) { + folder = resetFolder; + } else { + folder = result.length > 0 ? result[0] : resetFolder; + } + } + } + + this.folder = folder; + + // if this is not the same as our initial value notify parent + if (this.folder.value !== this.initialFolderId) { + this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } }); + } + }); + } +} + +export function folderPicker() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html', + controller: FolderPickerCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + initialTitle: '<', + initialFolderId: '<', + labelClass: '@', + rootName: '@', + onChange: '&', + onCreateFolder: '&', + enterFolderCreation: '&', + exitFolderCreation: '&', + enableCreateNew: '@', + enableReset: '@', + dashboardId: '; + // The last raw response + data?: PanelData; + isDataLoading: boolean; + dataOptions: GetDataOptions; + // If the datasource supports custom metadata + metadataDatasource?: DataSourceApi; + onDataOptionsChange: (options: GetDataOptions) => void; + onClose: () => void; +} + +export const InspectContent: React.FC = ({ + panel, + plugin, + dashboard, + tabs, + data, + isDataLoading, + dataOptions, + metadataDatasource, + defaultTab, + onDataOptionsChange, + onClose, +}) => { + const [currentTab, setCurrentTab] = useState(defaultTab ?? InspectTab.Data); + + if (!plugin) { + return null; + } + + const styles = getPanelInspectorStyles(); + const error = data?.error; + + // Validate that the active tab is actually valid and allowed + let activeTab = currentTab; + if (!tabs.find((item) => item.value === currentTab)) { + activeTab = InspectTab.JSON; + } + const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text'); + + return ( + setCurrentTab(item.value || InspectTab.Data)} + /> + } + width="50%" + onClose={onClose} + expandable + > + {activeTab === InspectTab.Data && ( + + )} + + + {data && activeTab === InspectTab.Meta && ( + + )} + + {activeTab === InspectTab.JSON && ( + + )} + {activeTab === InspectTab.Error && } + {data && activeTab === InspectTab.Stats && } + {data && activeTab === InspectTab.Query && ( + panel.refresh()} /> + )} + + + + ); +}; diff --git a/public/app/features/dashboard/components/Inspector/PanelInspector.tsx b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx new file mode 100644 index 0000000..0e42391 --- /dev/null +++ b/public/app/features/dashboard/components/Inspector/PanelInspector.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useState } from 'react'; +import { connect, MapStateToProps, useDispatch } from 'react-redux'; + +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; + +import { PanelPlugin } from '@grafana/data'; +import { StoreState } from 'app/types'; +import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; +import { usePanelLatestData } from '../PanelEditor/usePanelLatestData'; +import { InspectContent } from './InspectContent'; +import { useDatasourceMetadata, useInspectTabs } from './hooks'; +import { updateLocation } from 'app/core/actions'; +import { InspectTab } from 'app/features/inspector/types'; + +interface OwnProps { + dashboard: DashboardModel; + panel: PanelModel; + defaultTab?: InspectTab; +} + +export interface ConnectedProps { + plugin?: PanelPlugin | null; +} + +export type Props = OwnProps & ConnectedProps; + +const PanelInspectorUnconnected: React.FC = ({ panel, dashboard, defaultTab, plugin }) => { + if (!plugin) { + return null; + } + + const dispatch = useDispatch(); + const [dataOptions, setDataOptions] = useState({ + withTransforms: false, + withFieldConfig: true, + }); + const { data, isLoading, error } = usePanelLatestData(panel, dataOptions); + const metaDs = useDatasourceMetadata(data); + const tabs = useInspectTabs(plugin, dashboard, error, metaDs); + const onClose = useCallback(() => { + dispatch( + updateLocation({ + query: { inspect: null, inspectTab: null }, + partial: true, + }) + ); + }, [updateLocation]); + + return ( + + ); +}; + +const mapStateToProps: MapStateToProps = (state, props) => { + const panelState = state.dashboard.panels[props.panel.id]; + if (!panelState) { + return { plugin: null }; + } + + return { + plugin: panelState.plugin, + }; +}; + +export const PanelInspector = connect(mapStateToProps)(PanelInspectorUnconnected); diff --git a/public/app/features/dashboard/components/Inspector/hooks.ts b/public/app/features/dashboard/components/Inspector/hooks.ts new file mode 100644 index 0000000..91dc117 --- /dev/null +++ b/public/app/features/dashboard/components/Inspector/hooks.ts @@ -0,0 +1,64 @@ +import { DataQueryError, DataSourceApi, PanelData, PanelPlugin } from '@grafana/data'; +import useAsync from 'react-use/lib/useAsync'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { useMemo } from 'react'; +import { supportsDataQuery } from '../PanelEditor/utils'; +import { InspectTab } from 'app/features/inspector/types'; + +/** + * Given PanelData return first data source supporting metadata inspector + */ +export const useDatasourceMetadata = (data?: PanelData) => { + const state = useAsync(async () => { + const targets = data?.request?.targets || []; + + if (data && data.series && targets.length) { + for (const frame of data.series) { + if (frame.meta && frame.meta.custom) { + // get data source from first query + const dataSource = await getDataSourceSrv().get(targets[0].datasource); + if (dataSource && dataSource.components?.MetadataInspector) { + return dataSource; + } + } + } + } + + return undefined; + }, [data]); + return state.value; +}; + +/** + * Configures tabs for PanelInspector + */ +export const useInspectTabs = ( + plugin: PanelPlugin, + dashboard: DashboardModel, + error?: DataQueryError, + metaDs?: DataSourceApi +) => { + return useMemo(() => { + const tabs = []; + if (supportsDataQuery(plugin)) { + tabs.push({ label: 'Data', value: InspectTab.Data }); + tabs.push({ label: 'Stats', value: InspectTab.Stats }); + } + + if (metaDs) { + tabs.push({ label: 'Meta Data', value: InspectTab.Meta }); + } + + tabs.push({ label: 'JSON', value: InspectTab.JSON }); + + if (error && error.message) { + tabs.push({ label: 'Error', value: InspectTab.Error }); + } + + if (dashboard.meta.canEdit && supportsDataQuery(plugin)) { + tabs.push({ label: 'Query', value: InspectTab.Query }); + } + return tabs; + }, [plugin, metaDs, dashboard, error]); +}; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx new file mode 100644 index 0000000..e0603c6 --- /dev/null +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { CollapsableSection, Button, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { LinkSettingsMode } from '../DashboardSettings/LinksSettings'; +import { DashboardLink, DashboardModel } from '../../state/DashboardModel'; + +const newLink = { + icon: 'external link', + title: '', + tooltip: '', + type: 'dashboards', + url: '', + asDropdown: false, + tags: [], + targetBlank: false, + keepTime: false, + includeVars: false, +} as DashboardLink; + +const linkTypeOptions = [ + { value: 'dashboards', label: 'Dashboards' }, + { value: 'link', label: 'Link' }, +]; + +export const linkIconMap: { [key: string]: string } = { + 'external link': 'external-link-alt', + dashboard: 'apps', + question: 'question-circle', + info: 'info-circle', + bolt: 'bolt', + doc: 'file-alt', + cloud: 'cloud', +}; + +const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key })); + +type LinkSettingsEditProps = { + mode: LinkSettingsMode; + editLinkIdx: number | null; + dashboard: DashboardModel; + backToList: () => void; +}; + +export const LinkSettingsEdit: React.FC = ({ mode, editLinkIdx, dashboard, backToList }) => { + const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink); + + const onTagsChange = (tags: any[]) => { + setLinkSettings((link) => ({ ...link, tags: tags })); + }; + + const onTypeChange = (selectedItem: SelectableValue) => { + setLinkSettings((link) => ({ ...link, type: selectedItem.value })); + }; + + const onIconChange = (selectedItem: SelectableValue) => { + setLinkSettings((link) => ({ ...link, icon: selectedItem.value })); + }; + + const onChange = (ev: React.FocusEvent) => { + const target = ev.currentTarget; + setLinkSettings((link) => ({ + ...link, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + })); + }; + + const addLink = () => { + dashboard.links = [...dashboard.links, linkSettings]; + dashboard.updateSubmenuVisibility(); + backToList(); + }; + + const updateLink = () => { + dashboard.links.splice(editLinkIdx!, 1, linkSettings); + dashboard.updateSubmenuVisibility(); + backToList(); + }; + + return ( +
+ + + + {linkSettings.type === 'dashboards' && ( + <> + + + + + )} + {linkSettings.type === 'link' && ( + <> + + + + + + + + } + ref={(elem) => elem && elem.focus()} + placeholder="Search all options" + suffix={ + setSearchMode(false)} className={styles.searchRemoveIcon} /> + } + /> +
+
+ ); + }} + + ); + } + + // Show the appropriate tabs + let tabs = tabSelections; + let active = tabs.find((v) => v.value === activeTab)!; + + // If no field configs hide Fields & Override tab + if (plugin.fieldConfigRegistry.isEmpty()) { + active = tabSelections[0]; + tabs = [active]; + } + + return ( + <> + {width < 352 ? ( +
+ onPanelConfigChange('title', e.currentTarget.value)} /> + + + + + + Copy to clipboard + +
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx new file mode 100644 index 0000000..0f4c14a --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -0,0 +1,114 @@ +import React, { PureComponent } from 'react'; +import { saveAs } from 'file-saver'; +import { Button, Field, Switch } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal'; +import { appEvents } from 'app/core/core'; +import { CoreEvents } from 'app/types'; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + shareExternally: boolean; +} + +export class ShareExport extends PureComponent { + private exporter: DashboardExporter; + + constructor(props: Props) { + super(props); + this.state = { + shareExternally: false, + }; + + this.exporter = new DashboardExporter(); + } + + onShareExternallyChange = () => { + this.setState({ + shareExternally: !this.state.shareExternally, + }); + }; + + onSaveAsFile = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + this.openSaveAsDialog(dashboardJson); + }); + } else { + this.openSaveAsDialog(dashboard.getSaveModelClone()); + } + }; + + onViewJson = () => { + const { dashboard } = this.props; + const { shareExternally } = this.state; + + if (shareExternally) { + this.exporter.makeExportable(dashboard).then((dashboardJson: any) => { + this.openJsonModal(dashboardJson); + }); + } else { + this.openJsonModal(dashboard.getSaveModelClone()); + } + }; + + openSaveAsDialog = (dash: any) => { + const dashboardJsonPretty = JSON.stringify(dash, null, 2); + const blob = new Blob([dashboardJsonPretty], { + type: 'application/json;charset=utf-8', + }); + const time = new Date().getTime(); + saveAs(blob, `${dash.title}-${time}.json`); + }; + + openJsonModal = (clone: object) => { + const model = { + object: clone, + enableCopy: true, + }; + + appEvents.emit(CoreEvents.showModal, { + src: 'public/app/partials/edit_json.html', + model, + }); + + this.props.onDismiss(); + }; + + render() { + const { onDismiss } = this.props; + const { shareExternally } = this.state; + + return ( +
+
+
+

Export this dashboard.

+ + + +
+ + + +
+
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx new file mode 100644 index 0000000..4cf7c65 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { setTemplateSrv } from '@grafana/runtime'; +import config from 'app/core/config'; +import { ShareLink, Props, State } from './ShareLink'; +import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv'; +import { variableAdapters } from '../../../variables/adapters'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => ({ + getTimeSrv: () => ({ + timeRange: () => { + return { from: new Date(1000), to: new Date(2000) }; + }, + }), +})); + +function mockLocationHref(href: string) { + const location = window.location; + + let search = ''; + const searchPos = href.indexOf('?'); + if (searchPos >= 0) { + search = href.substring(searchPos); + } + + //@ts-ignore + delete window.location; + (window as any).location = { + ...location, + href, + search, + }; +} + +function setUTCTimeZone() { + (window as any).Intl.DateTimeFormat = () => { + return { + resolvedOptions: () => { + return { timeZone: 'UTC' }; + }, + }; + }; +} + +const mockUid = 'abc123'; +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + post: jest.fn().mockResolvedValue({ + uid: mockUid, + url: `http://localhost:3000/goto/${mockUid}`, + }), + }), + }; +}); + +interface ScenarioContext { + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: any = { + setup: (fn: any) => { + setupFn = fn; + }, + mount: (propOverrides?: any) => { + const props: any = { + panel: undefined, + }; + + Object.assign(props, propOverrides); + ctx.wrapper = shallow(); + }, + }; + + beforeEach(() => { + setUTCTimeZone(); + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('ShareModal', () => { + let templateSrv = initTemplateSrv([]); + + beforeAll(() => { + variableAdapters.register(createQueryVariableAdapter()); + setTemplateSrv(templateSrv); + }); + + shareLinkScenario('shareUrl with current time range and panel', (ctx) => { + ctx.setup(() => { + mockLocationHref('http://server/#!/test'); + config.bootData = { + user: { + orgId: 1, + }, + }; + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + }); + + it('should generate share url absolute time', async () => { + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&viewPanel=22'); + }); + + it('should generate render url', async () => { + mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash'); + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should generate render url for scripted dashboard', async () => { + mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js'); + ctx.mount({ + panel: { id: 22, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js'; + const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(state?.imageUrl).toContain(base + params); + }); + + it('should remove panel id when no panel in scope', async () => { + ctx.mount({ + panel: undefined, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1'); + }); + + it('should add theme when specified', async () => { + ctx.wrapper?.setProps({ panel: undefined }); + ctx.wrapper?.setState({ selectedTheme: 'light' }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove editPanel from image url when is first param in querystring', async () => { + mockLocationHref('http://server/#!/test?editPanel=1'); + ctx.mount({ + panel: { id: 1, options: {}, fieldConfig: { defaults: {}, overrides: [] } }, + }); + + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain('?editPanel=1&from=1000&to=2000&orgId=1'); + expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); + + it('should shorten url', () => { + mockLocationHref('http://server/#!/test'); + ctx.mount(); + ctx.wrapper?.setState({ useShortUrl: true }, async () => { + await ctx.wrapper?.instance().buildUrl(); + const state = ctx.wrapper?.state(); + expect(state?.shareUrl).toContain(`/goto/${mockUid}`); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx new file mode 100644 index 0000000..0ece68d --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx @@ -0,0 +1,159 @@ +import React, { PureComponent } from 'react'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Field, RadioButtonGroup, Switch, ClipboardButton, Icon, InfoBox, Input, FieldSet } from '@grafana/ui'; +import { SelectableValue, PanelModel, AppEvents } from '@grafana/data'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { buildImageUrl, buildShareUrl } from './utils'; +import { appEvents } from 'app/core/core'; +import config from 'app/core/config'; + +const themeOptions: Array> = [ + { label: 'Current', value: 'current' }, + { label: 'Dark', value: 'dark' }, + { label: 'Light', value: 'light' }, +]; + +export interface Props { + dashboard: DashboardModel; + panel?: PanelModel; +} + +export interface State { + useCurrentTimeRange: boolean; + useShortUrl: boolean; + selectedTheme: string; + shareUrl: string; + imageUrl: string; +} + +export class ShareLink extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + useCurrentTimeRange: true, + useShortUrl: false, + selectedTheme: 'current', + shareUrl: '', + imageUrl: '', + }; + } + + componentDidMount() { + this.buildUrl(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state; + if ( + prevState.useCurrentTimeRange !== useCurrentTimeRange || + prevState.selectedTheme !== selectedTheme || + prevState.useShortUrl !== useShortUrl + ) { + this.buildUrl(); + } + } + + buildUrl = async () => { + const { panel } = this.props; + const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state; + + const shareUrl = await buildShareUrl(useCurrentTimeRange, selectedTheme, panel, useShortUrl); + const imageUrl = buildImageUrl(useCurrentTimeRange, selectedTheme, panel); + + this.setState({ shareUrl, imageUrl }); + }; + + onUseCurrentTimeRangeChange = () => { + this.setState({ useCurrentTimeRange: !this.state.useCurrentTimeRange }); + }; + + onUrlShorten = () => { + this.setState({ useShortUrl: !this.state.useShortUrl }); + }; + + onThemeChange = (value: string) => { + this.setState({ selectedTheme: value }); + }; + + onShareUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + getShareUrl = () => { + return this.state.shareUrl; + }; + + render() { + const { panel } = this.props; + const isRelativeTime = this.props.dashboard ? this.props.dashboard.time.to === 'now' : false; + const { useCurrentTimeRange, useShortUrl, selectedTheme, shareUrl, imageUrl } = this.state; + const selectors = e2eSelectors.pages.SharePanelModal; + + return ( +
+
+
+

+ Create a direct link to this dashboard or panel, customized with the options below. +

+
+ + + + + + + + + + + + + Copy + + } + /> + +
+ {panel && config.rendererAvailable && ( + + )} + {panel && !config.rendererAvailable && ( + +

+ <>To render a panel image, you must install the + + Grafana Image Renderer plugin + + . Please contact your Grafana administrator to install the plugin. +

+
+ )} +
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx new file mode 100644 index 0000000..1de8c0c --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { ShareLink } from './ShareLink'; +import { ShareSnapshot } from './ShareSnapshot'; +import { ShareExport } from './ShareExport'; +import { ShareEmbed } from './ShareEmbed'; +import { ShareModalTabModel } from './types'; +import { contextSrv } from 'app/core/core'; + +const customDashboardTabs: ShareModalTabModel[] = []; +const customPanelTabs: ShareModalTabModel[] = []; + +export function addDashboardShareTab(tab: ShareModalTabModel) { + customDashboardTabs.push(tab); +} + +export function addPanelShareTab(tab: ShareModalTabModel) { + customPanelTabs.push(tab); +} + +function getInitialState(props: Props): State { + const tabs = getTabs(props); + return { + tabs, + activeTab: tabs[0].value, + }; +} + +function getTabs(props: Props) { + const { panel } = props; + + const tabs: ShareModalTabModel[] = [{ label: 'Link', value: 'link', component: ShareLink }]; + + if (contextSrv.isSignedIn) { + tabs.push({ label: 'Snapshot', value: 'snapshot', component: ShareSnapshot }); + } + + if (panel) { + tabs.push({ label: 'Embed', value: 'embed', component: ShareEmbed }); + tabs.push(...customPanelTabs); + } else { + tabs.push({ label: 'Export', value: 'export', component: ShareExport }); + tabs.push(...customDashboardTabs); + } + + return tabs; +} + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + + onDismiss(): void; +} + +interface State { + tabs: ShareModalTabModel[]; + activeTab: string; +} + +export class ShareModal extends React.Component { + constructor(props: Props) { + super(props); + this.state = getInitialState(props); + } + + onDismiss = () => { + this.setState(getInitialState(this.props)); + this.props.onDismiss(); + }; + + onSelectTab = (t: any) => { + this.setState({ activeTab: t.value }); + }; + + getTabs() { + return getTabs(this.props); + } + + getActiveTab() { + const { tabs, activeTab } = this.state; + return tabs.find((t) => t.value === activeTab)!; + } + + renderTitle() { + const { panel } = this.props; + const { activeTab } = this.state; + const title = panel ? 'Share Panel' : 'Share'; + const tabs = this.getTabs(); + + return ( + + ); + } + + render() { + const { dashboard, panel } = this.props; + const activeTabModel = this.getActiveTab(); + const ActiveTab = activeTabModel.component; + + return ( + + + + + + ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx new file mode 100644 index 0000000..b31d59e --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -0,0 +1,307 @@ +import React, { PureComponent } from 'react'; +import { Button, ClipboardButton, Icon, Spinner, Select, Input, LinkButton, Field } from '@grafana/ui'; +import { AppEvents, SelectableValue } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { appEvents } from 'app/core/core'; +import { VariableRefresh } from '../../../variables/types'; + +const snapshotApiUrl = '/api/snapshots'; + +const expireOptions: Array> = [ + { label: 'Never', value: 0 }, + { label: '1 Hour', value: 60 * 60 }, + { label: '1 Day', value: 60 * 60 * 24 }, + { label: '7 Days', value: 60 * 60 * 24 * 7 }, +]; + +interface Props { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss(): void; +} + +interface State { + isLoading: boolean; + step: number; + snapshotName: string; + selectedExpireOption: SelectableValue; + snapshotExpires?: number; + snapshotUrl: string; + deleteUrl: string; + timeoutSeconds: number; + externalEnabled: boolean; + sharingButtonText: string; +} + +export class ShareSnapshot extends PureComponent { + private dashboard: DashboardModel; + + constructor(props: Props) { + super(props); + this.dashboard = props.dashboard; + this.state = { + isLoading: false, + step: 1, + selectedExpireOption: expireOptions[0], + snapshotExpires: expireOptions[0].value, + snapshotName: props.dashboard.title, + timeoutSeconds: 4, + snapshotUrl: '', + deleteUrl: '', + externalEnabled: false, + sharingButtonText: '', + }; + } + + componentDidMount() { + this.getSnaphotShareOptions(); + } + + async getSnaphotShareOptions() { + const shareOptions = await getBackendSrv().get('/api/snapshot/shared-options'); + this.setState({ + sharingButtonText: shareOptions['externalSnapshotName'], + externalEnabled: shareOptions['externalEnabled'], + }); + } + + createSnapshot = (external?: boolean) => () => { + const { timeoutSeconds } = this.state; + this.dashboard.snapshot = { + timestamp: new Date(), + }; + + if (!external) { + this.dashboard.snapshot.originalUrl = window.location.href; + } + + this.setState({ isLoading: true }); + this.dashboard.startRefresh(); + + setTimeout(() => { + this.saveSnapshot(this.dashboard, external); + }, timeoutSeconds * 1000); + }; + + saveSnapshot = async (dashboard: DashboardModel, external?: boolean) => { + const { snapshotExpires } = this.state; + const dash = this.dashboard.getSaveModelClone(); + this.scrubDashboard(dash); + + const cmdData = { + dashboard: dash, + name: dash.title, + expires: snapshotExpires, + external: external, + }; + + try { + const results: { deleteUrl: any; url: any } = await getBackendSrv().post(snapshotApiUrl, cmdData); + this.setState({ + deleteUrl: results.deleteUrl, + snapshotUrl: results.url, + step: 2, + }); + } finally { + this.setState({ isLoading: false }); + } + }; + + scrubDashboard = (dash: DashboardModel) => { + const { panel } = this.props; + const { snapshotName } = this.state; + // change title + dash.title = snapshotName; + + // make relative times absolute + dash.time = getTimeSrv().timeRange(); + + // remove panel queries & links + dash.panels.forEach((panel) => { + panel.targets = []; + panel.links = []; + panel.datasource = null; + }); + + // remove annotation queries + const annotations = dash.annotations.list.filter((annotation) => annotation.enable); + dash.annotations.list = annotations.map((annotation: any) => { + return { + name: annotation.name, + enable: annotation.enable, + iconColor: annotation.iconColor, + snapshotData: annotation.snapshotData, + type: annotation.type, + builtIn: annotation.builtIn, + hide: annotation.hide, + }; + }); + + // remove template queries + dash.getVariables().forEach((variable: any) => { + variable.query = ''; + variable.options = variable.current ? [variable.current] : []; + variable.refresh = VariableRefresh.never; + }); + + // snapshot single panel + if (panel) { + const singlePanel = panel.getSaveModel(); + singlePanel.gridPos.w = 24; + singlePanel.gridPos.x = 0; + singlePanel.gridPos.y = 0; + singlePanel.gridPos.h = 20; + dash.panels = [singlePanel]; + } + + // cleanup snapshotData + delete this.dashboard.snapshot; + this.dashboard.forEachPanel((panel: PanelModel) => { + delete panel.snapshotData; + }); + this.dashboard.annotations.list.forEach((annotation) => { + delete annotation.snapshotData; + }); + }; + + deleteSnapshot = async () => { + const { deleteUrl } = this.state; + await getBackendSrv().get(deleteUrl); + this.setState({ step: 3 }); + }; + + getSnapshotUrl = () => { + return this.state.snapshotUrl; + }; + + onSnapshotNameChange = (event: React.ChangeEvent) => { + this.setState({ snapshotName: event.target.value }); + }; + + onTimeoutChange = (event: React.ChangeEvent) => { + this.setState({ timeoutSeconds: Number(event.target.value) }); + }; + + onExpireChange = (option: SelectableValue) => { + this.setState({ + selectedExpireOption: option, + snapshotExpires: option.value, + }); + }; + + onSnapshotUrlCopy = () => { + appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + }; + + renderStep1() { + const { onDismiss } = this.props; + const { + snapshotName, + selectedExpireOption, + timeoutSeconds, + isLoading, + sharingButtonText, + externalEnabled, + } = this.state; + + return ( + <> +
+

+ A snapshot is an instant way to share an interactive dashboard publicly. When created, we{' '} + strip sensitive data like queries (metric, template and annotation) and panel links, + leaving only the visible metric data and series names embedded into your dashboard. +

+

+ Keep in mind, your snapshot can be viewed by anyone that has the link and can reach the + URL. Share wisely. +

+
+ + + + + + + +
+ + {externalEnabled && ( + + )} + +
+ + ); + } + + renderStep2() { + const { snapshotUrl } = this.state; + + return ( + <> +
+
+ + {snapshotUrl} + +
+ + Copy Link + +
+
+ +
+ Did you make a mistake?{' '} + + delete snapshot. + +
+ + ); + } + + renderStep3() { + return ( +
+

+ The snapshot has now been deleted. If you have already accessed it once, it might take up to an hour before it + is removed from browser caches or CDN caches. +

+
+ ); + } + + render() { + const { isLoading, step } = this.state; + + return ( +
+
+
+ {step === 1 && this.renderStep1()} + {step === 2 && this.renderStep2()} + {step === 3 && this.renderStep3()} + {isLoading && } +
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts new file mode 100644 index 0000000..e69de29 diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts new file mode 100644 index 0000000..2cccea8 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/index.ts @@ -0,0 +1,2 @@ +export { ShareModal, addDashboardShareTab, addPanelShareTab } from './ShareModal'; +export * from './types'; diff --git a/public/app/features/dashboard/components/ShareModal/types.ts b/public/app/features/dashboard/components/ShareModal/types.ts new file mode 100644 index 0000000..3627981 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/types.ts @@ -0,0 +1,17 @@ +import React from 'react'; +import { PanelModel } from '@grafana/data'; +import { DashboardModel } from 'app/features/dashboard/state'; + +export interface ShareModalTabProps { + dashboard: DashboardModel; + panel?: PanelModel; + onDismiss?(): void; +} + +export type ShareModalTab = React.ComponentType; + +export interface ShareModalTabModel { + label: string; + value: string; + component: ShareModalTab; +} diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts new file mode 100644 index 0000000..51569cc --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -0,0 +1,103 @@ +import { config } from '@grafana/runtime'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { createShortLink } from 'app/core/utils/shortLinks'; +import { PanelModel, dateTime, urlUtil } from '@grafana/data'; + +export function buildParams(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let params = urlUtil.getUrlSearchParams(); + + const range = getTimeSrv().timeRange(); + params.from = range.from.valueOf(); + params.to = range.to.valueOf(); + params.orgId = config.bootData.user.orgId; + + if (!useCurrentTimeRange) { + delete params.from; + delete params.to; + } + + if (selectedTheme !== 'current') { + params.theme = selectedTheme; + } + + if (panel && !params.editPanel) { + params.viewPanel = panel.id; + } + + return params; +} + +export function buildBaseUrl() { + let baseUrl = window.location.href; + const queryStart = baseUrl.indexOf('?'); + + if (queryStart !== -1) { + baseUrl = baseUrl.substring(0, queryStart); + } + + return baseUrl; +} + +export async function buildShareUrl( + useCurrentTimeRange: boolean, + selectedTheme?: string, + panel?: PanelModel, + shortenUrl?: boolean +) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, selectedTheme, panel); + const shareUrl = urlUtil.appendQueryToUrl(baseUrl, urlUtil.toUrlParams(params)); + if (shortenUrl) { + return await createShortLink(shareUrl); + } + return shareUrl; +} + +export function buildSoloUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + const baseUrl = buildBaseUrl(); + const params = buildParams(useCurrentTimeRange, selectedTheme, panel); + + let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/'); + soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/'); + + params.panelId = params.editPanel ?? params.viewPanel; + delete params.editPanel; + delete params.viewPanel; + + return urlUtil.appendQueryToUrl(soloUrl, urlUtil.toUrlParams(params)); +} + +export function buildImageUrl(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel); + + let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); + imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/'); + imageUrl += '&width=1000&height=500' + getLocalTimeZone(); + return imageUrl; +} + +export function buildIframeHtml(useCurrentTimeRange: boolean, selectedTheme?: string, panel?: PanelModel) { + let soloUrl = buildSoloUrl(useCurrentTimeRange, selectedTheme, panel); + return ''; +} + +export function getLocalTimeZone() { + const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z')); + + // Older browser does not the internationalization API + if (!(window as any).Intl) { + return utcOffset; + } + + const dateFormat = (window as any).Intl.DateTimeFormat(); + if (!dateFormat.resolvedOptions) { + return utcOffset; + } + + const options = dateFormat.resolvedOptions(); + if (!options.timeZone) { + return utcOffset; + } + + return '&tz=' + encodeURIComponent(options.timeZone); +} diff --git a/public/app/features/dashboard/components/SubMenu/Annotations.tsx b/public/app/features/dashboard/components/SubMenu/Annotations.tsx new file mode 100644 index 0000000..0679b81 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/Annotations.tsx @@ -0,0 +1,40 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { LegacyForms } from '@grafana/ui'; +import { AnnotationQuery } from '@grafana/data'; +const { Switch } = LegacyForms; + +interface Props { + annotations: AnnotationQuery[]; + onAnnotationChanged: (annotation: any) => void; +} + +export const Annotations: FunctionComponent = ({ annotations, onAnnotationChanged }) => { + const [visibleAnnotations, setVisibleAnnotations] = useState([]); + useEffect(() => { + setVisibleAnnotations(annotations.filter((annotation) => annotation.hide !== true)); + }, [annotations]); + + if (visibleAnnotations.length === 0) { + return null; + } + + return ( + <> + {visibleAnnotations.map((annotation: any) => { + return ( +
+ onAnnotationChanged(annotation)} + /> +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx new file mode 100644 index 0000000..bdc03e6 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -0,0 +1,73 @@ +import React, { FC, useReducer } from 'react'; +import { Icon, IconName, Tooltip } from '@grafana/ui'; +import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize'; +import { DashboardLinksDashboard } from './DashboardLinksDashboard'; +import { getLinkSrv } from '../../../panel/panellinks/link_srv'; + +import { DashboardModel } from '../../state'; +import { DashboardLink } from '../../state/DashboardModel'; +import { linkIconMap } from '../LinksSettings/LinkSettingsEdit'; +import { useEffectOnce } from 'react-use'; +import { CoreEvents } from 'app/types'; +import { selectors } from '@grafana/e2e-selectors'; + +export interface Props { + dashboard: DashboardModel; + links: DashboardLink[]; +} + +export const DashboardLinks: FC = ({ dashboard, links }) => { + if (!links.length) { + return null; + } + + // Emulate forceUpdate (https://reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate) + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + useEffectOnce(() => { + dashboard.on(CoreEvents.timeRangeUpdated, forceUpdate); + + return () => { + dashboard.off(CoreEvents.timeRangeUpdated, forceUpdate); + }; + }); + + useEffectOnce(() => { + dashboard.on(CoreEvents.submenuVisibilityChanged, forceUpdate); + return () => { + dashboard.off(CoreEvents.submenuVisibilityChanged, forceUpdate); + }; + }); + + return ( + <> + {links.map((link: DashboardLink, index: number) => { + const linkInfo = getLinkSrv().getAnchorInfo(link); + const key = `${link.title}-$${index}`; + + if (link.type === 'dashboards') { + return ; + } + + const linkElement = ( + + + {sanitize(linkInfo.title)} + + ); + + return ( +
+ {link.tooltip ? {linkElement} : linkElement} +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx new file mode 100644 index 0000000..23d17ef --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx @@ -0,0 +1,118 @@ +import { DashboardLink } from '../../state/DashboardModel'; +import { DashboardSearchHit, DashboardSearchItemType } from '../../../search/types'; +import { resolveLinks, searchForTags } from './DashboardLinksDashboard'; +import { describe, expect } from '../../../../../test/lib/common'; + +describe('searchForTags', () => { + const setupTestContext = () => { + const tags = ['A', 'B']; + const link: DashboardLink = { + targetBlank: false, + keepTime: false, + includeVars: false, + asDropdown: false, + icon: 'some icon', + tags, + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: '/d/6ieouugGk/DashLinks', + }; + const backendSrv: any = { + search: jest.fn((args) => []), + }; + + return { link, backendSrv }; + }; + + describe('when called', () => { + it('then tags from link should be used in search and limit should be 100', async () => { + const { link, backendSrv } = setupTestContext(); + + const results = await searchForTags(link.tags, { getBackendSrv: () => backendSrv }); + + expect(results.length).toEqual(0); + expect(backendSrv.search).toHaveBeenCalledWith({ tag: ['A', 'B'], limit: 100 }); + expect(backendSrv.search).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('resolveLinks', () => { + const setupTestContext = (dashboardId: number, searchHitId: number) => { + const link: DashboardLink = { + targetBlank: false, + keepTime: false, + includeVars: false, + asDropdown: false, + icon: 'some icon', + tags: [], + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: '/d/6ieouugGk/DashLinks', + }; + const searchHits: DashboardSearchHit[] = [ + { + id: searchHitId, + title: 'DashLinks', + url: '/d/6ieouugGk/DashLinks', + isStarred: false, + items: [], + tags: [], + uri: 'db/DashLinks', + type: DashboardSearchItemType.DashDB, + }, + ]; + const linkSrv: any = { + getLinkUrl: jest.fn((args) => args.url), + }; + const sanitize = jest.fn((args) => args); + const sanitizeUrl = jest.fn((args) => args); + + return { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl }; + }; + + describe('when called', () => { + it('should filter out the calling dashboardId', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 1); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(0); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0); + expect(sanitize).toHaveBeenCalledTimes(0); + expect(sanitizeUrl).toHaveBeenCalledTimes(0); + }); + + it('should resolve link url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledWith({ ...link, url: searchHits[0].url }); + }); + + it('should sanitize title', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchHits[0].title); + }); + + it('should sanitize url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitizeUrl).toHaveBeenCalledTimes(1); + expect(sanitizeUrl).toHaveBeenCalledWith(searchHits[0].url); + }); + }); +}); diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx new file mode 100644 index 0000000..576e333 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -0,0 +1,167 @@ +import React, { useRef, useState } from 'react'; +import { Icon, Tooltip } from '@grafana/ui'; +import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { getLinkSrv } from '../../../panel/panellinks/link_srv'; +import { DashboardLink } from '../../state/DashboardModel'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { selectors } from '@grafana/e2e-selectors'; +import { useAsync } from 'react-use'; + +interface Props { + link: DashboardLink; + linkInfo: { title: string; href: string }; + dashboardId: any; +} + +export const DashboardLinksDashboard: React.FC = (props) => { + const { link, linkInfo } = props; + const listRef = useRef(null); + const [opened, setOpened] = useState(0); + const resolvedLinks = useResolvedLinks(props, opened); + + if (link.asDropdown) { + return ( + + <> + setOpened(Date.now())} + className="gf-form-label gf-form-label--dashlink" + data-placement="bottom" + data-toggle="dropdown" + > + + {linkInfo.title} + +
    + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { + return ( +
  • + + {resolvedLink.title} + +
  • + ); + })} +
+ +
+ ); + } + + return ( + <> + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { + return ( + + + + {resolvedLink.title} + + + ); + })} + + ); +}; + +interface LinkElementProps { + link: DashboardLink; + 'aria-label': string; + key: string; + children: JSX.Element; +} + +const LinkElement: React.FC = (props) => { + const { link, children, ...rest } = props; + + return ( +
+ {link.tooltip && {children}} + {!link.tooltip && <>{children}} +
+ ); +}; + +const useResolvedLinks = ({ link, dashboardId }: Props, opened: number): ResolvedLinkDTO[] => { + const { tags } = link; + const result = useAsync(() => searchForTags(tags), [tags, opened]); + if (!result.value) { + return []; + } + return resolveLinks(dashboardId, link, result.value); +}; + +interface ResolvedLinkDTO { + id: any; + url: string; + title: string; +} + +export async function searchForTags( + tags: any[], + dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv } +): Promise { + const limit = 100; + const searchHits: DashboardSearchHit[] = await dependencies.getBackendSrv().search({ tag: tags, limit }); + + return searchHits; +} + +export function resolveLinks( + dashboardId: any, + link: DashboardLink, + searchHits: DashboardSearchHit[], + dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = { + getLinkSrv, + sanitize, + sanitizeUrl, + } +): ResolvedLinkDTO[] { + return searchHits + .filter((searchHit) => searchHit.id !== dashboardId) + .map((searchHit) => { + const id = searchHit.id; + const title = dependencies.sanitize(searchHit.title); + const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url }); + const url = dependencies.sanitizeUrl(resolvedLink); + + return { id, title, url }; + }); +} + +function getDropdownLocationCssClass(element: HTMLElement | null) { + if (!element) { + return 'invisible'; + } + + const wrapperPos = element.parentElement!.getBoundingClientRect(); + const pos = element.getBoundingClientRect(); + + if (pos.width === 0) { + return 'invisible'; + } + + if (wrapperPos.left + pos.width + 10 > window.innerWidth) { + return 'pull-left'; + } else { + return 'pull-right'; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx new file mode 100644 index 0000000..4c1411e --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -0,0 +1,81 @@ +import React, { PureComponent } from 'react'; +import { connect, MapStateToProps } from 'react-redux'; +import { StoreState } from '../../../../types'; +import { getSubMenuVariables } from '../../../variables/state/selectors'; +import { VariableHide, VariableModel } from '../../../variables/types'; +import { DashboardModel } from '../../state'; +import { DashboardLinks } from './DashboardLinks'; +import { Annotations } from './Annotations'; +import { SubMenuItems } from './SubMenuItems'; +import { DashboardLink } from '../../state/DashboardModel'; +import { AnnotationQuery } from '@grafana/data'; + +interface OwnProps { + dashboard: DashboardModel; + links: DashboardLink[]; + annotations: AnnotationQuery[]; +} + +interface ConnectedProps { + variables: VariableModel[]; +} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +class SubMenuUnConnected extends PureComponent { + onAnnotationStateChanged = (updatedAnnotation: any) => { + // we're mutating dashboard state directly here until annotations are in Redux. + for (let index = 0; index < this.props.dashboard.annotations.list.length; index++) { + const annotation = this.props.dashboard.annotations.list[index]; + if (annotation.name === updatedAnnotation.name) { + annotation.enable = !annotation.enable; + break; + } + } + this.props.dashboard.startRefresh(); + this.forceUpdate(); + }; + + isSubMenuVisible = () => { + if (this.props.dashboard.links.length > 0) { + return true; + } + + const visibleVariables = this.props.variables.filter((variable) => variable.hide !== VariableHide.hideVariable); + if (visibleVariables.length > 0) { + return true; + } + + const visibleAnnotations = this.props.dashboard.annotations.list.filter((annotation) => annotation.hide !== true); + return visibleAnnotations.length > 0; + }; + + render() { + const { dashboard, variables, links, annotations } = this.props; + + if (!this.isSubMenuVisible()) { + return null; + } + + return ( +
+ + +
+ {dashboard && } +
+
+ ); + } +} + +const mapStateToProps: MapStateToProps = (state) => { + return { + variables: getSubMenuVariables(state.templating.variables), + }; +}; + +export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected); +SubMenu.displayName = 'SubMenu'; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx new file mode 100644 index 0000000..ce20a18 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx @@ -0,0 +1,35 @@ +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { VariableHide, VariableModel } from '../../../variables/types'; +import { selectors } from '@grafana/e2e-selectors'; +import { PickerRenderer } from '../../../variables/pickers/PickerRenderer'; + +interface Props { + variables: VariableModel[]; +} + +export const SubMenuItems: FunctionComponent = ({ variables }) => { + const [visibleVariables, setVisibleVariables] = useState([]); + useEffect(() => { + setVisibleVariables(variables.filter((state) => state.hide !== VariableHide.hideVariable)); + }, [variables]); + + if (visibleVariables.length === 0) { + return null; + } + + return ( + <> + {visibleVariables.map((variable) => { + return ( +
+ +
+ ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx new file mode 100644 index 0000000..2b9f134 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { mergeMap } from 'rxjs/operators'; +import { css } from 'emotion'; +import { Icon, JSONFormatter, useStyles } from '@grafana/ui'; +import { + DataFrame, + DataTransformerConfig, + GrafanaTheme, + transformDataFrame, + TransformerRegistryItem, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { TransformationsEditorTransformation } from './types'; + +interface TransformationEditorProps { + debugMode?: boolean; + index: number; + data: DataFrame[]; + uiConfig: TransformerRegistryItem; + configs: TransformationsEditorTransformation[]; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationEditor = ({ + debugMode, + index, + data, + uiConfig, + configs, + onChange, +}: TransformationEditorProps) => { + const styles = useStyles(getStyles); + const [input, setInput] = useState([]); + const [output, setOutput] = useState([]); + const config = useMemo(() => configs[index], [configs, index]); + + useEffect(() => { + const inputTransforms = configs.slice(0, index).map((t) => t.transformation); + const outputTransforms = configs.slice(index, index + 1).map((t) => t.transformation); + const inputSubscription = transformDataFrame(inputTransforms, data).subscribe(setInput); + const outputSubscription = transformDataFrame(inputTransforms, data) + .pipe(mergeMap((before) => transformDataFrame(outputTransforms, before))) + .subscribe(setOutput); + + return function unsubscribe() { + inputSubscription.unsubscribe(); + outputSubscription.unsubscribe(); + }; + }, [index, data, configs]); + + const editor = useMemo( + () => + React.createElement(uiConfig.editor, { + options: { ...uiConfig.transformation.defaultOptions, ...config.transformation.options }, + input, + onChange: (opts: any) => { + onChange(index, { id: config.transformation.id, options: opts }); + }, + }), + [ + uiConfig.editor, + uiConfig.transformation.defaultOptions, + config.transformation.id, + config.transformation.options, + input, + onChange, + ] + ); + + return ( +
+ {editor} + {debugMode && ( +
+
+
Transformation input data
+
+ +
+
+
+ +
+
+
Transformation output data
+
{output && }
+
+
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + const debugBorder = theme.isLight ? theme.palette.gray85 : theme.palette.gray15; + + return { + title: css` + display: flex; + padding: 4px 8px 4px 8px; + position: relative; + height: 35px; + border-radius: 4px 4px 0 0; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + `, + name: css` + font-weight: ${theme.typography.weight.semibold}; + color: ${theme.colors.textBlue}; + `, + iconRow: css` + display: flex; + `, + icon: css` + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing.sm}; + &:hover { + color: ${theme.colors.text}; + } + `, + editor: css``, + debugWrapper: css` + display: flex; + flex-direction: row; + `, + debugSeparator: css` + width: 48px; + min-height: 300px; + display: flex; + align-items: center; + align-self: stretch; + justify-content: center; + margin: 0 ${theme.spacing.xs}; + color: ${theme.colors.textBlue}; + `, + debugTitle: css` + padding: ${theme.spacing.sm} ${theme.spacing.xxs}; + font-family: ${theme.typography.fontFamily.monospace}; + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.text}; + border-bottom: 1px solid ${debugBorder}; + flex-grow: 0; + flex-shrink: 1; + `, + + debug: css` + margin-top: ${theme.spacing.sm}; + padding: 0 ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; + border: 1px solid ${debugBorder}; + background: ${theme.isLight ? theme.palette.white : theme.palette.gray05}; + border-radius: ${theme.border.radius.sm}; + width: 100%; + min-height: 300px; + display: flex; + flex-direction: column; + align-self: stretch; + `, + debugJson: css` + flex-grow: 1; + height: 100%; + overflow: hidden; + padding: ${theme.spacing.xs}; + `, + }; +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx new file mode 100644 index 0000000..277becf --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { DataFrame, DataTransformerConfig, TransformerRegistryItem } from '@grafana/data'; +import { HorizontalGroup } from '@grafana/ui'; + +import { TransformationEditor } from './TransformationEditor'; +import { + QueryOperationRow, + QueryOperationRowRenderProps, +} from 'app/core/components/QueryOperationRow/QueryOperationRow'; +import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; +import { TransformationsEditorTransformation } from './types'; + +interface TransformationOperationRowProps { + id: string; + index: number; + data: DataFrame[]; + uiConfig: TransformerRegistryItem; + configs: TransformationsEditorTransformation[]; + onRemove: (index: number) => void; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationOperationRow: React.FC = ({ + onRemove, + index, + id, + data, + configs, + uiConfig, + onChange, +}) => { + const [showDebug, setShowDebug] = useState(false); + + const renderActions = ({ isOpen }: QueryOperationRowRenderProps) => { + return ( + + { + setShowDebug(!showDebug); + }} + /> + + onRemove(index)} /> + + ); + }; + + return ( + + + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx new file mode 100644 index 0000000..6477e0e --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRows.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { DataFrame, DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; + +import { TransformationOperationRow } from './TransformationOperationRow'; +import { TransformationsEditorTransformation } from './types'; + +interface TransformationOperationRowsProps { + data: DataFrame[]; + configs: TransformationsEditorTransformation[]; + onRemove: (index: number) => void; + onChange: (index: number, config: DataTransformerConfig) => void; +} + +export const TransformationOperationRows: React.FC = ({ + data, + onChange, + onRemove, + configs, +}) => { + return ( + <> + {configs.map((t, i) => { + const uiConfig = standardTransformersRegistry.getIfExists(t.transformation.id); + if (!uiConfig) { + return null; + } + + return ( + + ); + })} + + ); +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx new file mode 100644 index 0000000..5269a9a --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransformationsEditor } from './TransformationsEditor'; +import { PanelModel } from '../../state'; +import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { selectors } from '@grafana/e2e-selectors'; + +const setup = (transformations: DataTransformerConfig[] = []) => { + const panel = new PanelModel({}); + panel.setTransformations(transformations); + render(); +}; + +describe('TransformationsEditor', () => { + standardTransformersRegistry.setInit(getStandardTransformers); + + describe('when no transformations configured', () => { + it('renders transformations selection list', () => { + setup(); + + const cards = screen.getAllByLabelText(/^New transform/i); + expect(cards.length).toEqual(standardTransformersRegistry.list().length); + }); + }); + + describe('when transformations configured', () => { + it('renders transformation editors', () => { + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + const editors = screen.getAllByLabelText(/^Transformation editor/g); + expect(editors).toHaveLength(1); + }); + }); + + describe('when Add transformation clicked', () => { + it('renders transformations picker', () => { + const buttonLabel = 'Add transformation'; + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + + const addTransformationButton = screen.getByText(buttonLabel); + userEvent.click(addTransformationButton); + + const search = screen.getByLabelText(selectors.components.Transforms.searchInput); + expect(search).toBeDefined(); + }); + }); + + describe('actions', () => { + describe('debug', () => { + it('should show/hide debugger', () => { + setup([ + { + id: 'reduce', + options: {}, + }, + ]); + const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce'); + + expect(screen.queryByLabelText(debuggerSelector)).toBeNull(); + + const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug')); + userEvent.click(debugButton); + + expect(screen.getByLabelText(debuggerSelector)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx new file mode 100644 index 0000000..6d0e7d7 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -0,0 +1,391 @@ +import React, { ChangeEvent } from 'react'; +import { + Alert, + Button, + Container, + CustomScrollbar, + stylesFactory, + Themeable, + FeatureInfoBox, + useTheme, + VerticalGroup, + withTheme, + Input, + IconButton, +} from '@grafana/ui'; +import { + DataFrame, + DataTransformerConfig, + DocsId, + GrafanaTheme, + PanelData, + SelectableValue, + standardTransformersRegistry, +} from '@grafana/data'; +import { Card, CardProps } from '../../../../core/components/Card/Card'; +import { css } from 'emotion'; +import { selectors } from '@grafana/e2e-selectors'; +import { Unsubscribable } from 'rxjs'; +import { PanelModel } from '../../state'; +import { getDocsLink } from 'app/core/utils/docsLinks'; +import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; +import { TransformationOperationRows } from './TransformationOperationRows'; +import { TransformationsEditorTransformation } from './types'; +import { PanelNotSupported } from '../PanelEditor/PanelNotSupported'; +import { AppNotificationSeverity } from '../../../../types'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed'; + +interface TransformationsEditorProps extends Themeable { + panel: PanelModel; +} + +interface State { + data: DataFrame[]; + transformations: TransformationsEditorTransformation[]; + search: string; + showPicker?: boolean; +} + +class UnThemedTransformationsEditor extends React.PureComponent { + subscription?: Unsubscribable; + + constructor(props: TransformationsEditorProps) { + super(props); + const transformations = props.panel.transformations || []; + + const ids = this.buildTransformationIds(transformations); + this.state = { + transformations: transformations.map((t, i) => ({ + transformation: t, + id: ids[i], + })), + data: [], + search: '', + }; + } + + onSearchChange = (event: ChangeEvent) => { + this.setState({ search: event.target.value }); + }; + + onSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + const { search } = this.state; + if (search) { + const lower = search.toLowerCase(); + const filtered = standardTransformersRegistry.list().filter((t) => { + const txt = (t.name + t.description).toLowerCase(); + return txt.indexOf(lower) >= 0; + }); + if (filtered.length > 0) { + this.onTransformationAdd({ value: filtered[0].id }); + } + } + } else if (event.keyCode === 27) { + // Escape key + this.setState({ search: '', showPicker: false }); + event.stopPropagation(); // don't exit the editor + } + }; + + buildTransformationIds(transformations: DataTransformerConfig[]) { + const transformationCounters: Record = {}; + const transformationIds: string[] = []; + + for (let i = 0; i < transformations.length; i++) { + const transformation = transformations[i]; + if (transformationCounters[transformation.id] === undefined) { + transformationCounters[transformation.id] = 0; + } else { + transformationCounters[transformation.id] += 1; + } + transformationIds.push(`${transformations[i].id}-${transformationCounters[transformations[i].id]}`); + } + return transformationIds; + } + + componentDidMount() { + this.subscription = this.props.panel + .getQueryRunner() + .getData({ withTransforms: false, withFieldConfig: false }) + .subscribe({ + next: (panelData: PanelData) => this.setState({ data: panelData.series }), + }); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + onChange(transformations: TransformationsEditorTransformation[]) { + this.setState({ transformations }); + this.props.panel.setTransformations(transformations.map((t) => t.transformation)); + } + + // Transformation uid are stored in a name-X form. name is NOT unique hence we need to parse the ids and increase X + // for transformations with the same name + getTransformationNextId = (name: string) => { + const { transformations } = this.state; + let nextId = 0; + const existingIds = transformations.filter((t) => t.id.startsWith(name)).map((t) => t.id); + + if (existingIds.length !== 0) { + nextId = Math.max(...existingIds.map((i) => parseInt(i.match(/\d+/)![0], 10))) + 1; + } + + return `${name}-${nextId}`; + }; + + onTransformationAdd = (selectable: SelectableValue) => { + const { transformations } = this.state; + + const nextId = this.getTransformationNextId(selectable.value!); + this.setState({ search: '', showPicker: false }); + this.onChange([ + ...transformations, + { + id: nextId, + transformation: { + id: selectable.value as string, + options: {}, + }, + }, + ]); + }; + + onTransformationChange = (idx: number, config: DataTransformerConfig) => { + const { transformations } = this.state; + const next = Array.from(transformations); + next[idx].transformation = config; + this.onChange(next); + }; + + onTransformationRemove = (idx: number) => { + const { transformations } = this.state; + const next = Array.from(transformations); + next.splice(idx, 1); + this.onChange(next); + }; + + onDragEnd = (result: DropResult) => { + const { transformations } = this.state; + + if (!result || !result.destination) { + return; + } + + const startIndex = result.source.index; + const endIndex = result.destination.index; + if (startIndex === endIndex) { + return; + } + const update = Array.from(transformations); + const [removed] = update.splice(startIndex, 1); + update.splice(endIndex, 0, removed); + this.onChange(update); + }; + + renderTransformationEditors = () => { + const { data, transformations } = this.state; + + return ( + + + {(provided) => { + return ( +
+ + {provided.placeholder} +
+ ); + }} +
+
+ ); + }; + + renderTransformsPicker() { + const { transformations, search } = this.state; + let suffix: React.ReactNode = null; + let xforms = standardTransformersRegistry.list(); + if (search) { + const lower = search.toLowerCase(); + const filtered = xforms.filter((t) => { + const txt = (t.name + t.description).toLowerCase(); + return txt.indexOf(lower) >= 0; + }); + suffix = ( + <> + {filtered.length} / {xforms.length}    + { + this.setState({ search: '' }); + }} + /> + + ); + + xforms = filtered; + } + + const noTransforms = !transformations?.length; + const showPicker = noTransforms || this.state.showPicker; + if (!suffix && showPicker && !noTransforms) { + suffix = ( + { + this.setState({ showPicker: false }); + }} + /> + ); + } + + return ( + <> + {noTransforms && ( + + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + + return ( + { + onDismiss(true); + }} + url={getDocsLink(DocsId.Transformations)} + > +

+ Transformations allow you to join, calculate, re-order, hide and rename your query results before + being visualized.
+ Many transforms are not suitable if you're using the Graph visualization as it currently only + supports time series.
+ It can help to switch to Table visualization to understand what a transformation is doing.
+

+
+ ); + }} + +
+ )} + {showPicker ? ( + + + + {xforms.map((t) => { + return ( + Select} + ariaLabel={selectors.components.TransformTab.newTransform(t.name)} + onClick={() => { + this.onTransformationAdd({ value: t.id }); + }} + /> + ); + })} + + ) : ( + + )} + + ); + } + + render() { + const { + panel: { alert }, + } = this.props; + const { transformations } = this.state; + + const hasTransforms = transformations.length > 0; + + if (!hasTransforms && alert) { + return ; + } + + return ( + + +
+ {hasTransforms && alert ? ( + + ) : null} + {hasTransforms && this.renderTransformationEditors()} + {this.renderTransformsPicker()} +
+
+
+ ); + } +} + +const TransformationCard: React.FC = (props) => { + const theme = useTheme(); + const styles = getTransformationCardStyles(theme); + return ; +}; + +const getTransformationCardStyles = stylesFactory((theme: GrafanaTheme) => { + return { + card: css` + background: ${theme.colors.bg2}; + width: 100%; + border: none; + padding: ${theme.spacing.sm}; + + // hack because these cards use classes from a very different card for some reason + .add-data-source-item-text { + font-size: ${theme.typography.size.md}; + } + + &:hover { + background: ${theme.colors.bg3}; + box-shadow: none; + border: none; + } + `, + }; +}); + +export const TransformationsEditor = withTheme(UnThemedTransformationsEditor); diff --git a/public/app/features/dashboard/components/TransformationsEditor/types.ts b/public/app/features/dashboard/components/TransformationsEditor/types.ts new file mode 100644 index 0000000..50ff63d --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/types.ts @@ -0,0 +1,6 @@ +import { DataTransformerConfig } from '@grafana/data'; + +export interface TransformationsEditorTransformation { + transformation: DataTransformerConfig; + id: string; +} diff --git a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts new file mode 100644 index 0000000..b44d857 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts @@ -0,0 +1,161 @@ +import _ from 'lodash'; +import { IScope } from 'angular'; + +import { HistoryListCtrl } from './HistoryListCtrl'; +import { compare, restore, versions } from './__mocks__/history'; +import { CoreEvents } from 'app/types'; + +describe('HistoryListCtrl', () => { + const RESTORE_ID = 4; + + const versionsResponse: any = versions(); + + restore(7, RESTORE_ID); + + let historySrv: any; + let $rootScope: any; + const $scope: IScope = ({ $evalAsync: jest.fn() } as any) as IScope; + let historyListCtrl: any; + beforeEach(() => { + historySrv = { + calculateDiff: jest.fn(), + restoreDashboard: jest.fn(() => Promise.resolve({})), + }; + $rootScope = { + appEvent: jest.fn(), + onAppEvent: jest.fn(), + }; + }); + + describe('when the history list component is loaded', () => { + beforeEach(async () => { + historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse)); + historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope); + + historyListCtrl.dashboard = { + id: 2, + version: 3, + formatDate: jest.fn(() => 'date'), + getRelativeTime: jest.fn(() => 'time ago'), + }; + historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic'))); + historyListCtrl.delta = { + basic: '
', + json: '', + }; + historyListCtrl.baseInfo = { version: 1 }; + historyListCtrl.newInfo = { version: 2 }; + historyListCtrl.isNewLatest = false; + }); + + it('should have basic diff state', () => { + expect(historyListCtrl.delta.basic).toBe('
'); + expect(historyListCtrl.delta.json).toBe(''); + expect(historyListCtrl.diff).toBe('basic'); + }); + + it('should indicate loading has finished', () => { + expect(historyListCtrl.loading).toBe(false); + }); + + describe('and the json diff is successfully fetched', () => { + beforeEach(async () => { + historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('json'))); + await historyListCtrl.getDiff('json'); + }); + + it('should fetch the json diff', () => { + expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1); + expect(historyListCtrl.delta.json).toBe('
'); + }); + + it('should set the json diff view as active', () => { + expect(historyListCtrl.diff).toBe('json'); + }); + + it('should indicate loading has finished', () => { + expect(historyListCtrl.loading).toBe(false); + }); + }); + + describe('and diffs have already been fetched', () => { + beforeEach(async () => { + historySrv.calculateDiff = jest.fn(() => Promise.resolve(compare('basic'))); + historyListCtrl.delta.basic = 'cached basic'; + historyListCtrl.getDiff('basic'); + await historySrv.calculateDiff(); + }); + + it('should use the cached diffs instead of fetching', () => { + expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1); + expect(historyListCtrl.delta.basic).toBe('cached basic'); + }); + + it('should indicate loading has finished', () => { + expect(historyListCtrl.loading).toBe(false); + }); + }); + + describe('and fetching the diff fails', () => { + beforeEach(async () => { + historySrv.calculateDiff = jest.fn(() => Promise.reject()); + historyListCtrl.onFetchFail = jest.fn(); + historyListCtrl.delta = { + basic: '
', + json: '', + }; + await historyListCtrl.getDiff('json'); + }); + + it('should call calculateDiff', () => { + expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1); + }); + + it('should call onFetchFail', () => { + expect(historyListCtrl.onFetchFail).toBeCalledTimes(1); + }); + + it('should indicate loading has finished', () => { + expect(historyListCtrl.loading).toBe(false); + }); + + it('should have a default delta/changeset', () => { + expect(historyListCtrl.delta).toEqual({ basic: '
', json: '' }); + }); + }); + }); + + describe('when the user wants to restore a revision', () => { + beforeEach(async () => { + historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse)); + historySrv.restoreDashboard = jest.fn(() => Promise.resolve()); + + historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope); + + historyListCtrl.dashboard = { + id: 1, + }; + historyListCtrl.restore(); + historySrv.restoreDashboard = jest.fn(() => Promise.resolve(versionsResponse)); + }); + + it('should display a modal allowing the user to restore or cancel', () => { + expect($rootScope.appEvent).toHaveBeenCalledTimes(1); + expect($rootScope.appEvent.mock.calls[0][0]).toBe(CoreEvents.showConfirmModal); + }); + + describe('and restore fails to fetch', () => { + beforeEach(async () => { + historySrv.getHistoryList = jest.fn(() => Promise.resolve(versionsResponse)); + historySrv.restoreDashboard = jest.fn(() => Promise.resolve()); + historyListCtrl = new HistoryListCtrl({}, $rootScope, {} as any, historySrv, $scope); + historySrv.restoreDashboard = jest.fn(() => Promise.reject(new Error('RestoreError'))); + historyListCtrl.restoreConfirm(RESTORE_ID); + }); + + it('should indicate loading has finished', () => { + expect(historyListCtrl.loading).toBe(false); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts new file mode 100644 index 0000000..a2d2a9c --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts @@ -0,0 +1,114 @@ +import _ from 'lodash'; +import angular, { ILocationService, IScope } from 'angular'; + +import { DashboardModel } from '../../state/DashboardModel'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; +import { CalculateDiffOptions, HistorySrv } from './HistorySrv'; +import { AppEvents, locationUtil } from '@grafana/data'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; +import { CoreEvents } from 'app/types'; +import { promiseToDigest } from '../../../../core/utils/promiseToDigest'; + +export class HistoryListCtrl { + dashboard: DashboardModel; + delta: { basic: string; json: string }; + diff: string; + loading: boolean; + newInfo: DecoratedRevisionModel; + baseInfo: DecoratedRevisionModel; + isNewLatest: boolean; + onFetchFail: () => void; + + /** @ngInject */ + constructor( + private $route: any, + private $rootScope: GrafanaRootScope, + private $location: ILocationService, + private historySrv: HistorySrv, + public $scope: IScope + ) { + this.diff = 'basic'; + this.loading = false; + } + getDiff(diff: 'basic' | 'json') { + this.diff = diff; + + // has it already been fetched? + if (this.delta[diff]) { + return Promise.resolve(this.delta[diff]); + } + + this.loading = true; + const options: CalculateDiffOptions = { + new: { + dashboardId: this.dashboard.id, + version: this.newInfo.version, + }, + base: { + dashboardId: this.dashboard.id, + version: this.baseInfo.version, + }, + diffType: diff, + }; + + return promiseToDigest(this.$scope)( + this.historySrv + .calculateDiff(options) + .then((response: any) => { + // @ts-ignore + this.delta[this.diff] = response; + }) + .catch(this.onFetchFail) + .finally(() => { + this.loading = false; + }) + ); + } + + restore(version: number) { + this.$rootScope.appEvent(CoreEvents.showConfirmModal, { + title: 'Restore version', + text: '', + text2: `Are you sure you want to restore the dashboard to version ${version}? All unsaved changes will be lost.`, + icon: 'history', + yesText: `Yes, restore to version ${version}`, + onConfirm: this.restoreConfirm.bind(this, version), + }); + } + + restoreConfirm(version: number) { + this.loading = true; + return promiseToDigest(this.$scope)( + this.historySrv + .restoreDashboard(this.dashboard, version) + .then((response: any) => { + this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace(); + this.$route.reload(); + this.$rootScope.appEvent(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]); + }) + .catch(() => { + this.loading = false; + }) + ); + } +} + +export function dashboardHistoryDirective() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html', + controller: HistoryListCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + dashboard: '=', + delta: '=', + baseInfo: '=baseinfo', + newInfo: '=newinfo', + isNewLatest: '=isnewlatest', + onFetchFail: '=onfetchfail', + }, + }; +} + +angular.module('grafana.directives').directive('gfDashboardHistory', dashboardHistoryDirective); diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts new file mode 100644 index 0000000..6ce068e --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -0,0 +1,74 @@ +import { restore, versions } from './__mocks__/history'; +import { HistorySrv } from './HistorySrv'; +import { DashboardModel } from '../../state/DashboardModel'; + +const getMock = jest.fn().mockResolvedValue({}); +const postMock = jest.fn().mockResolvedValue({}); + +jest.mock('app/core/store'); +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + + return { + ...original, + getBackendSrv: () => ({ + post: postMock, + get: getMock, + }), + }; +}); + +describe('historySrv', () => { + const versionsResponse = versions(); + const restoreResponse = restore; + + let historySrv = new HistorySrv(); + + const dash = new DashboardModel({ id: 1 }); + const emptyDash = new DashboardModel({}); + const historyListOpts = { limit: 10, start: 0 }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getHistoryList', () => { + it('should return a versions array for the given dashboard id', () => { + getMock.mockImplementation(() => Promise.resolve(versionsResponse)); + historySrv = new HistorySrv(); + + return historySrv.getHistoryList(dash, historyListOpts).then((versions: any) => { + expect(versions).toEqual(versionsResponse); + }); + }); + + it('should return an empty array when not given an id', () => { + return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions: any) => { + expect(versions).toEqual([]); + }); + }); + + it('should return an empty array when not given a dashboard', () => { + return historySrv.getHistoryList((null as unknown) as DashboardModel, historyListOpts).then((versions: any) => { + expect(versions).toEqual([]); + }); + }); + }); + + describe('restoreDashboard', () => { + it('should return a success response given valid parameters', () => { + const version = 6; + postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); + historySrv = new HistorySrv(); + return historySrv.restoreDashboard(dash, version).then((response: any) => { + expect(response).toEqual(restoreResponse(version)); + }); + }); + + it('should return an empty object when not given an id', async () => { + historySrv = new HistorySrv(); + const rsp = await historySrv.restoreDashboard(emptyDash, 6); + expect(rsp).toEqual({}); + }); + }); +}); diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts new file mode 100644 index 0000000..8124f20 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import { DashboardModel } from '../../state/DashboardModel'; +import { getBackendSrv } from '@grafana/runtime'; + +export interface HistoryListOpts { + limit: number; + start: number; +} + +export interface RevisionsModel { + id: number; + checked: boolean; + dashboardId: number; + parentVersion: number; + version: number; + created: Date; + createdBy: string; + message: string; +} + +export interface CalculateDiffOptions { + new: DiffTarget; + base: DiffTarget; + diffType: string; +} + +export interface DiffTarget { + dashboardId: number; + version: number; + unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version +} + +export class HistorySrv { + getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + return id ? getBackendSrv().get(`api/dashboards/id/${id}/versions`, options) : Promise.resolve([]); + } + + calculateDiff(options: CalculateDiffOptions) { + return getBackendSrv().post('api/dashboards/calculate-diff', options); + } + + restoreDashboard(dashboard: DashboardModel, version: number) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + const url = `api/dashboards/id/${id}/restore`; + + return id && _.isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({}); + } +} + +const historySrv = new HistorySrv(); +export { historySrv }; + +coreModule.service('historySrv', HistorySrv); diff --git a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx new file mode 100644 index 0000000..831e1e8 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ConfirmModal } from '@grafana/ui'; +import { useDashboardRestore } from './useDashboardRestore'; +export interface RevertDashboardModalProps { + hideModal: () => void; + version: number; +} + +export const RevertDashboardModal: React.FC = ({ hideModal, version }) => { + // TODO: how should state.error be handled? + const { onRestoreDashboard } = useDashboardRestore(version); + + return ( + Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

+ } + confirmText={`Yes, restore to version ${version}`} + /> + ); +}; diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx new file mode 100644 index 0000000..fcb23f9 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { HorizontalGroup, Tooltip, Button } from '@grafana/ui'; + +type VersionsButtonsType = { + hasMore: boolean; + canCompare: boolean; + getVersions: (append: boolean) => void; + getDiff: (diff: string) => void; + isLastPage: boolean; +}; +export const VersionsHistoryButtons: React.FC = ({ + hasMore, + canCompare, + getVersions, + getDiff, + isLastPage, +}) => ( + + {hasMore && ( + + )} + + + + +); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx new file mode 100644 index 0000000..0639303 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -0,0 +1,47 @@ +import React, { PureComponent } from 'react'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { DashboardModel } from '../../state/DashboardModel'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; + +type DiffViewProps = { + dashboard: DashboardModel; + isNewLatest: boolean; + newInfo?: DecoratedRevisionModel; + baseInfo?: DecoratedRevisionModel; + delta: { basic: string; json: string }; + onFetchFail: () => void; +}; + +export class VersionHistoryComparison extends PureComponent { + element?: HTMLElement | null; + angularCmp?: AngularComponent; + + constructor(props: DiffViewProps) { + super(props); + } + + componentDidMount() { + const loader = getAngularLoader(); + const template = + ''; + const scopeProps = { + dashboard: this.props.dashboard, + delta: this.props.delta, + baseinfo: this.props.baseInfo, + newinfo: this.props.newInfo, + isnewlatest: this.props.isNewLatest, + onfetchfail: this.props.onFetchFail, + }; + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
(this.element = ref)} />; + } +} diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx new file mode 100644 index 0000000..d6d7780 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import noop from 'lodash/noop'; +import { Icon } from '@grafana/ui'; + +type VersionHistoryHeaderProps = { + isComparing?: boolean; + onClick?: () => void; + baseVersion?: number; + newVersion?: number; + isNewLatest?: boolean; +}; + +export const VersionHistoryHeader: React.FC = ({ + isComparing = false, + onClick = noop, + baseVersion = 0, + newVersion = 0, + isNewLatest = false, +}) => ( +

+ + Versions + + {isComparing && ( + + Comparing {baseVersion} {newVersion}{' '} + {isNewLatest && (Latest)} + + )} +

+); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx new file mode 100644 index 0000000..5831f5e --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; +import { RevertDashboardModal } from './RevertDashboardModal'; + +type VersionsTableProps = { + versions: DecoratedRevisionModel[]; + onCheck: (ev: React.FormEvent, versionId: number) => void; +}; +export const VersionHistoryTable: React.FC = ({ versions, onCheck }) => ( + + + + + + + + + + + + + {versions.map((version, idx) => ( + + + + + + + + + ))} + +
VersionDateUpdated ByNotes
+ onCheck(ev, version.id)} + /> + {version.version}{version.createdDateString}{version.createdBy}{version.message} + {idx === 0 ? ( + + ) : ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+); diff --git a/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts b/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts new file mode 100644 index 0000000..0fcd3b4 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts @@ -0,0 +1,177 @@ +export function versions() { + return [ + { + id: 4, + dashboardId: 1, + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 3, + dashboardId: 1, + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 2, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + version: 2, + created: '2017-02-22T17:29:52-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 1, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + slug: 'history-dashboard', + version: 1, + created: '2017-02-22T17:06:37-08:00', + createdBy: 'admin', + message: '', + }, + ]; +} + +export function compare(type: any) { + return type === 'basic' ? '
' : '
'; +} + +export function restore(version: any, restoredFrom?: any): any { + return { + dashboard: { + meta: { + type: 'db', + canSave: true, + canEdit: true, + canStar: true, + slug: 'history-dashboard', + expires: '0001-01-01T00:00:00Z', + created: '2017-02-21T18:40:45-08:00', + updated: '2017-04-11T21:31:22.59219665-07:00', + updatedBy: 'admin', + createdBy: 'admin', + version: version, + }, + dashboard: { + annotations: { + list: [], + }, + description: 'A random dashboard for implementing the history list', + editable: true, + gnetId: null, + graphTooltip: 0, + hideControls: false, + id: 1, + links: [], + restoredFrom: restoredFrom, + rows: [ + { + collapse: false, + height: '250px', + panels: [ + { + aliasColors: {}, + bars: false, + datasource: null, + fill: 1, + id: 1, + legend: { + avg: false, + current: false, + max: false, + min: false, + show: true, + total: false, + values: false, + }, + lines: true, + linewidth: 1, + nullPointMode: 'null', + percentage: false, + pointradius: 5, + points: false, + renderer: 'flot', + seriesOverrides: [], + span: 12, + stack: false, + steppedLine: false, + targets: [{}], + thresholds: [], + timeFrom: null, + timeShift: null, + title: 'Panel Title', + tooltip: { + shared: true, + sort: 0, + value_type: 'individual', + }, + type: 'graph', + xaxis: { + mode: 'time', + name: null, + show: true, + values: [], + }, + yaxes: [ + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + { + format: 'short', + label: null, + logBase: 1, + max: null, + min: null, + show: true, + }, + ], + }, + ], + repeat: null, + repeatIteration: null, + repeatRowId: null, + showTitle: false, + title: 'Dashboard Row', + titleSize: 'h6', + }, + ], + schemaVersion: 14, + style: 'dark', + tags: ['development'], + templating: { + list: [], + }, + time: { + from: 'now-6h', + 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: 'utc', + title: 'History Dashboard', + version: version, + }, + }, + message: 'Dashboard restored to version ' + version, + version: version, + }; +} diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts new file mode 100644 index 0000000..138de43 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/index.ts @@ -0,0 +1,2 @@ +export { HistoryListCtrl } from './HistoryListCtrl'; +export { HistorySrv } from './HistorySrv'; diff --git a/public/app/features/dashboard/components/VersionHistory/template.html b/public/app/features/dashboard/components/VersionHistory/template.html new file mode 100644 index 0000000..444ab08 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/template.html @@ -0,0 +1,39 @@ +
+ + Fetching changes… +
+ +
+ +
+

+ Version {{ctrl.newInfo.version}} updated by + {{ctrl.newInfo.createdBy}} + {{ctrl.newInfo.ageString}} + - {{ctrl.newInfo.message}} +

+

+ Version {{ctrl.baseInfo.version}} updated by + {{ctrl.baseInfo.createdBy}} + {{ctrl.baseInfo.ageString}} + - {{ctrl.baseInfo.message}} +

+
+ +
+
+
+ +
+ +
+ +
+
diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx new file mode 100644 index 0000000..4e917cb --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useAsyncFn } from 'react-use'; +import { AppEvents, locationUtil } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { updateLocation } from 'app/core/reducers/location'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { StoreState } from 'app/types'; +import { historySrv } from './HistorySrv'; +import { DashboardModel } from '../../state'; + +const restoreDashboard = async (version: number, dashboard: DashboardModel) => { + return await historySrv.restoreDashboard(dashboard, version); +}; + +export const useDashboardRestore = (version: number) => { + const dashboard = useSelector((state: StoreState) => state.dashboard.getModel()); + const dispatch = useDispatch(); + const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); + useEffect(() => { + if (state.value) { + const newUrl = locationUtil.stripBaseFromUrl(state.value.url); + dispatch( + updateLocation({ + path: newUrl, + replace: true, + query: {}, + }) + ); + dashboardWatcher.reloadPage(); + appEvents.emit(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]); + } + }, [state]); + return { state, onRestoreDashboard }; +}; diff --git a/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx b/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx new file mode 100644 index 0000000..dd71a90 --- /dev/null +++ b/public/app/features/dashboard/components/VizTypePicker/VizTypePicker.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useMemo } from 'react'; + +import config from 'app/core/config'; +import VizTypePickerPlugin from './VizTypePickerPlugin'; +import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; +import { css } from 'emotion'; + +export interface Props { + current: PanelPluginMeta; + onTypeChange: (newType: PanelPluginMeta) => void; + searchQuery: string; + onClose: () => void; +} + +export function getAllPanelPluginMeta(): PanelPluginMeta[] { + const allPanels = config.panels; + + return Object.keys(allPanels) + .filter((key) => allPanels[key]['hideFromList'] === false) + .map((key) => allPanels[key]) + .sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); +} + +export function filterPluginList( + pluginsList: PanelPluginMeta[], + searchQuery: string, + current: PanelPluginMeta +): PanelPluginMeta[] { + if (!searchQuery.length) { + return pluginsList.filter((p) => { + if (p.state === PluginState.deprecated) { + return current.id === p.id; + } + return true; + }); + } + const query = searchQuery.toLowerCase(); + const first: PanelPluginMeta[] = []; + const match: PanelPluginMeta[] = []; + for (const item of pluginsList) { + if (item.state === PluginState.deprecated && current.id !== item.id) { + continue; + } + const name = item.name.toLowerCase(); + const idx = name.indexOf(query); + if (idx === 0) { + first.push(item); + } else if (idx > 0) { + match.push(item); + } + } + return first.concat(match); +} + +export const VizTypePicker: React.FC = ({ searchQuery, onTypeChange, current }) => { + const theme = useTheme(); + const styles = getStyles(theme); + const pluginsList: PanelPluginMeta[] = useMemo(() => { + return getAllPanelPluginMeta(); + }, []); + + const getFilteredPluginList = useCallback((): PanelPluginMeta[] => { + return filterPluginList(pluginsList, searchQuery, current); + }, [searchQuery]); + + const renderVizPlugin = (plugin: PanelPluginMeta, index: number) => { + const isCurrent = plugin.id === current.id; + const filteredPluginList = getFilteredPluginList(); + + const matchesQuery = filteredPluginList.indexOf(plugin) > -1; + return ( + onTypeChange(plugin)} + /> + ); + }; + + const filteredPluginList = getFilteredPluginList(); + const hasResults = filteredPluginList.length > 0; + const renderList = filteredPluginList.concat(pluginsList.filter((p) => filteredPluginList.indexOf(p) === -1)); + + return ( +
+ {hasResults ? ( + renderList.map((plugin, index) => { + if (plugin.state === PluginState.deprecated) { + return null; + } + return renderVizPlugin(plugin, index); + }) + ) : ( + Could not find anything matching your query + )} +
+ ); +}; + +VizTypePicker.displayName = 'VizTypePicker'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + grid: css` + max-width: 100%; + display: grid; + grid-gap: ${theme.spacing.md}; + grid-template-columns: repeat(auto-fit, minmax(116px, 1fr)); + `, + }; +}); diff --git a/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx b/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx new file mode 100644 index 0000000..4aa96f0 --- /dev/null +++ b/public/app/features/dashboard/components/VizTypePicker/VizTypePickerPlugin.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; +import { Badge, BadgeProps, styleMixins, stylesFactory, useTheme } from '@grafana/ui'; +import { css, cx } from 'emotion'; +import { selectors } from '@grafana/e2e-selectors'; +import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../../plugins/PluginSignatureBadge'; + +interface Props { + isCurrent: boolean; + plugin: PanelPluginMeta; + onClick: () => void; + disabled: boolean; +} + +const VizTypePickerPlugin: React.FC = ({ isCurrent, plugin, onClick, disabled }) => { + const theme = useTheme(); + const styles = getStyles(theme); + const cssClass = cx({ + [styles.item]: true, + [styles.disabled]: disabled || plugin.state === PluginState.deprecated, + [styles.current]: isCurrent, + }); + + return ( +
+
{} : onClick} + title={isCurrent ? 'Click again to close this section' : plugin.name} + > +
+
+
+ {plugin.name} +
+ +
+
+
+ +
+
+ ); +}; + +VizTypePickerPlugin.displayName = 'VizTypePickerPlugin'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + wrapper: css` + position: relative; + `, + bg: css` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${theme.colors.bg2}; + border: 1px solid ${theme.colors.border2}; + border-radius: 3px; + transform: scale(1); + transform-origin: center; + transition: all 0.1s ease-in; + z-index: 0; + `, + item: css` + flex-shrink: 0; + flex-direction: column; + text-align: center; + cursor: pointer; + display: flex; + margin-right: 10px; + align-items: center; + justify-content: center; + padding-bottom: 6px; + height: 100px; + width: 100%; + position: relative; + + &:hover { + > div:first-child { + transform: scale(1.05); + border-color: ${theme.colors.formFocusOutline}; + } + } + `, + itemContent: css` + position: relative; + z-index: 1; + width: 100%; + `, + current: css` + label: currentVisualizationItem; + > div:first-child { + ${styleMixins.focusCss(theme)}; + } + `, + disabled: css` + opacity: 0.2; + filter: grayscale(1); + cursor: default; + pointer-events: none; + &:hover { + border: 1px solid ${theme.colors.border2}; + } + `, + name: css` + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: ${theme.typography.size.sm}; + text-align: center; + height: 23px; + font-weight: ${theme.typography.weight.semibold}; + padding: 0 10px; + width: 100%; + `, + img: css` + height: 55px; + `, + badge: css` + position: absolute; + background: ${theme.colors.bg1}; + bottom: ${theme.spacing.xs}; + right: ${theme.spacing.xs}; + z-index: 1; + `, + }; +}); + +export default VizTypePickerPlugin; + +interface PanelPluginBadgeProps { + plugin: PanelPluginMeta; +} +const PanelPluginBadge: React.FC = ({ plugin }) => { + const display = getPanelStateBadgeDisplayModel(plugin); + + if (isUnsignedPluginSignature(plugin.signature)) { + return ; + } + + if (!display) { + return null; + } + + return ; +}; + +function getPanelStateBadgeDisplayModel(panel: PanelPluginMeta): BadgeProps | null { + switch (panel.state) { + case PluginState.deprecated: + return { + text: 'Deprecated', + color: 'red', + tooltip: `${panel.name} panel is deprecated`, + }; + case PluginState.alpha: + return { + text: 'Alpha', + color: 'blue', + tooltip: `${panel.name} panel is experimental`, + }; + case PluginState.beta: + return { + text: 'Beta', + color: 'blue', + tooltip: `${panel.name} panel is in beta`, + }; + default: + return null; + } +} + +PanelPluginBadge.displayName = 'PanelPluginBadge'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx new file mode 100644 index 0000000..fb37911 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { DashboardPage, mapStateToProps, Props, State } from './DashboardPage'; +import { DashboardModel } from '../state'; +import { mockToolkitActionCreator } from 'test/core/redux/mocks'; +import { DashboardInitPhase, DashboardRouteInfo } from 'app/types'; +import { notifyApp, updateLocation } from 'app/core/actions'; +import { cleanUpDashboardAndVariables } from '../state/actions'; + +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); + +interface ScenarioContext { + cleanUpDashboardAndVariablesMock: typeof cleanUpDashboardAndVariables; + dashboard?: DashboardModel | null; + setDashboardProp: (overrides?: any, metaOverrides?: any) => void; + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + cleanUpDashboardAndVariablesMock: jest.fn(), + setup: (fn) => { + setupFn = fn; + }, + setDashboardProp: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + ctx.wrapper?.setProps({ dashboard: ctx.dashboard }); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + urlSlug: 'my-dash', + $scope: {}, + urlUid: '11', + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + initDashboard: jest.fn(), + updateLocation: mockToolkitActionCreator(updateLocation), + notifyApp: mockToolkitActionCreator(notifyApp), + cleanUpDashboardAndVariables: ctx.cleanUpDashboardAndVariablesMock, + cancelVariables: jest.fn(), + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + ctx.wrapper = shallow(); + }, + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('DashboardPage', () => { + dashboardPageScenario('Given initial state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('Dashboard is fetching slowly', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.wrapper?.setProps({ + isInitSlow: true, + initPhase: DashboardInitPhase.Fetching, + }); + }); + + it('Should render slow init state', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('Dashboard init completed ', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + }); + + it('Should update title', () => { + expect(document.title).toBe('My dashboard - Grafana'); + }); + + it('Should render dashboard grid', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('When user goes into panel edit', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setProps({ + urlEditPanelId: '1', + }); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper?.state(); + expect(state).not.toBe(null); + expect(state?.editPanel).toBeDefined(); + }); + }); + + dashboardPageScenario('When user goes into panel edit but has no edit permissions', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({}, { canEdit: false }); + ctx.wrapper?.setProps({ + urlEditPanelId: '1', + }); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper?.state(); + expect(state?.editPanel).toBe(null); + }); + }); + dashboardPageScenario('When user goes back to dashboard from view panel', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setState({ scrollTop: 100 }); + ctx.wrapper?.setProps({ + urlEditPanelId: '1', + }); + ctx.wrapper?.setProps({ + urlEditPanelId: undefined, + }); + }); + + it('Should update model state normal state', () => { + expect(ctx.dashboard).toBeDefined(); + // @ts-ignore typescript doesn't understand that dashboard must be defined to reach the row below + expect(ctx.dashboard.panelInEdit).toBeUndefined(); + }); + + it('Should update component state to normal and restore scrollTop', () => { + const state = ctx.wrapper?.state(); + expect(ctx.wrapper).not.toBe(null); + expect(state).not.toBe(null); + expect(state?.editPanel).toBe(null); + expect(state?.scrollTop).toBe(100); + }); + }); + + dashboardPageScenario('When dashboard has editview url state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setProps({ + editview: 'settings', + }); + }); + + it('should render settings view', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario('When adding panel', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper?.setState({ scrollTop: 100 }); + ctx.wrapper?.instance().onAddPanel(); + }); + + it('should set scrollTop to 0', () => { + expect(ctx.wrapper).not.toBe(null); + expect(ctx.wrapper?.state()).not.toBe(null); + expect(ctx.wrapper?.state().updateScrollTop).toBe(0); + }); + + it('should add panel widget to dashboard panels', () => { + expect(ctx.dashboard).not.toBe(null); + expect(ctx.dashboard?.panels[0].type).toBe('add-panel'); + }); + }); + + dashboardPageScenario('Given panel with id 0', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + ctx.wrapper?.setProps({ + urlEditPanelId: '0', + }); + }); + + it('Should go into edit mode', () => { + const state = ctx.wrapper?.state(); + expect(ctx.wrapper).not.toBe(null); + expect(state).not.toBe(null); + expect(state?.editPanel).not.toBe(null); + }); + }); + + dashboardPageScenario('When dashboard unmounts', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph' }], + schemaVersion: 17, + }); + ctx.wrapper?.unmount(); + }); + + it('Should call clean up action', () => { + expect(ctx.cleanUpDashboardAndVariablesMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('mapStateToProps with editPanel', () => { + const props = mapStateToProps({ + location: { + routeParams: {}, + query: { + editPanel: '1', + }, + }, + panelEditor: {}, + dashboard: { + getModel: () => ({} as DashboardModel), + }, + } as any); + + expect(props.urlEditPanelId).toBe('1'); + }); + + describe('mapStateToProps with string edit true', () => { + const props = mapStateToProps({ + location: { + routeParams: {}, + query: { + viewPanel: '2', + }, + }, + panelEditor: {}, + dashboard: { + getModel: () => ({} as DashboardModel), + }, + } as any); + + expect(props.urlViewPanelId).toBe('2'); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx new file mode 100644 index 0000000..5d9cfe9 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -0,0 +1,371 @@ +// Libraries +import $ from 'jquery'; +import React, { MouseEvent, PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getMessageFromError } from 'app/core/utils/errors'; +import { Branding } from 'app/core/components/Branding/Branding'; +// Components +import { DashboardGrid } from '../dashgrid/DashboardGrid'; +import { DashNav } from '../components/DashNav'; +import { DashboardSettings } from '../components/DashboardSettings'; +import { PanelEditor } from '../components/PanelEditor/PanelEditor'; +import { Alert, Button, CustomScrollbar, HorizontalGroup, Spinner, VerticalGroup } from '@grafana/ui'; +// Redux +import { initDashboard } from '../state/initDashboard'; +import { notifyApp, updateLocation } from 'app/core/actions'; +// Types +import { + AppNotificationSeverity, + DashboardInitError, + DashboardInitPhase, + DashboardRouteInfo, + StoreState, +} from 'app/types'; + +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { InspectTab } from 'app/features/inspector/types'; +import { PanelInspector } from '../components/Inspector/PanelInspector'; +import { SubMenu } from '../components/SubMenu/SubMenu'; +import { cleanUpDashboardAndVariables } from '../state/actions'; +import { cancelVariables } from '../../variables/state/actions'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; + +export interface Props { + urlUid?: string; + urlSlug?: string; + urlType?: string; + editview?: string; + urlPanelId?: string; + urlFolderId?: string; + inspectPanelId?: string; + $scope: any; + $injector: any; + routeInfo: DashboardRouteInfo; + urlEditPanelId?: string; + urlViewPanelId?: string; + initPhase: DashboardInitPhase; + isInitSlow: boolean; + dashboard: DashboardModel | null; + initError?: DashboardInitError; + initDashboard: typeof initDashboard; + cleanUpDashboardAndVariables: typeof cleanUpDashboardAndVariables; + notifyApp: typeof notifyApp; + updateLocation: typeof updateLocation; + inspectTab?: InspectTab; + isPanelEditorOpen?: boolean; + cancelVariables: typeof cancelVariables; +} + +export interface State { + editPanel: PanelModel | null; + viewPanel: PanelModel | null; + scrollTop: number; + updateScrollTop?: number; + rememberScrollTop: number; + showLoadingState: boolean; +} + +export class DashboardPage extends PureComponent { + state: State = { + editPanel: null, + viewPanel: null, + showLoadingState: false, + scrollTop: 0, + rememberScrollTop: 0, + }; + + async componentDidMount() { + this.props.initDashboard({ + $injector: this.props.$injector, + $scope: this.props.$scope, + urlSlug: this.props.urlSlug, + urlUid: this.props.urlUid, + urlType: this.props.urlType, + urlFolderId: this.props.urlFolderId, + routeInfo: this.props.routeInfo, + fixUrl: true, + }); + } + + componentWillUnmount() { + this.props.cleanUpDashboardAndVariables(); + this.setPanelFullscreenClass(false); + } + + componentDidUpdate(prevProps: Props) { + const { dashboard, urlEditPanelId, urlViewPanelId, urlUid } = this.props; + const { editPanel, viewPanel } = this.state; + + if (!dashboard) { + return; + } + + // if we just got dashboard update title + if (!prevProps.dashboard) { + document.title = dashboard.title + ' - ' + Branding.AppTitle; + } + + // Due to the angular -> react url bridge we can ge an update here with new uid before the container unmounts + // Can remove this condition after we switch to react router + if (prevProps.urlUid !== urlUid) { + return; + } + + // entering edit mode + if (!editPanel && urlEditPanelId) { + dashboardWatcher.setEditingState(true); + + this.getPanelByIdFromUrlParam(urlEditPanelId, (panel) => { + // if no edit permission show error + if (!dashboard.canEditPanel(panel)) { + this.props.notifyApp(createErrorNotification('Permission to edit panel denied')); + return; + } + + this.setState({ editPanel: panel }); + }); + } + + // leaving edit mode + if (editPanel && !urlEditPanelId) { + dashboardWatcher.setEditingState(false); + + this.setState({ editPanel: null }); + } + + // entering view mode + if (!viewPanel && urlViewPanelId) { + this.getPanelByIdFromUrlParam(urlViewPanelId, (panel) => { + this.setPanelFullscreenClass(true); + dashboard.initViewPanel(panel); + this.setState({ + viewPanel: panel, + rememberScrollTop: this.state.scrollTop, + updateScrollTop: 0, + }); + }); + } + + // leaving view mode + if (viewPanel && !urlViewPanelId) { + this.setPanelFullscreenClass(false); + dashboard.exitViewPanel(viewPanel); + this.setState( + { viewPanel: null, updateScrollTop: this.state.rememberScrollTop }, + this.triggerPanelsRendering.bind(this) + ); + } + } + + getPanelByIdFromUrlParam(urlPanelId: string, callback: (panel: PanelModel) => void) { + const { dashboard } = this.props; + + const panelId = parseInt(urlPanelId!, 10); + dashboard!.expandParentRowFor(panelId); + const panel = dashboard!.getPanelById(panelId); + + if (!panel) { + // Panel not found + this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); + // Clear url state + this.props.updateLocation({ + query: { + editPanel: null, + viewPanel: null, + }, + partial: true, + }); + return; + } + + callback(panel); + } + + triggerPanelsRendering() { + try { + this.props.dashboard!.render(); + } catch (err) { + console.error(err); + this.props.notifyApp(createErrorNotification(`Panel rendering error`, err)); + } + } + + setPanelFullscreenClass(isFullscreen: boolean) { + $('body').toggleClass('panel-in-fullscreen', isFullscreen); + } + + setScrollTop = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop, updateScrollTop: undefined }); + }; + + onAddPanel = () => { + const { dashboard } = this.props; + + if (!dashboard) { + return; + } + + // Return if the "Add panel" exists already + if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { + return; + } + + dashboard.addPanel({ + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: 'Panel Title', + }); + + // scroll to top after adding panel + this.setState({ updateScrollTop: 0 }); + }; + + cancelVariables = () => { + this.props.updateLocation({ path: '/' }); + }; + + renderSlowInitState() { + return ( +
+
+ + + {this.props.initPhase} + {' '} + + + + +
+
+ ); + } + + renderInitFailedState() { + const { initError } = this.props; + + if (!initError) { + return null; + } + + return ( +
+ + {getMessageFromError(initError.error)} + +
+ ); + } + + getInspectPanel() { + const { dashboard, inspectPanelId } = this.props; + if (!dashboard || !inspectPanelId) { + return null; + } + + const inspectPanel = dashboard.getPanelById(parseInt(inspectPanelId, 10)); + + // cannot inspect panels plugin is not already loaded + if (!inspectPanel) { + return null; + } + + return inspectPanel; + } + + render() { + const { + dashboard, + editview, + $injector, + isInitSlow, + initError, + inspectTab, + isPanelEditorOpen, + updateLocation, + } = this.props; + + const { editPanel, viewPanel, scrollTop, updateScrollTop } = this.state; + + if (!dashboard) { + if (isInitSlow) { + return this.renderSlowInitState(); + } + return null; + } + + // Only trigger render when the scroll has moved by 25 + const approximateScrollTop = Math.round(scrollTop / 25) * 25; + const inspectPanel = this.getInspectPanel(); + + return ( +
+ + +
+ +
+ {initError && this.renderInitFailedState()} + {!editPanel && ( + + )} + + +
+
+
+ + {inspectPanel && } + {editPanel && } + {editview && } +
+ ); + } +} + +export const mapStateToProps = (state: StoreState) => ({ + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + editview: state.location.query.editview, + urlPanelId: state.location.query.panelId, + urlFolderId: state.location.query.folderId, + urlEditPanelId: state.location.query.editPanel, + urlViewPanelId: state.location.query.viewPanel, + inspectPanelId: state.location.query.inspect, + initPhase: state.dashboard.initPhase, + isInitSlow: state.dashboard.isInitSlow, + initError: state.dashboard.initError, + dashboard: state.dashboard.getModel() as DashboardModel, + inspectTab: state.location.query.inspectTab, + isPanelEditorOpen: state.panelEditor.isOpen, +}); + +const mapDispatchToProps = { + initDashboard, + cleanUpDashboardAndVariables, + notifyApp, + updateLocation, + cancelVariables, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx new file mode 100644 index 0000000..9294ee0 --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SoloPanelPage, Props } from './SoloPanelPage'; +import { Props as DashboardPanelProps } from '../dashgrid/DashboardPanel'; +import { DashboardModel } from '../state'; +import { DashboardRouteInfo } from 'app/types'; + +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); +jest.mock('app/features/dashboard/dashgrid/DashboardPanel', () => { + class DashboardPanel extends React.Component { + render() { + // In this test we only check whether a new panel has arrived in the props + return <>{this.props.panel?.title}; + } + } + + return { DashboardPanel }; +}); + +interface ScenarioContext { + dashboard?: DashboardModel | null; + secondaryDashboard?: DashboardModel | null; + setDashboard: (overrides?: any, metaOverrides?: any) => void; + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => void; + mount: (propOverrides?: Partial) => void; + rerender: (propOverrides?: Partial) => void; + setup: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function soloPanelPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: (fn) => { + setupFn = fn; + }, + setDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + }, + setSecondaryDashboard: (overrides?: any, metaOverrides?: any) => { + ctx.secondaryDashboard = getTestDashboard(overrides, metaOverrides); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + urlSlug: 'my-dash', + $scope: {}, + urlUid: '11', + urlPanelId: '1', + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + initDashboard: jest.fn(), + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + let { rerender } = render(); + // prop updates will be submitted by rerendering the same component with different props + ctx.rerender = (newProps: Partial) => { + Object.assign(props, newProps); + rerender(); + }; + }, + rerender: () => { + // will be replaced while mount() is called + }, + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('SoloPanelPage', () => { + soloPanelPageScenario('Given initial state', (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(screen.queryByText(/Loading/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('Dashboard init completed ', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard(); + expect(ctx.dashboard).not.toBeNull(); + // the componentDidMount will change the dashboard prop to the new dashboard + // emulate this by rerendering with new props + ctx.rerender({ dashboard: ctx.dashboard }); + }); + + it('Should render dashboard grid', async () => { + // check if the panel title has arrived in the DashboardPanel mock + expect(screen.queryByText(/My graph/)).not.toBeNull(); + }); + }); + + soloPanelPageScenario('When user navigates to other SoloPanelPage', (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboard({ uid: 1, panels: [{ id: 1, type: 'graph', title: 'Panel 1' }] }); + ctx.setSecondaryDashboard({ uid: 2, panels: [{ id: 1, type: 'graph', title: 'Panel 2' }] }); + }); + + it('Should show other graph', () => { + // check that the title in the DashboardPanel has changed + ctx.rerender({ dashboard: ctx.dashboard }); + expect(screen.queryByText(/Panel 1/)).not.toBeNull(); + ctx.rerender({ dashboard: ctx.secondaryDashboard }); + expect(screen.queryByText(/Panel 1/)).toBeNull(); + expect(screen.queryByText(/Panel 2/)).not.toBeNull(); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx new file mode 100644 index 0000000..7f755ba --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -0,0 +1,110 @@ +// Libraries +import React, { Component } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +// Components +import { DashboardPanel } from '../dashgrid/DashboardPanel'; + +// Redux +import { initDashboard } from '../state/initDashboard'; + +// Types +import { StoreState, DashboardRouteInfo } from 'app/types'; +import { PanelModel, DashboardModel } from 'app/features/dashboard/state'; + +export interface Props { + urlPanelId: string; + urlUid?: string; + urlSlug?: string; + urlType?: string; + $scope: any; + $injector: any; + routeInfo: DashboardRouteInfo; + initDashboard: typeof initDashboard; + dashboard: DashboardModel | null; +} + +export interface State { + panel: PanelModel | null; + notFound: boolean; +} + +export class SoloPanelPage extends Component { + state: State = { + panel: null, + notFound: false, + }; + + componentDidMount() { + const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props; + + this.props.initDashboard({ + $injector: $injector, + $scope: $scope, + urlSlug: urlSlug, + urlUid: urlUid, + urlType: urlType, + routeInfo: routeInfo, + fixUrl: false, + }); + } + + componentDidUpdate(prevProps: Props) { + const { urlPanelId, dashboard } = this.props; + + if (!dashboard) { + return; + } + + // we just got a new dashboard + if (!prevProps.dashboard || prevProps.dashboard.uid !== dashboard.uid) { + const panelId = parseInt(urlPanelId, 10); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); + + if (!panel) { + this.setState({ notFound: true }); + return; + } + + this.setState({ panel }); + } + } + + render() { + const { urlPanelId, dashboard } = this.props; + const { notFound, panel } = this.state; + + if (notFound) { + return
Panel with id {urlPanelId} not found
; + } + + if (!panel || !dashboard) { + return
Loading & initializing dashboard
; + } + + return ( +
+ +
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + urlPanelId: state.location.query.panelId, + dashboard: state.dashboard.getModel() as DashboardModel, +}); + +const mapDispatchToProps = { + initDashboard, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage)); diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap new file mode 100644 index 0000000..6dbfba5 --- /dev/null +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -0,0 +1,801 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = ` +
+ +
+ +
+ + +
+
+
+
+`; + +exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = ` +
+
+ + + + + Fetching + + + + + + +
+
+`; + +exports[`DashboardPage Given initial state Should render nothing 1`] = `""`; + +exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = ` +
+ +
+ +
+ + +
+
+
+ +
+`; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx new file mode 100644 index 0000000..3b45858 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { DashboardGrid, Props } from './DashboardGrid'; +import { DashboardModel } from '../state'; + +interface ScenarioContext { + props: Props; + wrapper?: ShallowWrapper; + setup: (fn: () => void) => void; + setProps: (props: Partial) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign( + { + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 24, h: 10 }, + }, + { + id: 2, + type: 'graph2', + title: 'My graph2', + gridPos: { x: 0, y: 10, w: 25, h: 10 }, + }, + { + id: 3, + type: 'graph3', + title: 'My graph3', + gridPos: { x: 0, y: 20, w: 25, h: 100 }, + }, + { + id: 4, + type: 'graph4', + title: 'My graph4', + gridPos: { x: 0, y: 120, w: 25, h: 10 }, + }, + ], + }, + overrides + ); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: (fn) => { + setupFn = fn; + }, + props: { + editPanel: null, + viewPanel: null, + scrollTop: 0, + dashboard: getTestDashboard(), + }, + setProps: (props: Partial) => { + Object.assign(ctx.props, props); + if (ctx.wrapper) { + ctx.wrapper.setProps(ctx.props); + } + }, + }; + + beforeEach(() => { + setupFn(); + ctx.wrapper = shallow(); + }); + + scenarioFn(ctx); + }); +} + +describe('DashboardGrid', () => { + dashboardGridScenario('Can render dashboard grid', (ctx) => { + ctx.setup(() => {}); + + it('Should render', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx new file mode 100644 index 0000000..3491698 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -0,0 +1,281 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; +import classNames from 'classnames'; +// @ts-ignore +import sizeMe from 'react-sizeme'; + +// Components +import { AddPanelWidget } from '../components/AddPanelWidget'; +import { DashboardRow } from '../components/DashboardRow'; + +// Types +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; +import { DashboardPanel } from './DashboardPanel'; +import { DashboardModel, PanelModel } from '../state'; +import { Subscription } from 'rxjs'; +import { DashboardPanelsChangedEvent } from 'app/types/events'; + +let lastGridWidth = 1200; +let ignoreNextWidthChange = false; + +interface GridWrapperProps { + size: { width: number }; + layout: ReactGridLayout.Layout[]; + onLayoutChange: (layout: ReactGridLayout.Layout[]) => void; + children: JSX.Element | JSX.Element[]; + onDragStop: ItemCallback; + onResize: ItemCallback; + onResizeStop: ItemCallback; + className: string; + isResizable?: boolean; + isDraggable?: boolean; + viewPanel: PanelModel | null; +} + +function GridWrapper({ + size, + layout, + onLayoutChange, + children, + onDragStop, + onResize, + onResizeStop, + className, + isResizable, + isDraggable, + viewPanel, +}: GridWrapperProps) { + const width = size.width > 0 ? size.width : lastGridWidth; + + // logic to ignore width changes (optimization) + if (width !== lastGridWidth) { + if (ignoreNextWidthChange) { + ignoreNextWidthChange = false; + } else if (!viewPanel && Math.abs(width - lastGridWidth) > 8) { + lastGridWidth = width; + } + } + + /* + Disable draggable if mobile device, solving an issue with unintentionally + moving panels. https://github.com/grafana/grafana/issues/18497 + theme.breakpoints.md = 769 + */ + const draggable = width <= 769 ? false : isDraggable; + + return ( + + {children} + + ); +} + +const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); + +export interface Props { + dashboard: DashboardModel; + editPanel: PanelModel | null; + viewPanel: PanelModel | null; + scrollTop: number; + isPanelEditorOpen?: boolean; +} + +export class DashboardGrid extends PureComponent { + private panelMap: { [id: string]: PanelModel } = {}; + private panelRef: { [id: string]: HTMLElement } = {}; + private eventSubs = new Subscription(); + + componentDidMount() { + const { dashboard } = this.props; + this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate)); + } + + componentWillUnmount() { + this.eventSubs.unsubscribe(); + } + + buildLayout() { + const layout = []; + this.panelMap = {}; + + for (const panel of this.props.dashboard.panels) { + const stringId = panel.id.toString(); + this.panelMap[stringId] = panel; + + if (!panel.gridPos) { + console.log('panel without gridpos'); + continue; + } + + const panelPos: any = { + i: stringId, + x: panel.gridPos.x, + y: panel.gridPos.y, + w: panel.gridPos.w, + h: panel.gridPos.h, + }; + + if (panel.type === 'row') { + panelPos.w = GRID_COLUMN_COUNT; + panelPos.h = 1; + panelPos.isResizable = false; + panelPos.isDraggable = panel.collapsed; + } + + layout.push(panelPos); + } + + return layout; + } + + onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => { + for (const newPos of newLayout) { + this.panelMap[newPos.i!].updateGridPos(newPos); + } + + this.props.dashboard.sortPanelsByGridPos(); + + // Call render() after any changes. This is called when the layour loads + this.forceUpdate(); + }; + + triggerForceUpdate = () => { + this.forceUpdate(); + }; + + updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => { + this.panelMap[item.i!].updateGridPos(item); + + // react-grid-layout has a bug (#670), and onLayoutChange() is only called when the component is mounted. + // So it's required to call it explicitly when panel resized or moved to save layout changes. + this.onLayoutChange(layout); + }; + + onResize: ItemCallback = (layout, oldItem, newItem) => { + this.panelMap[newItem.i!].updateGridPos(newItem); + }; + + onResizeStop: ItemCallback = (layout, oldItem, newItem) => { + this.updateGridPos(newItem, layout); + }; + + onDragStop: ItemCallback = (layout, oldItem, newItem) => { + this.updateGridPos(newItem, layout); + }; + + isInView = (panel: PanelModel): boolean => { + if (panel.isViewing || panel.isEditing) { + return true; + } + + // elem is set *after* the first render + const elem = this.panelRef[panel.id.toString()]; + if (!elem) { + // NOTE the gridPos is also not valid until after the first render + // since it is passed to the layout engine and made to be valid + // for example, you can have Y=0 for everything and it will stack them + // down vertically in the second call + return false; + } + + const top = elem.offsetTop; + const height = panel.gridPos.h * GRID_CELL_HEIGHT + 40; + const bottom = top + height; + + // Show things that are almost in the view + const buffer = 250; + + const viewTop = this.props.scrollTop; + if (viewTop > bottom + buffer) { + return false; // The panel is above the viewport + } + + // Use the whole browser height (larger than real value) + // TODO? is there a better way + const viewHeight = isNaN(window.innerHeight) ? (window as any).clientHeight : window.innerHeight; + const viewBot = viewTop + viewHeight; + if (top > viewBot + buffer) { + return false; + } + + return !this.props.dashboard.otherPanelInFullscreen(panel); + }; + + renderPanels() { + const panelElements = []; + + for (const panel of this.props.dashboard.panels) { + const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing }); + const id = panel.id.toString(); + panel.isInView = this.isInView(panel); + + panelElements.push( +
elem && (this.panelRef[id] = elem)}> + {this.renderPanel(panel)} +
+ ); + } + + return panelElements; + } + + renderPanel(panel: PanelModel) { + if (panel.type === 'row') { + return ; + } + + if (panel.type === 'add-panel') { + return ; + } + + return ( + + ); + } + + render() { + const { dashboard, viewPanel } = this.props; + + return ( + + {this.renderPanels()} + + ); + } +} + +export default hot(module)(DashboardGrid); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx new file mode 100644 index 0000000..f475124 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -0,0 +1,146 @@ +// Libraries +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { connect, ConnectedProps } from 'react-redux'; + +// Components +import { PanelChrome } from './PanelChrome'; +import { PanelChromeAngular } from './PanelChromeAngular'; + +// Actions +import { initDashboardPanel } from '../state/actions'; + +// Types +import { DashboardModel, PanelModel } from '../state'; +import { StoreState } from 'app/types'; +import { PanelPlugin } from '@grafana/data'; + +export interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; + isEditing: boolean; + isViewing: boolean; + isInView: boolean; +} + +export interface State { + isLazy: boolean; +} + +const mapStateToProps = (state: StoreState, props: OwnProps) => { + const panelState = state.dashboard.panels[props.panel.id]; + if (!panelState) { + return { plugin: null }; + } + + return { + plugin: panelState.plugin, + }; +}; + +const mapDispatchToProps = { initDashboardPanel }; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type Props = OwnProps & ConnectedProps; + +export class DashboardPanelUnconnected extends PureComponent { + specialPanels: { [key: string]: Function } = {}; + + constructor(props: Props) { + super(props); + + this.state = { + isLazy: !props.isInView, + }; + } + + componentDidMount() { + this.props.initDashboardPanel(this.props.panel); + } + + componentDidUpdate() { + if (this.state.isLazy && this.props.isInView) { + this.setState({ isLazy: false }); + } + } + + onMouseEnter = () => { + this.props.dashboard.setPanelFocus(this.props.panel.id); + }; + + onMouseLeave = () => { + this.props.dashboard.setPanelFocus(0); + }; + + renderPanel(plugin: PanelPlugin) { + const { dashboard, panel, isViewing, isInView, isEditing } = this.props; + + return ( + + {({ width, height }) => { + if (width === 0) { + return null; + } + + if (plugin.angularPanelCtrl) { + return ( + + ); + } + + return ( + + ); + }} + + ); + } + + render() { + const { isViewing, plugin } = this.props; + const { isLazy } = this.state; + + // if we have not loaded plugin exports yet, wait + if (!plugin) { + return null; + } + + // If we are lazy state don't render anything + if (isLazy) { + return null; + } + + const panelWrapperClass = classNames({ + 'panel-wrapper': true, + 'panel-wrapper--view': isViewing, + }); + + return ( +
+ {this.renderPanel(plugin)} +
+ ); + } +} + +export const DashboardPanel = connector(DashboardPanelUnconnected); diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx new file mode 100644 index 0000000..fef5ef8 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -0,0 +1,91 @@ +import React, { FC } from 'react'; +import { ReplaySubject } from 'rxjs'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import { act, render, screen } from '@testing-library/react'; +import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data'; + +import { PanelChrome, Props } from './PanelChrome'; +import { DashboardModel, PanelModel } from '../state'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { setTimeSrv, TimeSrv } from '../services/TimeSrv'; + +jest.mock('app/core/profiler', () => ({ + profiler: { + renderingCompleted: jest.fn(), + }, +})); + +function setupTestContext(options: Partial) { + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); + const subject: ReplaySubject = new ReplaySubject(); + const panelQueryRunner = ({ + getData: () => subject, + run: () => { + subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); + }, + } as unknown) as PanelQueryRunner; + const timeSrv = ({ + timeRange: jest.fn(), + } as unknown) as TimeSrv; + setTimeSrv(timeSrv); + const defaults: Props = { + panel: ({ + hasTitle: jest.fn(), + replaceVariables: jest.fn(), + events: { subscribe: jest.fn() }, + getQueryRunner: () => panelQueryRunner, + getOptions: jest.fn(), + getDisplayTitle: jest.fn(), + } as unknown) as PanelModel, + dashboard: ({ + panelInitialized: jest.fn(), + getTimezone: () => 'browser', + } as unknown) as DashboardModel, + plugin: ({ + meta: { skipDataQuery: false }, + panel: TestPanelComponent, + } as unknown) as PanelPlugin, + isViewing: true, + isEditing: false, + isInView: false, + width: 100, + height: 100, + }; + + const props = { ...defaults, ...options }; + const { rerender } = render( + + + + ); + + return { rerender, props, subject, store }; +} + +describe('PanelChrome', () => { + describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => { + it('then it should load the panel successfully when scrolled into view again', () => { + const { rerender, props, subject, store } = setupTestContext({}); + + expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument(); + + act(() => { + subject.next({ state: LoadingState.Loading, series: [], timeRange: getDefaultTimeRange() }); + subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); + }); + + const newProps = { ...props, isInView: true }; + rerender( + + + + ); + + expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument(); + }); + }); +}); + +const TestPanelComponent: FC = () =>
Plugin Panel to Render
; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx new file mode 100644 index 0000000..8b4b3a7 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -0,0 +1,364 @@ +// Libraries +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { Subscription } from 'rxjs'; +// Components +import { PanelHeader } from './PanelHeader/PanelHeader'; +import { ErrorBoundary } from '@grafana/ui'; +// Utils & Services +import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; +import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; +import { profiler } from 'app/core/profiler'; +import config from 'app/core/config'; +// Types +import { DashboardModel, PanelModel } from '../state'; +import { PANEL_BORDER } from 'app/core/constants'; +import { + AbsoluteTimeRange, + FieldConfigSource, + getDefaultTimeRange, + LoadingState, + PanelData, + PanelPlugin, + PanelPluginMeta, + toDataFrameDTO, + toUtc, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { loadSnapshotData } from '../utils/loadSnapshotData'; +import { RefreshEvent, RenderEvent } from 'app/types/events'; + +const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; + +export interface Props { + panel: PanelModel; + dashboard: DashboardModel; + plugin: PanelPlugin; + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + width: number; + height: number; +} + +export interface State { + isFirstLoad: boolean; + renderCounter: number; + errorMessage?: string; + refreshWhenInView: boolean; + data: PanelData; +} + +export class PanelChrome extends Component { + private readonly timeSrv: TimeSrv = getTimeSrv(); + private subs = new Subscription(); + + constructor(props: Props) { + super(props); + + this.state = { + isFirstLoad: true, + renderCounter: 0, + refreshWhenInView: false, + data: { + state: LoadingState.NotStarted, + series: [], + timeRange: getDefaultTimeRange(), + }, + }; + } + + componentDidMount() { + const { panel, dashboard } = this.props; + + // Subscribe to panel events + this.subs.add(panel.events.subscribe(RefreshEvent, this.onRefresh)); + this.subs.add(panel.events.subscribe(RenderEvent, this.onRender)); + + dashboard.panelInitialized(this.props.panel); + + // Move snapshot data into the query response + if (this.hasPanelSnapshot) { + this.setState({ + data: loadSnapshotData(panel, dashboard), + isFirstLoad: false, + }); + return; + } + + if (!this.wantsQueryExecution) { + this.setState({ isFirstLoad: false }); + } + + this.subs.add( + panel + .getQueryRunner() + .getData({ withTransforms: true, withFieldConfig: true }) + .subscribe({ + next: (data) => this.onDataUpdate(data), + }) + ); + } + + componentWillUnmount() { + this.subs.unsubscribe(); + } + + componentDidUpdate(prevProps: Props) { + const { isInView } = this.props; + + // View state has changed + if (isInView !== prevProps.isInView) { + if (isInView) { + // Check if we need a delayed refresh + if (this.state.refreshWhenInView) { + this.onRefresh(); + } + } + } + } + + shouldComponentUpdate(prevProps: Props, prevState: State) { + const { plugin, panel } = this.props; + + // If plugin changed we need to process fieldOverrides again + // We do this by asking panel query runner to resend last result + if (prevProps.plugin !== plugin) { + panel.getQueryRunner().resendLastResult(); + return false; + } + + return true; + } + + // Updates the response with information from the stream + // The next is outside a react synthetic event so setState is not batched + // So in this context we can only do a single call to setState + onDataUpdate(data: PanelData) { + if (!this.props.isInView) { + if (data.state !== LoadingState.Streaming) { + // Ignore events when not visible. + // The call will be repeated when the panel comes into view + this.setState({ refreshWhenInView: true }); + } + + return; + } + + let { isFirstLoad } = this.state; + let errorMessage: string | undefined; + + switch (data.state) { + case LoadingState.Loading: + // Skip updating state data if it is already in loading state + // This is to avoid rendering partial loading responses + if (this.state.data.state === LoadingState.Loading) { + return; + } + break; + case LoadingState.Error: + const { error } = data; + if (error) { + if (errorMessage !== error.message) { + errorMessage = error.message; + } + } + break; + case LoadingState.Done: + // If we are doing a snapshot save data in panel model + if (this.props.dashboard.snapshot) { + this.props.panel.snapshotData = data.series.map((frame) => toDataFrameDTO(frame)); + } + if (isFirstLoad) { + isFirstLoad = false; + } + break; + } + + this.setState({ isFirstLoad, errorMessage, data }); + } + + onRefresh = () => { + const { panel, isInView, width } = this.props; + if (!isInView) { + this.setState({ refreshWhenInView: true }); + return; + } + + const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange()); + + // Issue Query + if (this.wantsQueryExecution) { + if (width < 0) { + return; + } + + panel.getQueryRunner().run({ + datasource: panel.datasource, + queries: panel.targets, + panelId: panel.id, + dashboardId: this.props.dashboard.id, + timezone: this.props.dashboard.getTimezone(), + timeRange: timeData.timeRange, + timeInfo: timeData.timeInfo, + maxDataPoints: panel.maxDataPoints || width, + minInterval: panel.interval, + scopedVars: panel.scopedVars, + cacheTimeout: panel.cacheTimeout, + transformations: panel.transformations, + }); + } else { + // The panel should render on refresh as well if it doesn't have a query, like clock panel + this.onRender(); + } + }; + + onRender = () => { + const stateUpdate = { renderCounter: this.state.renderCounter + 1 }; + this.setState(stateUpdate); + }; + + onOptionsChange = (options: any) => { + this.props.panel.updateOptions(options); + }; + + onFieldConfigChange = (config: FieldConfigSource) => { + this.props.panel.updateFieldConfig(config); + }; + + onPanelError = (message: string) => { + if (this.state.errorMessage !== message) { + this.setState({ errorMessage: message }); + } + }; + + get hasPanelSnapshot() { + const { panel } = this.props; + return panel.snapshotData && panel.snapshotData.length; + } + + get wantsQueryExecution() { + return !(this.props.plugin.meta.skipDataQuery || this.hasPanelSnapshot); + } + + onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { + this.timeSrv.setTime({ + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }); + }; + + shouldSignalRenderingCompleted(loadingState: LoadingState, pluginMeta: PanelPluginMeta) { + return loadingState === LoadingState.Done || pluginMeta.skipDataQuery; + } + + renderPanel(width: number, height: number) { + const { panel, plugin, dashboard } = this.props; + const { renderCounter, data, isFirstLoad } = this.state; + const { theme } = config; + const { state: loadingState } = data; + + // do not render component until we have first data + if (isFirstLoad && (loadingState === LoadingState.Loading || loadingState === LoadingState.NotStarted)) { + return null; + } + + // This is only done to increase a counter that is used by backend + // image rendering to know when to capture image + if (this.shouldSignalRenderingCompleted(loadingState, plugin.meta)) { + profiler.renderingCompleted(); + } + + const PanelComponent = plugin.panel!; + const timeRange = data.timeRange || this.timeSrv.timeRange(); + const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + const panelWidth = width - chromePadding * 2 - PANEL_BORDER; + const innerPanelHeight = height - headerHeight - chromePadding * 2 - PANEL_BORDER; + const panelContentClassNames = classNames({ + 'panel-content': true, + 'panel-content--no-padding': plugin.noPadding, + }); + const panelOptions = panel.getOptions(); + + return ( + <> +
+ +
+ + ); + } + + hasOverlayHeader() { + const { panel } = this.props; + const { errorMessage, data } = this.state; + + // always show normal header if we have an error message + if (errorMessage) { + return false; + } + + // always show normal header if we have time override + if (data.request && data.request.timeInfo) { + return false; + } + + return !panel.hasTitle(); + } + + render() { + const { dashboard, panel, isViewing, isEditing, width, height } = this.props; + const { errorMessage, data } = this.state; + const { transparent } = panel; + + const containerClassNames = classNames({ + 'panel-container': true, + 'panel-container--absolute': true, + 'panel-container--transparent': transparent, + 'panel-container--no-title': this.hasOverlayHeader(), + }); + + return ( +
+ + + {({ error }) => { + if (error) { + this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR); + return null; + } + return this.renderPanel(width, height); + }} + +
+ ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx new file mode 100644 index 0000000..b2ebd16 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -0,0 +1,263 @@ +// Libraries +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { Subscription } from 'rxjs'; +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +// Components +import { PanelHeader } from './PanelHeader/PanelHeader'; +// Utils & Services +import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; +import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { setPanelAngularComponent } from '../state/reducers'; +import config from 'app/core/config'; +// Types +import { DashboardModel, PanelModel } from '../state'; +import { StoreState } from 'app/types'; +import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data'; +import { PANEL_BORDER } from 'app/core/constants'; +import { selectors } from '@grafana/e2e-selectors'; +import { RenderEvent } from 'app/types/events'; + +interface OwnProps { + panel: PanelModel; + dashboard: DashboardModel; + plugin: PanelPlugin; + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + width: number; + height: number; +} + +interface ConnectedProps { + angularComponent?: AngularComponent | null; +} + +interface DispatchProps { + setPanelAngularComponent: typeof setPanelAngularComponent; +} + +export type Props = OwnProps & ConnectedProps & DispatchProps; + +export interface State { + data: PanelData; + errorMessage?: string; + alertState?: string; +} + +interface AngularScopeProps { + panel: PanelModel; + dashboard: DashboardModel; + size: { + height: number; + width: number; + }; +} + +export class PanelChromeAngularUnconnected extends PureComponent { + element: HTMLElement | null = null; + timeSrv: TimeSrv = getTimeSrv(); + scopeProps?: AngularScopeProps; + subs = new Subscription(); + + constructor(props: Props) { + super(props); + this.state = { + data: { + state: LoadingState.NotStarted, + series: [], + timeRange: getDefaultTimeRange(), + }, + }; + } + + componentDidMount() { + const { panel } = this.props; + this.loadAngularPanel(); + + // subscribe to data events + const queryRunner = panel.getQueryRunner(); + + // we are not displaying any of this data so no need for transforms or field config + this.subs.add( + queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({ + next: (data: PanelData) => this.onPanelDataUpdate(data), + }) + ); + + this.subs.add(panel.events.subscribe(RenderEvent, this.onPanelRenderEvent)); + } + + onPanelRenderEvent = (event: RenderEvent) => { + const { alertState } = this.state; + // graph sends these old render events with payloads + const payload = event.payload; + + if (payload && payload.alertState && this.props.panel.alert) { + this.setState({ alertState: payload.alertState }); + } else if (payload && payload.alertState && !this.props.panel.alert) { + // when user deletes alert in panel editor the source panel needs to refresh as this is in the mutable state and + // will not automatically re render + this.setState({ alertState: undefined }); + } else if (payload && alertState) { + this.setState({ alertState: undefined }); + } else { + // only needed for detecting title updates right now fix before 7.0 + this.forceUpdate(); + } + }; + + onPanelDataUpdate(data: PanelData) { + let errorMessage: string | undefined; + + if (data.state === LoadingState.Error) { + const { error } = data; + if (error) { + if (errorMessage !== error.message) { + errorMessage = error.message; + } + } + } + + this.setState({ data, errorMessage }); + } + + componentWillUnmount() { + this.cleanUpAngularPanel(); + this.subs.unsubscribe(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { plugin, height, width, panel } = this.props; + + if (prevProps.plugin !== plugin) { + this.cleanUpAngularPanel(); + this.loadAngularPanel(); + } + + if (prevProps.width !== width || prevProps.height !== height) { + if (this.scopeProps) { + this.scopeProps.size.height = this.getInnerPanelHeight(); + this.scopeProps.size.width = this.getInnerPanelWidth(); + panel.render(); + } + } + } + + getInnerPanelHeight() { + const { plugin, height } = this.props; + const { theme } = config; + + const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + return height - headerHeight - chromePadding * 2 - PANEL_BORDER; + } + + getInnerPanelWidth() { + const { plugin, width } = this.props; + const { theme } = config; + + const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; + return width - chromePadding * 2 - PANEL_BORDER; + } + + loadAngularPanel() { + const { panel, dashboard, setPanelAngularComponent } = this.props; + + // if we have no element or already have loaded the panel return + if (!this.element) { + return; + } + + const loader = getAngularLoader(); + const template = ''; + + this.scopeProps = { + panel: panel, + dashboard: dashboard, + size: { width: this.getInnerPanelWidth(), height: this.getInnerPanelHeight() }, + }; + + setPanelAngularComponent({ + panelId: panel.id, + angularComponent: loader.load(this.element, this.scopeProps, template), + }); + } + + cleanUpAngularPanel() { + const { angularComponent, setPanelAngularComponent, panel } = this.props; + + if (angularComponent) { + angularComponent.destroy(); + } + + setPanelAngularComponent({ panelId: panel.id, angularComponent: null }); + } + + hasOverlayHeader() { + const { panel } = this.props; + const { errorMessage, data } = this.state; + + // always show normal header if we have an error message + if (errorMessage) { + return false; + } + + // always show normal header if we have time override + if (data.request && data.request.timeInfo) { + return false; + } + + return !panel.hasTitle(); + } + + render() { + const { dashboard, panel, isViewing, isEditing, plugin } = this.props; + const { errorMessage, data, alertState } = this.state; + const { transparent } = panel; + + const containerClassNames = classNames({ + 'panel-container': true, + 'panel-container--absolute': true, + 'panel-container--transparent': transparent, + 'panel-container--no-title': this.hasOverlayHeader(), + 'panel-has-alert': panel.alert !== undefined, + [`panel-alert-state--${alertState}`]: alertState !== undefined, + }); + + const panelContentClassNames = classNames({ + 'panel-content': true, + 'panel-content--no-padding': plugin.noPadding, + }); + + return ( +
+ +
+
(this.element = element)} className="panel-height-helper" /> +
+
+ ); + } +} + +const mapStateToProps: MapStateToProps = (state, props) => { + return { + angularComponent: state.dashboard.panels[props.panel.id].angularComponent, + }; +}; + +const mapDispatchToProps: MapDispatchToProps = { setPanelAngularComponent }; + +export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx new file mode 100644 index 0000000..787399c --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react'; +import { cx } from 'emotion'; +import { DataLink, PanelData } from '@grafana/data'; +import { Icon } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +import PanelHeaderCorner from './PanelHeaderCorner'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; +import { PanelHeaderNotices } from './PanelHeaderNotices'; +import { PanelHeaderMenuTrigger } from './PanelHeaderMenuTrigger'; +import { PanelHeaderLoadingIndicator } from './PanelHeaderLoadingIndicator'; +import { PanelHeaderMenuWrapper } from './PanelHeaderMenuWrapper'; + +export interface Props { + panel: PanelModel; + dashboard: DashboardModel; + title?: string; + description?: string; + links?: DataLink[]; + error?: string; + alertState?: string; + isViewing: boolean; + isEditing: boolean; + data: PanelData; +} + +export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => { + const onCancelQuery = () => panel.getQueryRunner().cancelQuery(); + const title = panel.getDisplayTitle(); + const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : ''); + + return ( + <> + +
+ + + {({ closeMenu, panelMenuOpen }) => { + return ( +
+ + {panel.libraryPanel && } + {alertState ? ( + + ) : null} + {title} + + + {data.request && data.request.timeInfo && ( + + {data.request.timeInfo} + + )} +
+ ); + }} +
+
+ + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx new file mode 100644 index 0000000..9d2095e --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { PanelHeaderCorner } from './PanelHeaderCorner'; +import { PanelModel } from '../../state'; + +describe('Render', () => { + it('should render component', () => { + const panel = new PanelModel({}); + const wrapper = shallow(); + const instance = wrapper.instance() as PanelHeaderCorner; + + expect(instance.getInfoContent()).toBeDefined(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx new file mode 100644 index 0000000..6e70c6c --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -0,0 +1,115 @@ +import React, { Component } from 'react'; + +import { renderMarkdown, LinkModelSupplier, ScopedVars } from '@grafana/data'; +import { Tooltip, PopoverContent } from '@grafana/ui'; +import { getLocationSrv, getTemplateSrv } from '@grafana/runtime'; + +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { InspectTab } from 'app/features/inspector/types'; +import { selectors } from '@grafana/e2e-selectors'; + +enum InfoMode { + Error = 'Error', + Info = 'Info', + Links = 'Links', +} + +interface Props { + panel: PanelModel; + title?: string; + description?: string; + scopedVars?: ScopedVars; + links?: LinkModelSupplier; + error?: string; +} + +export class PanelHeaderCorner extends Component { + timeSrv: TimeSrv = getTimeSrv(); + + getInfoMode = () => { + const { panel, error } = this.props; + if (error) { + return InfoMode.Error; + } + if (!!panel.description) { + return InfoMode.Info; + } + if (panel.links && panel.links.length) { + return InfoMode.Links; + } + + return undefined; + }; + + getInfoContent = (): JSX.Element => { + const { panel } = this.props; + const markdown = panel.description || ''; + const interpolatedMarkdown = getTemplateSrv().replace(markdown, panel.scopedVars); + const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); + const links = this.props.links && this.props.links.getLinks(panel.replaceVariables); + + return ( +
+
+ + {links && links.length > 0 && ( + + )} +
+ ); + }; + + /** + * Open the Panel Inspector when we click on an error + */ + onClickError = () => { + getLocationSrv().update({ partial: true, query: { inspect: this.props.panel.id, inspectTab: InspectTab.Error } }); + }; + + renderCornerType(infoMode: InfoMode, content: PopoverContent, onClick?: () => void) { + const theme = infoMode === InfoMode.Error ? 'error' : 'info'; + const className = `panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`; + const ariaLabel = selectors.components.Panels.Panel.headerCornerInfo(infoMode.toLowerCase()); + + return ( + +
+ + +
+
+ ); + } + + render() { + const { error } = this.props; + const infoMode: InfoMode | undefined = this.getInfoMode(); + + if (!infoMode) { + return null; + } + + if (infoMode === InfoMode.Error && error) { + return this.renderCornerType(infoMode, error, this.onClickError); + } + + if (infoMode === InfoMode.Info || infoMode === InfoMode.Links) { + return this.renderCornerType(infoMode, this.getInfoContent); + } + + return null; + } +} + +export default PanelHeaderCorner; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx new file mode 100644 index 0000000..a32ea8f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { css } from 'emotion'; +import { GrafanaTheme, LoadingState } from '@grafana/data'; +import { Icon, Tooltip, useStyles } from '@grafana/ui'; + +interface Props { + state: LoadingState; + onClick: () => void; +} + +export const PanelHeaderLoadingIndicator: FC = ({ state, onClick }) => { + const styles = useStyles(getStyles); + + if (state === LoadingState.Loading) { + return ( +
+ + + +
+ ); + } + + if (state === LoadingState.Streaming) { + return ( +
+
+
+ ); + } + + return null; +}; + +function getStyles(theme: GrafanaTheme) { + return { + streamIndicator: css` + width: 10px; + height: 10px; + background: ${theme.colors.textFaint}; + box-shadow: 0 0 2px ${theme.colors.textFaint}; + border-radius: 50%; + position: relative; + top: 6px; + right: 1px; + `, + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx new file mode 100644 index 0000000..c180359 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx @@ -0,0 +1,34 @@ +import React, { PureComponent } from 'react'; +import { PanelHeaderMenuItem } from './PanelHeaderMenuItem'; +import { PanelMenuItem } from '@grafana/data'; + +export interface Props { + items: PanelMenuItem[]; +} + +export class PanelHeaderMenu extends PureComponent { + renderItems = (menu: PanelMenuItem[], isSubMenu = false) => { + return ( +
    + {menu.map((menuItem, idx: number) => { + return ( + + {menuItem.subMenu && this.renderItems(menuItem.subMenu, true)} + + ); + })} +
+ ); + }; + + render() { + return
{this.renderItems(this.props.items)}
; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx new file mode 100644 index 0000000..86cbe5d --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx @@ -0,0 +1,67 @@ +import React, { FC, useState } from 'react'; +import { css } from 'emotion'; +import { PanelMenuItem } from '@grafana/data'; +import { Icon, IconName, useTheme } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +interface Props { + children?: any; +} + +export const PanelHeaderMenuItem: FC = (props) => { + const [ref, setRef] = useState(null); + const isSubMenu = props.type === 'submenu'; + const isDivider = props.type === 'divider'; + const theme = useTheme(); + const menuIconClassName = css` + margin-right: ${theme.spacing.sm}; + a::after { + display: none; + } + `; + const shortcutIconClassName = css` + position: absolute; + top: 7px; + right: ${theme.spacing.xs}; + color: ${theme.colors.textWeak}; + `; + + return isDivider ? ( +
  • + ) : ( +
  • + + {props.iconClassName && } + + {props.text} + {isSubMenu && } + + {props.shortcut && ( + + {props.shortcut} + + )} + + {props.children} +
  • + ); +}; + +function getDropdownLocationCssClass(element: HTMLElement | null) { + if (!element) { + return 'invisible'; + } + + const wrapperPos = element.parentElement!.getBoundingClientRect(); + const pos = element.getBoundingClientRect(); + + if (pos.width === 0) { + return 'invisible'; + } + + if (wrapperPos.right + pos.width + 10 > window.innerWidth) { + return 'pull-left'; + } else { + return 'pull-right'; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx new file mode 100644 index 0000000..4fd2950 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx @@ -0,0 +1,29 @@ +import { FC, ReactElement, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { PanelMenuItem } from '@grafana/data'; + +import { DashboardModel, PanelModel } from '../../state'; +import { StoreState } from '../../../../types'; +import { getPanelMenu } from '../../utils/getPanelMenu'; + +interface PanelHeaderMenuProviderApi { + items: PanelMenuItem[]; +} + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + children: (props: PanelHeaderMenuProviderApi) => ReactElement; +} + +export const PanelHeaderMenuProvider: FC = ({ panel, dashboard, children }) => { + const [items, setItems] = useState([]); + const angularComponent = useSelector( + (state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null + ); + useEffect(() => { + setItems(getPanelMenu(dashboard, panel, angularComponent)); + }, [dashboard, panel, angularComponent, setItems]); + + return children({ items }); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx new file mode 100644 index 0000000..3874129 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -0,0 +1,51 @@ +import React, { FC, HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react'; +import { CartesianCoords2D } from '@grafana/data'; + +interface PanelHeaderMenuTriggerApi { + panelMenuOpen: boolean; + closeMenu: () => void; +} + +interface Props extends HTMLAttributes { + children: (props: PanelHeaderMenuTriggerApi) => ReactElement; +} + +export const PanelHeaderMenuTrigger: FC = ({ children, ...divProps }) => { + const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); + const [panelMenuOpen, setPanelMenuOpen] = useState(false); + const onMenuToggle = useCallback( + (event: MouseEvent) => { + if (!isClick(clickCoordinates, eventToClickCoordinates(event))) { + return; + } + + event.stopPropagation(); + + setPanelMenuOpen(!panelMenuOpen); + }, + [clickCoordinates, panelMenuOpen, setPanelMenuOpen] + ); + const onMouseDown = useCallback( + (event: MouseEvent) => { + setClickCoordinates(eventToClickCoordinates(event)); + }, + [setClickCoordinates] + ); + + return ( +
    + {children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })} +
    + ); +}; + +function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D): boolean { + return clicked.x === current.x && clicked.y === current.y; +} + +function eventToClickCoordinates(event: MouseEvent): CartesianCoords2D { + return { + x: Math.floor(event.clientX), + y: Math.floor(event.clientY), + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx new file mode 100644 index 0000000..596dc96 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { ClickOutsideWrapper } from '@grafana/ui'; +import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider'; +import { PanelHeaderMenu } from './PanelHeaderMenu'; +import { DashboardModel, PanelModel } from '../../state'; + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + show: boolean; + onClose: () => void; +} + +export const PanelHeaderMenuWrapper: FC = ({ show, onClose, panel, dashboard }) => { + if (!show) { + return null; + } + + return ( + + + {({ items }) => { + return ; + }} + + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx new file mode 100644 index 0000000..16e9670 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { QueryResultMetaNotice } from '@grafana/data'; +import { Icon, Tooltip } from '@grafana/ui'; + +interface Props { + notice: QueryResultMetaNotice; + onClick: (e: React.SyntheticEvent, tab: string) => void; +} + +export const PanelHeaderNotice: FC = ({ notice, onClick }) => { + const iconName = + notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle'; + + return ( + + {notice.inspect ? ( +
    onClick(e, notice.inspect!)}> + +
    + ) : ( + + + + )} +
    + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx new file mode 100644 index 0000000..d4b888a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx @@ -0,0 +1,47 @@ +import React, { FC, useCallback } from 'react'; +import { DataFrame, QueryResultMetaNotice } from '@grafana/data'; +import { PanelHeaderNotice } from './PanelHeaderNotice'; +import { useDispatch } from 'react-redux'; +import { updateLocation } from '../../../../core/actions'; + +interface Props { + panelId: number; + frames: DataFrame[]; +} + +export const PanelHeaderNotices: FC = ({ frames, panelId }) => { + const dispatch = useDispatch(); + const openInspect = useCallback( + (e: React.SyntheticEvent, tab: string) => { + e.stopPropagation(); + + dispatch( + updateLocation({ + query: { inspect: panelId, inspectTab: tab }, + partial: true, + }) + ); + }, + [panelId] + ); + + // dedupe on severity + const notices: Record = {}; + for (const frame of frames) { + if (!frame.meta || !frame.meta.notices) { + continue; + } + + for (const notice of frame.meta.notices) { + notices[notice.severity] = notice; + } + } + + return ( + <> + {Object.values(notices).map((notice) => ( + + ))} + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelPluginError.tsx b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx new file mode 100644 index 0000000..ce6a9a5 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx @@ -0,0 +1,85 @@ +// Libraries +import _ from 'lodash'; +import React, { PureComponent, ReactNode } from 'react'; + +// Types +import { AppNotificationSeverity } from 'app/types'; +import { Alert } from '@grafana/ui'; +import { PanelProps, PanelPlugin, PluginType, PanelPluginMeta } from '@grafana/data'; + +interface Props { + title: string; + text?: ReactNode; +} + +class PanelPluginError extends PureComponent { + constructor(props: Props) { + super(props); + } + + render() { + const style = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }; + + return ( +
    + +
    + ); + } +} + +export function getPanelPluginLoadError(meta: PanelPluginMeta, err: any): PanelPlugin { + const LoadError = class LoadError extends PureComponent { + render() { + const text = ( + <> + Check the server startup logs for more information.
    + If this plugin was loaded from git, make sure it was compiled. + + ); + return ; + } + }; + const plugin = new PanelPlugin(LoadError); + plugin.meta = meta; + plugin.loadError = true; + return plugin; +} + +export function getPanelPluginNotFound(id: string): PanelPlugin { + const NotFound = class NotFound extends PureComponent { + render() { + return ; + } + }; + + const plugin = new PanelPlugin(NotFound); + plugin.meta = { + id: id, + name: id, + sort: 100, + type: PluginType.panel, + module: '', + baseUrl: '', + info: { + author: { + name: '', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + }; + return plugin; +} diff --git a/public/app/features/dashboard/dashgrid/PanelResizer.tsx b/public/app/features/dashboard/dashgrid/PanelResizer.tsx new file mode 100644 index 0000000..6b169e2 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx @@ -0,0 +1,77 @@ +import React, { PureComponent } from 'react'; +import { throttle } from 'lodash'; +import Draggable, { DraggableEventHandler } from 'react-draggable'; + +import { PanelModel } from '../state/PanelModel'; + +interface Props { + isEditing: boolean; + render: (styles: object) => JSX.Element; + panel: PanelModel; +} + +interface State { + editorHeight: number; +} + +export class PanelResizer extends PureComponent { + initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3); + prevEditorHeight: number; + throttledChangeHeight: (height: number) => void; + throttledResizeDone: () => void; + noStyles: object = {}; + + constructor(props: Props) { + super(props); + + this.state = { + editorHeight: this.initialHeight, + }; + + this.throttledChangeHeight = throttle(this.changeHeight, 20, { trailing: true }); + } + + get largestHeight() { + return document.documentElement.scrollHeight * 0.9; + } + get smallestHeight() { + return 100; + } + + changeHeight = (height: number) => { + const sh = this.smallestHeight; + const lh = this.largestHeight; + height = height < sh ? sh : height; + height = height > lh ? lh : height; + + this.prevEditorHeight = this.state.editorHeight; + this.setState({ + editorHeight: height, + }); + }; + + onDrag: DraggableEventHandler = (evt, data) => { + const newHeight = this.state.editorHeight + data.y; + this.throttledChangeHeight(newHeight); + }; + + render() { + const { render, isEditing } = this.props; + const { editorHeight } = this.state; + + return ( + <> + {render(isEditing ? { height: editorHeight } : this.noStyles)} + {isEditing && ( +
    + +
    +
    +
    + +
    + )} + + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap new file mode 100644 index 0000000..8ae6c30 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/__snapshots__/DashboardGrid.test.tsx.snap @@ -0,0 +1,943 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardGrid Can render dashboard grid Should render 1`] = ` + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +`; diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts new file mode 100644 index 0000000..7e990f9 --- /dev/null +++ b/public/app/features/dashboard/index.ts @@ -0,0 +1,9 @@ +// Services +import './services/UnsavedChangesSrv'; +import './services/DashboardLoaderSrv'; +import './services/DashboardSrv'; +// Components +import './components/DashExportModal'; +import './components/DashNav'; +import './components/VersionHistory'; +import './components/DashboardSettings'; diff --git a/public/app/features/dashboard/services/ChangeTracker.test.ts b/public/app/features/dashboard/services/ChangeTracker.test.ts new file mode 100644 index 0000000..b81c7d3 --- /dev/null +++ b/public/app/features/dashboard/services/ChangeTracker.test.ts @@ -0,0 +1,99 @@ +import { ChangeTracker } from './ChangeTracker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + user: { orgId: 1 }, + }, +})); + +describe('ChangeTracker', () => { + let rootScope: any; + let location; + const timeout = () => {}; + let tracker: ChangeTracker; + let dash: any; + let scope; + + beforeEach(() => { + dash = new DashboardModel({ + refresh: false, + panels: [ + { + id: 1, + type: 'graph', + gridPos: { x: 0, y: 0, w: 24, h: 6 }, + legend: { sortDesc: false }, + }, + { + id: 2, + type: 'row', + gridPos: { x: 0, y: 6, w: 24, h: 2 }, + collapsed: true, + panels: [ + { id: 3, type: 'graph', gridPos: { x: 0, y: 6, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 6, w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } }, + ], + }); + + scope = { + appEvent: jest.fn(), + onAppEvent: jest.fn(), + $on: jest.fn(), + }; + + rootScope = { + appEvent: jest.fn(), + onAppEvent: jest.fn(), + $on: jest.fn(), + }; + + location = { + path: jest.fn(), + }; + + tracker = new ChangeTracker(dash, scope as any, undefined, location as any, window, timeout, contextSrv, rootScope); + }); + + it('No changes should not have changes', () => { + expect(tracker.hasChanges()).toBe(false); + }); + + it('Simple change should be registered', () => { + dash.title = 'google'; + expect(tracker.hasChanges()).toBe(true); + }); + + it('Should ignore a lot of changes', () => { + dash.time = { from: '1h' }; + dash.refresh = true; + dash.schemaVersion = 10; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore .iteration changes', () => { + dash.iteration = new Date().getTime() + 1; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore row collapse change', () => { + dash.toggleRow(dash.panels[1]); + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore panel legend changes', () => { + dash.panels[0].legend.sortDesc = true; + dash.panels[0].legend.sort = 'avg'; + expect(tracker.hasChanges()).toBe(false); + }); + + it('Should ignore panel repeats', () => { + dash.panels.push(new PanelModel({ repeatPanelId: 10 })); + expect(tracker.hasChanges()).toBe(false); + }); +}); diff --git a/public/app/features/dashboard/services/ChangeTracker.ts b/public/app/features/dashboard/services/ChangeTracker.ts new file mode 100644 index 0000000..c9dde52 --- /dev/null +++ b/public/app/features/dashboard/services/ChangeTracker.ts @@ -0,0 +1,201 @@ +import angular, { ILocationService, IRootScopeService } from 'angular'; +import _ from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { ContextSrv } from 'app/core/services/context_srv'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; +import { AppEventConsumer, CoreEvents } from 'app/types'; +import { appEvents } from 'app/core/app_events'; +import { UnsavedChangesModal } from '../components/SaveDashboard/UnsavedChangesModal'; + +export class ChangeTracker { + current: any; + originalPath: any; + scope: any; + original: any; + next: any; + $window: any; + + /** @ngInject */ + constructor( + dashboard: DashboardModel, + scope: IRootScopeService & AppEventConsumer, + originalCopyDelay: any, + private $location: ILocationService, + $window: any, + private $timeout: any, + private contextSrv: ContextSrv, + private $rootScope: GrafanaRootScope + ) { + this.$location = $location; + this.$window = $window; + + this.current = dashboard; + this.originalPath = $location.path(); + this.scope = scope; + + // register events + appEvents.on(CoreEvents.dashboardSaved, () => { + this.original = this.current.getSaveModelClone(); + this.originalPath = $location.path(); + }); + + $window.onbeforeunload = () => { + if (this.ignoreChanges()) { + return undefined; + } + if (this.hasChanges()) { + return 'There are unsaved changes to this dashboard'; + } + return undefined; + }; + + scope.$on('$locationChangeStart', (event: any, next: any) => { + // check if we should look for changes + if (this.originalPath === $location.path()) { + return true; + } + + if (this.ignoreChanges()) { + return true; + } + + if (this.hasChanges()) { + event.preventDefault(); + this.next = next; + + this.$timeout(() => { + this.open_modal(); + }); + } + return false; + }); + + if (originalCopyDelay && !dashboard.meta.fromExplore) { + this.$timeout(() => { + // wait for different services to patch the dashboard (missing properties) + this.original = dashboard.getSaveModelClone(); + }, originalCopyDelay); + } else { + this.original = dashboard.getSaveModelClone(); + } + } + + // for some dashboards and users + // changes should be ignored + ignoreChanges() { + if (!this.original) { + return true; + } + if (!this.contextSrv.isEditor) { + return true; + } + if (!this.current || !this.current.meta) { + return true; + } + + //Ignore changes if the user has been signed out + if (!this.contextSrv.isSignedIn) { + return true; + } + + const meta = this.current.meta; + return !meta.canSave || meta.fromScript || meta.fromFile; + } + + // remove stuff that should not count in diff + cleanDashboardFromIgnoredChanges(dashData: any) { + // need to new up the domain model class to get access to expand / collapse row logic + const model = new DashboardModel(dashData); + + // Expand all rows before making comparison. This is required because row expand / collapse + // change order of panel array and panel positions. + model.expandRows(); + + const dash = model.getSaveModelClone(); + + // ignore time and refresh + dash.time = 0; + dash.refresh = 0; + dash.schemaVersion = 0; + dash.timezone = 0; + + // ignore iteration property + delete dash.iteration; + + dash.panels = _.filter(dash.panels, (panel) => { + if (panel.repeatPanelId) { + return false; + } + + // remove scopedVars + panel.scopedVars = undefined; + + // ignore panel legend sort + if (panel.legend) { + delete panel.legend.sort; + delete panel.legend.sortDesc; + } + + return true; + }); + + // ignore template variable values + _.each(dash.getVariables(), (variable: any) => { + variable.current = null; + variable.options = null; + variable.filters = null; + }); + + return dash; + } + + hasChanges() { + const current = this.cleanDashboardFromIgnoredChanges(this.current.getSaveModelClone()); + const original = this.cleanDashboardFromIgnoredChanges(this.original); + + const currentTimepicker: any = _.find((current as any).nav, { type: 'timepicker' }); + const originalTimepicker: any = _.find((original as any).nav, { type: 'timepicker' }); + + if (currentTimepicker && originalTimepicker) { + currentTimepicker.now = originalTimepicker.now; + } + + const currentJson = angular.toJson(current, true); + const originalJson = angular.toJson(original, true); + + return currentJson !== originalJson; + } + + discardChanges = () => { + this.original = null; + this.gotoNext(); + }; + + open_modal = () => { + this.$rootScope.appEvent(CoreEvents.showModalReact, { + component: UnsavedChangesModal, + props: { + dashboard: this.current, + onSaveSuccess: this.onSaveSuccess, + onDiscard: () => { + this.discardChanges(); + }, + }, + }); + }; + + onSaveSuccess = () => { + this.$timeout(() => { + this.gotoNext(); + }); + }; + + gotoNext = () => { + const baseLen = this.$location.absUrl().length - this.$location.url().length; + const nextUrl = this.next.substring(baseLen); + + this.$timeout(() => { + this.$location.url(nextUrl); + }); + }; +} diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts new file mode 100644 index 0000000..96dc24e --- /dev/null +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -0,0 +1,139 @@ +import angular from 'angular'; +import moment from 'moment'; // eslint-disable-line no-restricted-imports +import _ from 'lodash'; +import $ from 'jquery'; +import kbn from 'app/core/utils/kbn'; +import { AppEvents, dateMath, UrlQueryValue } from '@grafana/data'; +import impressionSrv from 'app/core/services/impression_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardSrv } from './DashboardSrv'; +import DatasourceSrv from 'app/features/plugins/datasource_srv'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; + +export class DashboardLoaderSrv { + /** @ngInject */ + constructor( + private dashboardSrv: DashboardSrv, + private datasourceSrv: DatasourceSrv, + private $http: any, + private $timeout: any, + contextSrv: any, + private $routeParams: any, + private $rootScope: GrafanaRootScope + ) {} + + _dashboardLoadFailed(title: string, snapshot?: boolean) { + snapshot = snapshot || false; + return { + meta: { + canStar: false, + isSnapshot: snapshot, + canDelete: false, + canSave: false, + canEdit: false, + dashboardNotFound: true, + }, + dashboard: { title }, + }; + } + + loadDashboard(type: UrlQueryValue, slug: any, uid: any) { + let promise; + + if (type === 'script') { + promise = this._loadScriptedDashboard(slug); + } else if (type === 'snapshot') { + promise = backendSrv.get('/api/snapshots/' + slug).catch(() => { + return this._dashboardLoadFailed('Snapshot not found', true); + }); + } else { + promise = backendSrv + .getDashboardByUid(uid) + .then((result: any) => { + if (result.meta.isFolder) { + this.$rootScope.appEvent(AppEvents.alertError, ['Dashboard not found']); + throw new Error('Dashboard not found'); + } + return result; + }) + .catch(() => { + return this._dashboardLoadFailed('Not found', true); + }); + } + + promise.then((result: any) => { + if (result.meta.dashboardNotFound !== true) { + impressionSrv.addDashboardImpression(result.dashboard.id); + } + + return result; + }); + + return promise; + } + + _loadScriptedDashboard(file: string) { + const url = 'public/dashboards/' + file.replace(/\.(?!js)/, '/') + '?' + new Date().getTime(); + + return this.$http({ url: url, method: 'GET' }) + .then(this._executeScript.bind(this)) + .then( + (result: any) => { + return { + meta: { + fromScript: true, + canDelete: false, + canSave: false, + canStar: false, + }, + dashboard: result.data, + }; + }, + (err: any) => { + console.error('Script dashboard error ' + err); + this.$rootScope.appEvent(AppEvents.alertError, [ + 'Script Error', + 'Please make sure it exists and returns a valid dashboard', + ]); + return this._dashboardLoadFailed('Scripted dashboard'); + } + ); + } + + _executeScript(result: any) { + const services = { + dashboardSrv: this.dashboardSrv, + datasourceSrv: this.datasourceSrv, + }; + + const scriptFunc = new Function( + 'ARGS', + 'kbn', + 'dateMath', + '_', + 'moment', + 'window', + 'document', + '$', + 'jQuery', + 'services', + result.data + ); + const scriptResult = scriptFunc(this.$routeParams, kbn, dateMath, _, moment, window, document, $, $, services); + + // Handle async dashboard scripts + if (_.isFunction(scriptResult)) { + return new Promise((resolve) => { + scriptResult((dashboard: any) => { + this.$timeout(() => { + resolve({ data: dashboard }); + }); + }); + }); + } + + return { data: scriptResult }; + } +} + +angular.module('grafana.services').service('dashboardLoaderSrv', DashboardLoaderSrv); diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts new file mode 100644 index 0000000..e86e686 --- /dev/null +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -0,0 +1,84 @@ +import coreModule from 'app/core/core_module'; +import { appEvents } from 'app/core/app_events'; +import { DashboardModel } from '../state/DashboardModel'; +import { removePanel } from '../utils/panel'; +import { CoreEvents, DashboardMeta } from 'app/types'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { promiseToDigest } from '../../../core/utils/promiseToDigest'; +import { saveDashboard } from 'app/features/manage-dashboards/state/actions'; + +export class DashboardSrv { + dashboard: DashboardModel; + + /** @ngInject */ + constructor(private $rootScope: GrafanaRootScope) { + appEvents.on(CoreEvents.removePanel, this.onRemovePanel); + } + + create(dashboard: any, meta: DashboardMeta) { + return new DashboardModel(dashboard, meta); + } + + setCurrent(dashboard: DashboardModel) { + this.dashboard = dashboard; + } + + getCurrent(): DashboardModel { + return this.dashboard; + } + + onRemovePanel = (panelId: number) => { + const dashboard = this.getCurrent(); + removePanel(dashboard, dashboard.getPanelById(panelId)!, true); + }; + + saveJSONDashboard(json: string) { + const parsedJson = JSON.parse(json); + return saveDashboard({ + dashboard: parsedJson, + folderId: this.dashboard.meta.folderId || parsedJson.folderId, + }); + } + + starDashboard(dashboardId: string, isStarred: any) { + let promise; + + if (isStarred) { + promise = promiseToDigest(this.$rootScope)( + backendSrv.delete('/api/user/stars/dashboard/' + dashboardId).then(() => { + return false; + }) + ); + } else { + promise = promiseToDigest(this.$rootScope)( + backendSrv.post('/api/user/stars/dashboard/' + dashboardId).then(() => { + return true; + }) + ); + } + + return promise.then((res: boolean) => { + if (this.dashboard && this.dashboard.id === dashboardId) { + this.dashboard.meta.isStarred = res; + } + return res; + }); + } +} + +coreModule.service('dashboardSrv', DashboardSrv); + +// +// Code below is to export the service to react components +// + +let singletonInstance: DashboardSrv; + +export function setDashboardSrv(instance: DashboardSrv) { + singletonInstance = instance; +} + +export function getDashboardSrv(): DashboardSrv { + return singletonInstance; +} diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts new file mode 100644 index 0000000..04786c3 --- /dev/null +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -0,0 +1,252 @@ +import { TimeSrv } from './TimeSrv'; +import { ContextSrvStub } from 'test/specs/helpers'; +import { isDateTime, dateTime } from '@grafana/data'; + +jest.mock('app/core/core', () => ({ + appEvents: { + on: () => {}, + }, +})); + +describe('timeSrv', () => { + const rootScope = { + $on: jest.fn(), + onAppEvent: jest.fn(), + appEvent: jest.fn(), + }; + + const timer = { + register: jest.fn(), + cancel: jest.fn(), + cancelAll: jest.fn(), + }; + + let location = { + search: jest.fn(() => ({})), + }; + + let timeSrv: TimeSrv; + + const _dashboard: any = { + time: { from: 'now-6h', to: 'now' }, + getTimezone: jest.fn(() => 'browser'), + }; + + beforeEach(() => { + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + timeSrv.init(_dashboard); + _dashboard.refresh = false; + }); + + describe('timeRange', () => { + it('should return unparsed when parse is false', () => { + timeSrv.setTime({ from: 'now', to: 'now-1h' }); + const time = timeSrv.timeRange(); + expect(time.raw.from).toBe('now'); + expect(time.raw.to).toBe('now-1h'); + }); + + it('should return parsed when parse is true', () => { + timeSrv.setTime({ from: 'now', to: 'now-1h' }); + const time = timeSrv.timeRange(); + expect(isDateTime(time.from)).toBe(true); + expect(isDateTime(time.to)).toBe(true); + }); + }); + + describe('init time from url', () => { + it('should handle relative times', () => { + location = { + search: jest.fn(() => ({ + from: 'now-2d', + to: 'now', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.raw.from).toBe('now-2d'); + expect(time.raw.to).toBe('now'); + }); + + it('should handle formatted dates', () => { + location = { + search: jest.fn(() => ({ + from: '20140410T052010', + to: '20140520T031022', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(new Date('2014-04-10T05:20:10Z').getTime()); + expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime()); + }); + + it('should ignore refresh if time absolute', () => { + location = { + search: jest.fn(() => ({ + from: '20140410T052010', + to: '20140520T031022', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + // dashboard saved with refresh on + _dashboard.refresh = true; + timeSrv.init(_dashboard); + + expect(timeSrv.refresh).toBe(false); + }); + + it('should handle formatted dates without time', () => { + location = { + search: jest.fn(() => ({ + from: '20140410', + to: '20140520', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(new Date('2014-04-10T00:00:00Z').getTime()); + expect(time.to.valueOf()).toEqual(new Date('2014-05-20T00:00:00Z').getTime()); + }); + + it('should handle epochs', () => { + location = { + search: jest.fn(() => ({ + from: '1410337646373', + to: '1410337665699', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337646373); + expect(time.to.valueOf()).toEqual(1410337665699); + }); + + it('should handle epochs that look like formatted date without time', () => { + location = { + search: jest.fn(() => ({ + from: '20149999', + to: '20159999', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(20149999); + expect(time.to.valueOf()).toEqual(20159999); + }); + + it('should handle epochs that look like formatted date', () => { + location = { + search: jest.fn(() => ({ + from: '201499991234567', + to: '201599991234567', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(201499991234567); + expect(time.to.valueOf()).toEqual(201599991234567); + }); + + it('should handle bad dates', () => { + location = { + search: jest.fn(() => ({ + from: '20151126T00010%3C%2Fp%3E%3Cspan%20class', + to: 'now', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + _dashboard.time.from = 'now-6h'; + timeSrv.init(_dashboard); + expect(timeSrv.time.from).toEqual('now-6h'); + expect(timeSrv.time.to).toEqual('now'); + }); + + describe('data point windowing', () => { + it('handles time window specfied as interval string', () => { + location = { + search: jest.fn(() => ({ + time: '1410337645000', + 'time.window': '10s', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337640000); + expect(time.to.valueOf()).toEqual(1410337650000); + }); + it('handles time window specified in ms', () => { + location = { + search: jest.fn(() => ({ + time: '1410337645000', + 'time.window': '10000', + })), + }; + + timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any); + + timeSrv.init(_dashboard); + const time = timeSrv.timeRange(); + expect(time.from.valueOf()).toEqual(1410337640000); + expect(time.to.valueOf()).toEqual(1410337650000); + }); + }); + }); + + describe('setTime', () => { + it('should return disable refresh if refresh is disabled for any range', () => { + _dashboard.refresh = false; + + timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).toBe(false); + }); + + it('should restore refresh for absolute time range', () => { + _dashboard.refresh = '30s'; + + timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).toBe('30s'); + }); + + it('should restore refresh after relative time range is set', () => { + _dashboard.refresh = '10s'; + timeSrv.setTime({ + from: dateTime([2011, 1, 1]), + to: dateTime([2015, 1, 1]), + }); + expect(_dashboard.refresh).toBe(false); + timeSrv.setTime({ from: '2011-01-01', to: 'now' }); + expect(_dashboard.refresh).toBe('10s'); + }); + + it('should keep refresh after relative time range is changed and now delay exists', () => { + _dashboard.refresh = '10s'; + timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); + expect(_dashboard.refresh).toBe('10s'); + }); + }); +}); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts new file mode 100644 index 0000000..df59d33 --- /dev/null +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -0,0 +1,316 @@ +import _ from 'lodash'; +import { ILocationService, ITimeoutService } from 'angular'; +import { + dateMath, + dateTime, + getDefaultTimeRange, + isDateTime, + rangeUtil, + RawTimeRange, + TimeRange, + toUtc, +} from '@grafana/data'; + +import coreModule from 'app/core/core_module'; +import { ContextSrv } from 'app/core/services/context_srv'; +import { DashboardModel } from '../state/DashboardModel'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; +import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { appEvents } from '../../../core/core'; +import { CoreEvents } from '../../../types'; +import { config } from 'app/core/config'; +import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; + +export class TimeSrv { + time: any; + refreshTimer: any; + refresh: any; + oldRefresh: string | null | undefined; + dashboard: DashboardModel; + timeAtLoad: any; + private autoRefreshBlocked: boolean; + + /** @ngInject */ + constructor( + $rootScope: GrafanaRootScope, + private $timeout: ITimeoutService, + private $location: ILocationService, + private timer: any, + private contextSrv: ContextSrv + ) { + // default time + this.time = getDefaultTimeRange().raw; + + appEvents.on(CoreEvents.zoomOut, this.zoomOut.bind(this)); + appEvents.on(CoreEvents.shiftTime, this.shiftTime.bind(this)); + $rootScope.$on('$routeUpdate', this.routeUpdated.bind(this)); + + document.addEventListener('visibilitychange', () => { + if (this.autoRefreshBlocked && document.visibilityState === 'visible') { + this.autoRefreshBlocked = false; + this.refreshDashboard(); + } + }); + } + + init(dashboard: DashboardModel) { + this.timer.cancelAll(); + + this.dashboard = dashboard; + this.time = dashboard.time; + this.refresh = dashboard.refresh; + + this.initTimeFromUrl(); + this.parseTime(); + + // remember time at load so we can go back to it + this.timeAtLoad = _.cloneDeep(this.time); + + if (this.refresh) { + this.setAutoRefresh(this.refresh); + } + } + + getValidIntervals(intervals: string[]): string[] { + if (!this.contextSrv.minRefreshInterval) { + return intervals; + } + + return intervals.filter((str) => str !== '').filter(this.contextSrv.isAllowedInterval); + } + + private parseTime() { + // when absolute time is saved in json it is turned to a string + if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { + this.time.from = dateTime(this.time.from).utc(); + } + if (_.isString(this.time.to) && this.time.to.indexOf('Z') >= 0) { + this.time.to = dateTime(this.time.to).utc(); + } + } + + private parseUrlParam(value: any) { + if (value.indexOf('now') !== -1) { + return value; + } + if (value.length === 8) { + const utcValue = toUtc(value, 'YYYYMMDD'); + if (utcValue.isValid()) { + return utcValue; + } + } else if (value.length === 15) { + const utcValue = toUtc(value, 'YYYYMMDDTHHmmss'); + if (utcValue.isValid()) { + return utcValue; + } + } + + if (!isNaN(value)) { + const epoch = parseInt(value, 10); + return toUtc(epoch); + } + + return null; + } + + private getTimeWindow(time: string, timeWindow: string) { + const valueTime = parseInt(time, 10); + let timeWindowMs; + + if (timeWindow.match(/^\d+$/) && parseInt(timeWindow, 10)) { + // when time window specified in ms + timeWindowMs = parseInt(timeWindow, 10); + } else { + timeWindowMs = rangeUtil.intervalToMs(timeWindow); + } + + return { + from: toUtc(valueTime - timeWindowMs / 2), + to: toUtc(valueTime + timeWindowMs / 2), + }; + } + + private initTimeFromUrl() { + const params = this.$location.search(); + + if (params.time && params['time.window']) { + this.time = this.getTimeWindow(params.time, params['time.window']); + } + + if (params.from) { + this.time.from = this.parseUrlParam(params.from) || this.time.from; + } + if (params.to) { + this.time.to = this.parseUrlParam(params.to) || this.time.to; + } + // if absolute ignore refresh option saved to dashboard + if (params.to && params.to.indexOf('now') === -1) { + this.refresh = false; + this.dashboard.refresh = false; + } + // but if refresh explicitly set then use that + this.refresh = getRefreshFromUrl({ + params, + currentRefresh: this.refresh, + refreshIntervals: this.dashboard?.timepicker?.refresh_intervals, + isAllowedIntervalFn: this.contextSrv.isAllowedInterval, + minRefreshInterval: config.minRefreshInterval, + }); + } + + private routeUpdated() { + const params = this.$location.search(); + if (params.left) { + return; // explore handles this; + } + const urlRange = this.timeRangeForUrl(); + // check if url has time range + if (params.from && params.to) { + // is it different from what our current time range? + if (params.from !== urlRange.from || params.to !== urlRange.to) { + // issue update + this.initTimeFromUrl(); + this.setTime(this.time, true); + } + } else if (this.timeHasChangedSinceLoad()) { + this.setTime(this.timeAtLoad, true); + } + } + + private timeHasChangedSinceLoad() { + return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to); + } + + setAutoRefresh(interval: any) { + this.dashboard.refresh = interval; + this.cancelNextRefresh(); + + if (interval) { + const validInterval = this.contextSrv.getValidInterval(interval); + const intervalMs = rangeUtil.intervalToMs(validInterval); + + this.refreshTimer = this.timer.register( + this.$timeout(() => { + this.startNextRefreshTimer(intervalMs); + this.refreshDashboard(); + }, intervalMs) + ); + } + + // update url inside timeout to so that a digest happens after (called from react) + this.$timeout(() => { + const params = this.$location.search(); + if (interval) { + params.refresh = this.contextSrv.getValidInterval(interval); + this.$location.search(params); + } else if (params.refresh) { + delete params.refresh; + this.$location.search(params); + } + }); + } + + refreshDashboard() { + this.dashboard.timeRangeUpdated(this.timeRange()); + } + + private startNextRefreshTimer(afterMs: number) { + this.cancelNextRefresh(); + this.refreshTimer = this.timer.register( + this.$timeout(() => { + this.startNextRefreshTimer(afterMs); + if (this.contextSrv.isGrafanaVisible()) { + this.refreshDashboard(); + } else { + this.autoRefreshBlocked = true; + } + }, afterMs) + ); + } + + private cancelNextRefresh() { + this.timer.cancel(this.refreshTimer); + } + + setTime(time: RawTimeRange, fromRouteUpdate?: boolean) { + _.extend(this.time, time); + + // disable refresh if zoom in or zoom out + if (isDateTime(time.to)) { + this.oldRefresh = this.dashboard.refresh || this.oldRefresh; + this.setAutoRefresh(false); + } else if (this.oldRefresh && this.oldRefresh !== this.dashboard.refresh) { + this.setAutoRefresh(this.oldRefresh); + this.oldRefresh = null; + } + + // update url + if (fromRouteUpdate !== true) { + const urlRange = this.timeRangeForUrl(); + const urlParams = this.$location.search(); + urlParams.from = urlRange.from; + urlParams.to = urlRange.to; + this.$location.search(urlParams); + } + + this.$timeout(this.refreshDashboard.bind(this), 0); + } + + timeRangeForUrl = () => { + const range = this.timeRange().raw; + + if (isDateTime(range.from)) { + range.from = range.from.valueOf().toString(); + } + if (isDateTime(range.to)) { + range.to = range.to.valueOf().toString(); + } + + return range; + }; + + timeRange(): TimeRange { + // make copies if they are moment (do not want to return out internal moment, because they are mutable!) + const raw = { + from: isDateTime(this.time.from) ? dateTime(this.time.from) : this.time.from, + to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to, + }; + + const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; + + return { + from: dateMath.parse(raw.from, false, timezone)!, + to: dateMath.parse(raw.to, true, timezone)!, + raw: raw, + }; + } + + zoomOut(factor: number) { + const range = this.timeRange(); + const { from, to } = getZoomedTimeRange(range, factor); + + this.setTime({ from: toUtc(from), to: toUtc(to) }); + } + + shiftTime(direction: number) { + const range = this.timeRange(); + const { from, to } = getShiftedTimeRange(direction, range); + + this.setTime({ + from: toUtc(from), + to: toUtc(to), + }); + } +} + +let singleton: TimeSrv; + +export function setTimeSrv(srv: TimeSrv) { + singleton = srv; +} + +export function getTimeSrv(): TimeSrv { + return singleton; +} + +coreModule.service('timeSrv', TimeSrv); diff --git a/public/app/features/dashboard/services/UnsavedChangesSrv.ts b/public/app/features/dashboard/services/UnsavedChangesSrv.ts new file mode 100644 index 0000000..4b71a83 --- /dev/null +++ b/public/app/features/dashboard/services/UnsavedChangesSrv.ts @@ -0,0 +1,23 @@ +import angular, { ILocationService } from 'angular'; +import { ChangeTracker } from './ChangeTracker'; +import { ContextSrv } from 'app/core/services/context_srv'; +import { DashboardSrv } from './DashboardSrv'; +import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; + +/** @ngInject */ +export function unsavedChangesSrv( + this: any, + $rootScope: GrafanaRootScope, + $location: ILocationService, + $timeout: any, + contextSrv: ContextSrv, + dashboardSrv: DashboardSrv, + $window: any +) { + this.init = function (dashboard: any, scope: any) { + this.tracker = new ChangeTracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope); + return this.tracker; + }; +} + +angular.module('grafana.services').service('unsavedChangesSrv', unsavedChangesSrv); diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts new file mode 100644 index 0000000..40acec1 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -0,0 +1,935 @@ +import _ from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; +import { expect } from 'test/lib/common'; +import { DataLinkBuiltInVars } from '@grafana/data'; +import { VariableHide } from '../../variables/types'; + +jest.mock('app/core/services/context_srv', () => ({})); + +describe('DashboardModel', () => { + describe('when creating dashboard with old schema', () => { + let model: any; + let graph: any; + let singlestat: any; + let table: any; + + beforeEach(() => { + model = new DashboardModel({ + services: { + filter: { time: { from: 'now-1d', to: 'now' }, list: [{}] }, + }, + pulldowns: [ + { type: 'filtering', enable: true }, + { type: 'annotations', enable: true, annotations: [{ name: 'old' }] }, + ], + panels: [ + { + type: 'graph', + legend: true, + aliasYAxis: { test: 2 }, + y_formats: ['kbyte', 'ms'], + grid: { + min: 1, + max: 10, + rightMin: 5, + rightMax: 15, + leftLogBase: 1, + rightLogBase: 2, + threshold1: 200, + threshold2: 400, + threshold1Color: 'yellow', + threshold2Color: 'red', + }, + leftYAxisLabel: 'left label', + targets: [{ refId: 'A' }, {}], + }, + { + type: 'singlestat', + legend: true, + thresholds: '10,20,30', + aliasYAxis: { test: 2 }, + grid: { min: 1, max: 10 }, + targets: [{ refId: 'A' }, {}], + }, + { + type: 'table', + legend: true, + styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }], + targets: [{ refId: 'A' }, {}], + }, + ], + }); + + graph = model.panels[0]; + singlestat = model.panels[1]; + table = model.panels[2]; + }); + + it('should have title', () => { + expect(model.title).toBe('No Title'); + }); + + it('should have panel id', () => { + expect(graph.id).toBe(1); + }); + + it('should move time and filtering list', () => { + expect(model.time.from).toBe('now-1d'); + expect(model.templating.list[0].allFormat).toBe('glob'); + }); + + it('graphite panel should change name too graph', () => { + expect(graph.type).toBe('graph'); + }); + + it('single stat panel should have two thresholds', () => { + expect(singlestat.thresholds).toBe('20,30'); + }); + + it('queries without refId should get it', () => { + expect(graph.targets[1].refId).toBe('B'); + }); + + it('update legend setting', () => { + expect(graph.legend.show).toBe(true); + }); + + it('move aliasYAxis to series override', () => { + expect(graph.seriesOverrides[0].alias).toBe('test'); + expect(graph.seriesOverrides[0].yaxis).toBe(2); + }); + + it('should move pulldowns to new schema', () => { + expect(model.annotations.list[1].name).toBe('old'); + }); + + it('table panel should only have two thresholds values', () => { + expect(table.styles[0].thresholds[0]).toBe('20'); + expect(table.styles[0].thresholds[1]).toBe('30'); + expect(table.styles[1].thresholds[0]).toBe('200'); + expect(table.styles[1].thresholds[1]).toBe('300'); + }); + + it('table type should be deprecated', () => { + expect(table.type).toBe('table-old'); + }); + + it('graph grid to yaxes options', () => { + expect(graph.yaxes[0].min).toBe(1); + expect(graph.yaxes[0].max).toBe(10); + expect(graph.yaxes[0].format).toBe('kbyte'); + expect(graph.yaxes[0].label).toBe('left label'); + expect(graph.yaxes[0].logBase).toBe(1); + expect(graph.yaxes[1].min).toBe(5); + expect(graph.yaxes[1].max).toBe(15); + expect(graph.yaxes[1].format).toBe('ms'); + expect(graph.yaxes[1].logBase).toBe(2); + + expect(graph.grid.rightMax).toBe(undefined); + expect(graph.grid.rightLogBase).toBe(undefined); + expect(graph.y_formats).toBe(undefined); + }); + + it('dashboard schema version should be set to latest', () => { + expect(model.schemaVersion).toBe(27); + }); + + it('graph thresholds should be migrated', () => { + expect(graph.thresholds.length).toBe(2); + expect(graph.thresholds[0].op).toBe('gt'); + expect(graph.thresholds[0].value).toBe(200); + expect(graph.thresholds[0].fillColor).toBe('yellow'); + expect(graph.thresholds[1].value).toBe(400); + expect(graph.thresholds[1].fillColor).toBe('red'); + }); + + it('graph thresholds should be migrated onto specified thresholds', () => { + model = new DashboardModel({ + panels: [ + { + type: 'graph', + y_formats: ['kbyte', 'ms'], + grid: { + threshold1: 200, + threshold2: 400, + }, + thresholds: [{ value: 100 }], + }, + ], + }); + graph = model.panels[0]; + expect(graph.thresholds.length).toBe(3); + expect(graph.thresholds[0].value).toBe(100); + expect(graph.thresholds[1].value).toBe(200); + expect(graph.thresholds[2].value).toBe(400); + }); + }); + + describe('when migrating to the grid layout', () => { + let model: any; + + beforeEach(() => { + model = { + rows: [], + }; + }); + + it('should create proper grid', () => { + model.rows = [createRow({ collapse: false, height: 8 }, [[6], [6]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 8 }, + { x: 12, y: 0, w: 12, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add special "row" panel if row is collapsed', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add special "row" panel if row has visible title', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row', height: 8 }, [[6], [6]]), + createRow({ height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 12, h: 8 }, + { x: 12, y: 1, w: 12, h: 8 }, + { x: 0, y: 9, w: 24, h: 8 }, // row + { x: 0, y: 10, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should not add "row" panel if row has not visible title or not collapsed', () => { + model.rows = [ + createRow({ collapse: true, height: 8 }, [[12]]), + createRow({ height: 8 }, [[12]]), + createRow({ height: 8 }, [[12], [6], [6]]), + createRow({ collapse: true, height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + { x: 0, y: 10, w: 24, h: 8 }, // row + { x: 0, y: 11, w: 24, h: 8 }, + { x: 0, y: 19, w: 12, h: 8 }, + { x: 12, y: 19, w: 12, h: 8 }, + { x: 0, y: 27, w: 24, h: 8 }, // row + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add all rows if even one collapsed or titled row is present', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]]), createRow({ height: 8 }, [[12]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, // row + { x: 0, y: 1, w: 24, h: 8 }, // row + { x: 0, y: 2, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should properly place panels with fixed height', () => { + model.rows = [ + createRow({ height: 6 }, [[6], [6, 3], [6, 3]]), + createRow({ height: 6 }, [[4], [4], [4, 3], [4, 3]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 6 }, + { x: 12, y: 0, w: 12, h: 3 }, + { x: 12, y: 3, w: 12, h: 3 }, + { x: 0, y: 6, w: 8, h: 6 }, + { x: 8, y: 6, w: 8, h: 6 }, + { x: 16, y: 6, w: 8, h: 3 }, + { x: 16, y: 9, w: 8, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should place panel to the right side of panel having bigger height', () => { + model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 6 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if it possible', () => { + model.rows = [createRow({ height: 9 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 9 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + { x: 8, y: 6, w: 16, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if it possible (2)', () => { + model.rows = [createRow({ height: 8 }, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 8 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 6 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + { x: 8, y: 6, w: 16, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should fill current row if panel height more than row height', () => { + model.rows = [createRow({ height: 6 }, [[4], [2, 3], [4, 8], [2, 3], [2, 3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 8, h: 6 }, + { x: 8, y: 0, w: 4, h: 3 }, + { x: 12, y: 0, w: 8, h: 8 }, + { x: 20, y: 0, w: 4, h: 3 }, + { x: 20, y: 3, w: 4, h: 3 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should wrap panels to multiple rows', () => { + model.rows = [createRow({ height: 6 }, [[6], [6], [12], [6], [3], [3]])]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 12, h: 6 }, + { x: 12, y: 0, w: 12, h: 6 }, + { x: 0, y: 6, w: 24, h: 6 }, + { x: 0, y: 12, w: 12, h: 6 }, + { x: 12, y: 12, w: 6, h: 6 }, + { x: 18, y: 12, w: 6, h: 6 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + }); + + it('should add repeated row if repeat set', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row', height: 8, repeat: 'server' }, [[6]]), + createRow({ height: 8 }, [[12]]), + ]; + const dashboard = new DashboardModel(model); + const panelGridPos = getGridPositions(dashboard); + const expectedGrid = [ + { x: 0, y: 0, w: 24, h: 8 }, + { x: 0, y: 1, w: 12, h: 8 }, + { x: 0, y: 9, w: 24, h: 8 }, + { x: 0, y: 10, w: 24, h: 8 }, + ]; + + expect(panelGridPos).toEqual(expectedGrid); + expect(dashboard.panels[0].repeat).toBe('server'); + expect(dashboard.panels[1].repeat).toBeUndefined(); + expect(dashboard.panels[2].repeat).toBeUndefined(); + expect(dashboard.panels[3].repeat).toBeUndefined(); + }); + + it('should ignore repeated row', () => { + model.rows = [ + createRow({ showTitle: true, title: 'Row1', height: 8, repeat: 'server' }, [[6]]), + createRow( + { + showTitle: true, + title: 'Row2', + height: 8, + repeatIteration: 12313, + repeatRowId: 1, + }, + [[6]] + ), + ]; + + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].repeat).toBe('server'); + expect(dashboard.panels.length).toBe(2); + }); + + it('should assign id', () => { + model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])]; + model.rows[0].panels[0] = {}; + + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].id).toBe(1); + }); + }); + + describe('when migrating from minSpan to maxPerRow', () => { + it('maxPerRow should be correct', () => { + const model = { + panels: [{ minSpan: 8 }], + }; + const dashboard = new DashboardModel(model); + expect(dashboard.panels[0].maxPerRow).toBe(3); + }); + }); + + describe('when migrating panel links', () => { + let model: any; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + links: [ + { + url: 'http://mylink.com', + keepTime: true, + title: 'test', + }, + { + url: 'http://mylink.com?existingParam', + params: 'customParam', + title: 'test', + }, + { + url: 'http://mylink.com?existingParam', + includeVars: true, + title: 'test', + }, + { + dashboard: 'my other dashboard', + title: 'test', + }, + { + dashUri: '', + title: 'test', + }, + { + type: 'dashboard', + keepTime: true, + }, + ], + }, + ], + }); + }); + + it('should add keepTime as variable', () => { + expect(model.panels[0].links[0].url).toBe(`http://mylink.com?$${DataLinkBuiltInVars.keepTime}`); + }); + + it('should add params to url', () => { + expect(model.panels[0].links[1].url).toBe('http://mylink.com?existingParam&customParam'); + }); + + it('should add includeVars to url', () => { + expect(model.panels[0].links[2].url).toBe(`http://mylink.com?existingParam&$${DataLinkBuiltInVars.includeVars}`); + }); + + it('should slugify dashboard name', () => { + expect(model.panels[0].links[3].url).toBe(`dashboard/db/my-other-dashboard`); + }); + }); + + describe('when migrating variables', () => { + let model: any; + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + //graph panel + options: { + dataLinks: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + }, + }, + { + // panel with field options + options: { + fieldOptions: { + defaults: { + links: [ + { + url: 'http://mylink.com?series=${__series_name}', + }, + { + url: 'http://mylink.com?series=${__value_time}', + }, + ], + title: '$__cell_0 * $__field_name * $__series_name', + }, + }, + }, + }, + ], + }); + }); + + describe('data links', () => { + it('should replace __series_name variable with __series.name', () => { + expect(model.panels[0].options.dataLinks[0].url).toBe('http://mylink.com?series=${__series.name}'); + expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe( + 'http://mylink.com?series=${__series.name}' + ); + }); + + it('should replace __value_time variable with __value.time', () => { + expect(model.panels[0].options.dataLinks[1].url).toBe('http://mylink.com?series=${__value.time}'); + expect(model.panels[1].options.fieldOptions.defaults.links[1].url).toBe( + 'http://mylink.com?series=${__value.time}' + ); + }); + }); + + describe('field display', () => { + it('should replace __series_name and __field_name variables with new syntax', () => { + expect(model.panels[1].options.fieldOptions.defaults.title).toBe( + '$__cell_0 * ${__field.name} * ${__series.name}' + ); + }); + }); + }); + + describe('when migrating labels from DataFrame to Field', () => { + let model: any; + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + //graph panel + options: { + dataLinks: [ + { + url: 'http://mylink.com?series=${__series.labels}&${__series.labels.a}', + }, + ], + }, + }, + { + // panel with field options + options: { + fieldOptions: { + defaults: { + links: [ + { + url: 'http://mylink.com?series=${__series.labels}&${__series.labels.x}', + }, + ], + }, + }, + }, + }, + ], + }); + }); + + describe('data links', () => { + it('should replace __series.label variable with __field.label', () => { + expect(model.panels[0].options.dataLinks[0].url).toBe( + 'http://mylink.com?series=${__field.labels}&${__field.labels.a}' + ); + expect(model.panels[1].options.fieldOptions.defaults.links[0].url).toBe( + 'http://mylink.com?series=${__field.labels}&${__field.labels.x}' + ); + }); + }); + }); + + describe('when migrating variables with multi support', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + multi: false, + current: { + value: ['value'], + text: ['text'], + }, + }, + { + multi: true, + current: { + value: ['value'], + text: ['text'], + }, + }, + ], + }, + }); + }); + + it('should have two variables after migration', () => { + expect(model.templating.list.length).toBe(2); + }); + + it('should be migrated if being out of sync', () => { + expect(model.templating.list[0].multi).toBe(false); + expect(model.templating.list[0].current).toEqual({ + text: 'text', + value: 'value', + }); + }); + + it('should not be migrated if being in sync', () => { + expect(model.templating.list[1].multi).toBe(true); + expect(model.templating.list[1].current).toEqual({ + text: ['text'], + value: ['value'], + }); + }); + }); + + describe('when migrating variables with old tags format', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + tags: ['Africa', 'America', 'Asia', 'Europe'], + }, + { + type: 'query', + current: { + tags: [ + { + selected: true, + text: 'America', + values: ['server-us-east', 'server-us-central', 'server-us-west'], + valuesText: 'server-us-east + server-us-central + server-us-west', + }, + { + selected: true, + text: 'Europe', + values: ['server-eu-east', 'server-eu-west'], + valuesText: 'server-eu-east + server-eu-west', + }, + ], + text: 'server-us-east + server-us-central + server-us-west + server-eu-east + server-eu-west', + value: ['server-us-east', 'server-us-central', 'server-us-west', 'server-eu-east', 'server-eu-west'], + }, + tags: ['Africa', 'America', 'Asia', 'Europe'], + }, + { + type: 'query', + tags: [ + { text: 'Africa', selected: false }, + { text: 'America', selected: true }, + { text: 'Asia', selected: false }, + { text: 'Europe', selected: false }, + ], + }, + ], + }, + }); + }); + + it('should have three variables after migration', () => { + expect(model.templating.list.length).toBe(3); + }); + + it('should be migrated with defaults if being out of sync', () => { + expect(model.templating.list[0].tags).toEqual([ + { text: 'Africa', selected: false }, + { text: 'America', selected: false }, + { text: 'Asia', selected: false }, + { text: 'Europe', selected: false }, + ]); + }); + + it('should be migrated with current values if being out of sync', () => { + expect(model.templating.list[1].tags).toEqual([ + { text: 'Africa', selected: false }, + { + selected: true, + text: 'America', + values: ['server-us-east', 'server-us-central', 'server-us-west'], + valuesText: 'server-us-east + server-us-central + server-us-west', + }, + { text: 'Asia', selected: false }, + { + selected: true, + text: 'Europe', + values: ['server-eu-east', 'server-eu-west'], + valuesText: 'server-eu-east + server-eu-west', + }, + ]); + }); + + it('should not be migrated if being in sync', () => { + expect(model.templating.list[2].tags).toEqual([ + { text: 'Africa', selected: false }, + { text: 'America', selected: true }, + { text: 'Asia', selected: false }, + { text: 'Europe', selected: false }, + ]); + }); + }); + + describe('when migrating to new Text Panel', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + id: 2, + type: 'text', + title: 'Angular Text Panel', + content: + '# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n', + mode: 'markdown', + }, + { + id: 3, + type: 'text2', + title: 'React Text Panel from scratch', + options: { + mode: 'markdown', + content: + '# React Text Panel from scratch\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text', + }, + }, + { + id: 4, + type: 'text2', + title: 'React Text Panel from Angular Panel', + options: { + mode: 'markdown', + content: + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text', + angular: { + content: + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n', + mode: 'markdown', + options: {}, + }, + }, + }, + ], + }); + }); + + it('should have 3 panels after migration', () => { + expect(model.panels.length).toBe(3); + }); + + it('should not migrate panel with old Text Panel id', () => { + const oldAngularPanel: any = model.panels[0]; + expect(oldAngularPanel.id).toEqual(2); + expect(oldAngularPanel.type).toEqual('text'); + expect(oldAngularPanel.title).toEqual('Angular Text Panel'); + expect(oldAngularPanel.content).toEqual( + '# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n' + ); + expect(oldAngularPanel.mode).toEqual('markdown'); + }); + + it('should migrate panels with new Text Panel id', () => { + const reactPanel: any = model.panels[1]; + expect(reactPanel.id).toEqual(3); + expect(reactPanel.type).toEqual('text'); + expect(reactPanel.title).toEqual('React Text Panel from scratch'); + expect(reactPanel.options.content).toEqual( + '# React Text Panel from scratch\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text' + ); + expect(reactPanel.options.mode).toEqual('markdown'); + }); + + it('should clean up old angular options for panels with new Text Panel id', () => { + const reactPanel: any = model.panels[2]; + expect(reactPanel.id).toEqual(4); + expect(reactPanel.type).toEqual('text'); + expect(reactPanel.title).toEqual('React Text Panel from Angular Panel'); + expect(reactPanel.options.content).toEqual( + '# React Text Panel from Angular Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text' + ); + expect(reactPanel.options.mode).toEqual('markdown'); + expect(reactPanel.options.angular).toBeUndefined(); + }); + }); + + describe('when migrating constant variables so they are always hidden', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [ + { + type: 'query', + hide: VariableHide.dontHide, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideLabel, + datasource: null, + allFormat: '', + }, + { + type: 'query', + hide: VariableHide.hideVariable, + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + { + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'A', value: 'B' }, + options: [{ selected: true, text: 'A', value: 'B' }], + datasource: null, + allFormat: '', + }, + ], + }, + }); + }); + + it('should have six variables after migration', () => { + expect(model.templating.list.length).toBe(6); + }); + + it('should not touch other variable types', () => { + expect(model.templating.list[0].hide).toEqual(VariableHide.dontHide); + expect(model.templating.list[1].hide).toEqual(VariableHide.hideLabel); + expect(model.templating.list[2].hide).toEqual(VariableHide.hideVariable); + }); + + it('should migrate visible constant variables to textbox variables', () => { + expect(model.templating.list[3]).toEqual({ + type: 'textbox', + hide: VariableHide.dontHide, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + expect(model.templating.list[4]).toEqual({ + type: 'textbox', + hide: VariableHide.hideLabel, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + + it('should change current and options for hidden constant variables', () => { + expect(model.templating.list[5]).toEqual({ + type: 'constant', + hide: VariableHide.hideVariable, + query: 'default value', + current: { selected: true, text: 'default value', value: 'default value' }, + options: [{ selected: true, text: 'default value', value: 'default value' }], + datasource: null, + allFormat: '', + }); + }); + }); +}); + +function createRow(options: any, panelDescriptions: any[]) { + const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN; + const { collapse, showTitle, title, repeat, repeatIteration } = options; + let { height } = options; + height = height * PANEL_HEIGHT_STEP; + const panels: any[] = []; + _.each(panelDescriptions, (panelDesc) => { + const panel = { span: panelDesc[0] }; + if (panelDesc.length > 1) { + //@ts-ignore + panel['height'] = panelDesc[1] * PANEL_HEIGHT_STEP; + } + panels.push(panel); + }); + const row = { + collapse, + height, + showTitle, + title, + panels, + repeat, + repeatIteration, + }; + return row; +} + +function getGridPositions(dashboard: DashboardModel) { + return _.map(dashboard.panels, (panel: PanelModel) => { + return panel.gridPos; + }); +} diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts new file mode 100644 index 0000000..e76be48 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -0,0 +1,846 @@ +// Libraries +import _, { defaults } from 'lodash'; +// Utils +import getFactors from 'app/core/utils/factors'; +import kbn from 'app/core/utils/kbn'; +// Types +import { PanelModel } from './PanelModel'; +import { DashboardModel } from './DashboardModel'; +import { DataLink, DataLinkBuiltInVars, urlUtil } from '@grafana/data'; +// Constants +import { + DEFAULT_PANEL_SPAN, + DEFAULT_ROW_HEIGHT, + GRID_CELL_HEIGHT, + GRID_CELL_VMARGIN, + GRID_COLUMN_COUNT, + MIN_PANEL_HEIGHT, +} from 'app/core/constants'; +import { isConstant, isMulti, isQuery } from 'app/features/variables/guard'; +import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions'; +import { VariableHide, VariableTag } from '../../variables/types'; + +export class DashboardMigrator { + dashboard: DashboardModel; + + constructor(dashboardModel: DashboardModel) { + this.dashboard = dashboardModel; + } + + updateSchema(old: any) { + let i, j, k, n; + const oldVersion = this.dashboard.schemaVersion; + const panelUpgrades = []; + this.dashboard.schemaVersion = 27; + + if (oldVersion === this.dashboard.schemaVersion) { + return; + } + + // version 2 schema changes + if (oldVersion < 2) { + if (old.services) { + if (old.services.filter) { + this.dashboard.time = old.services.filter.time; + this.dashboard.templating.list = old.services.filter.list || []; + } + } + + panelUpgrades.push((panel: any) => { + // rename panel type + if (panel.type === 'graphite') { + panel.type = 'graph'; + } + if (panel.type !== 'graph') { + return; + } + + if (_.isBoolean(panel.legend)) { + panel.legend = { show: panel.legend }; + } + + if (panel.grid) { + if (panel.grid.min) { + panel.grid.leftMin = panel.grid.min; + delete panel.grid.min; + } + + if (panel.grid.max) { + panel.grid.leftMax = panel.grid.max; + delete panel.grid.max; + } + } + + if (panel.y_format) { + if (!panel.y_formats) { + panel.y_formats = []; + } + panel.y_formats[0] = panel.y_format; + delete panel.y_format; + } + + if (panel.y2_format) { + if (!panel.y_formats) { + panel.y_formats = []; + } + panel.y_formats[1] = panel.y2_format; + delete panel.y2_format; + } + }); + } + + // schema version 3 changes + if (oldVersion < 3) { + // ensure panel ids + let maxId = this.dashboard.getNextPanelId(); + panelUpgrades.push((panel: any) => { + if (!panel.id) { + panel.id = maxId; + maxId += 1; + } + }); + } + + // schema version 4 changes + if (oldVersion < 4) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + _.each(panel.aliasYAxis, (value, key) => { + panel.seriesOverrides = [{ alias: key, yaxis: value }]; + }); + delete panel.aliasYAxis; + }); + } + + if (oldVersion < 6) { + // move pulldowns to new schema + const annotations: any = _.find(old.pulldowns, { type: 'annotations' }); + + if (annotations) { + this.dashboard.annotations = { + list: annotations.annotations || [], + }; + } + + // update template variables + for (i = 0; i < this.dashboard.templating.list.length; i++) { + const variable = this.dashboard.templating.list[i]; + if (variable.datasource === void 0) { + variable.datasource = null; + } + if (variable.type === 'filter') { + variable.type = 'query'; + } + if (variable.type === void 0) { + variable.type = 'query'; + } + if (variable.allFormat === void 0) { + variable.allFormat = 'glob'; + } + } + } + + if (oldVersion < 7) { + if (old.nav && old.nav.length) { + this.dashboard.timepicker = old.nav[0]; + } + + // ensure query refIds + panelUpgrades.push((panel: any) => { + _.each(panel.targets, (target) => { + if (!target.refId) { + target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter(); + } + }); + }); + } + + if (oldVersion < 8) { + panelUpgrades.push((panel: any) => { + _.each(panel.targets, (target) => { + // update old influxdb query schema + if (target.fields && target.tags && target.groupBy) { + if (target.rawQuery) { + delete target.fields; + delete target.fill; + } else { + target.select = _.map(target.fields, (field) => { + const parts = []; + parts.push({ type: 'field', params: [field.name] }); + parts.push({ type: field.func, params: [] }); + if (field.mathExpr) { + parts.push({ type: 'math', params: [field.mathExpr] }); + } + if (field.asExpr) { + parts.push({ type: 'alias', params: [field.asExpr] }); + } + return parts; + }); + delete target.fields; + _.each(target.groupBy, (part) => { + if (part.type === 'time' && part.interval) { + part.params = [part.interval]; + delete part.interval; + } + if (part.type === 'tag' && part.key) { + part.params = [part.key]; + delete part.key; + } + }); + + if (target.fill) { + target.groupBy.push({ type: 'fill', params: [target.fill] }); + delete target.fill; + } + } + } + }); + }); + } + + // schema version 9 changes + if (oldVersion < 9) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'singlestat' && panel.thresholds !== '') { + return; + } + + if (panel.thresholds) { + const k = panel.thresholds.split(','); + + if (k.length >= 3) { + k.shift(); + panel.thresholds = k.join(','); + } + } + }); + } + + // schema version 10 changes + if (oldVersion < 10) { + // move aliasYAxis changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'table') { + return; + } + + _.each(panel.styles, (style) => { + if (style.thresholds && style.thresholds.length >= 3) { + const k = style.thresholds; + k.shift(); + style.thresholds = k; + } + }); + }); + } + + if (oldVersion < 12) { + // update template variables + _.each(this.dashboard.getVariables(), (templateVariable: any) => { + if (templateVariable.refresh) { + templateVariable.refresh = 1; + } + if (!templateVariable.refresh) { + templateVariable.refresh = 0; + } + if (templateVariable.hideVariable) { + templateVariable.hide = 2; + } else if (templateVariable.hideLabel) { + templateVariable.hide = 1; + } + }); + } + + if (oldVersion < 12) { + // update graph yaxes changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + if (!panel.yaxes) { + panel.yaxes = [ + { + show: panel['y-axis'], + min: panel.grid.leftMin, + max: panel.grid.leftMax, + logBase: panel.grid.leftLogBase, + format: panel.y_formats[0], + label: panel.leftYAxisLabel, + }, + { + show: panel['y-axis'], + min: panel.grid.rightMin, + max: panel.grid.rightMax, + logBase: panel.grid.rightLogBase, + format: panel.y_formats[1], + label: panel.rightYAxisLabel, + }, + ]; + + panel.xaxis = { + show: panel['x-axis'], + }; + + delete panel.grid.leftMin; + delete panel.grid.leftMax; + delete panel.grid.leftLogBase; + delete panel.grid.rightMin; + delete panel.grid.rightMax; + delete panel.grid.rightLogBase; + delete panel.y_formats; + delete panel.leftYAxisLabel; + delete panel.rightYAxisLabel; + delete panel['y-axis']; + delete panel['x-axis']; + } + }); + } + + if (oldVersion < 13) { + // update graph yaxes changes + panelUpgrades.push((panel: any) => { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + if (!panel.thresholds) { + panel.thresholds = []; + } + const t1: any = {}, + t2: any = {}; + + if (panel.grid.threshold1 !== null) { + t1.value = panel.grid.threshold1; + if (panel.grid.thresholdLine) { + t1.line = true; + t1.lineColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } else { + t1.fill = true; + t1.fillColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } + } + + if (panel.grid.threshold2 !== null) { + t2.value = panel.grid.threshold2; + if (panel.grid.thresholdLine) { + t2.line = true; + t2.lineColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } else { + t2.fill = true; + t2.fillColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } + } + + if (_.isNumber(t1.value)) { + if (_.isNumber(t2.value)) { + if (t1.value > t2.value) { + t1.op = t2.op = 'lt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } else { + t1.op = t2.op = 'gt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } + } else { + t1.op = 'gt'; + panel.thresholds.push(t1); + } + } + + delete panel.grid.threshold1; + delete panel.grid.threshold1Color; + delete panel.grid.threshold2; + delete panel.grid.threshold2Color; + delete panel.grid.thresholdLine; + }); + } + + if (oldVersion < 14) { + this.dashboard.graphTooltip = old.sharedCrosshair ? 1 : 0; + } + + if (oldVersion < 16) { + this.upgradeToGridLayout(old); + } + + if (oldVersion < 17) { + panelUpgrades.push((panel: any) => { + if (panel.minSpan) { + const max = GRID_COLUMN_COUNT / panel.minSpan; + const factors = getFactors(GRID_COLUMN_COUNT); + // find the best match compared to factors + // (ie. [1,2,3,4,6,12,24] for 24 columns) + panel.maxPerRow = + factors[ + _.findIndex(factors, (o) => { + return o > max; + }) - 1 + ]; + } + delete panel.minSpan; + }); + } + + if (oldVersion < 18) { + // migrate change to gauge options + panelUpgrades.push((panel: any) => { + if (panel['options-gauge']) { + panel.options = panel['options-gauge']; + panel.options.valueOptions = { + unit: panel.options.unit, + stat: panel.options.stat, + decimals: panel.options.decimals, + prefix: panel.options.prefix, + suffix: panel.options.suffix, + }; + + // correct order + if (panel.options.thresholds) { + panel.options.thresholds.reverse(); + } + + // this options prop was due to a bug + delete panel.options.options; + delete panel.options.unit; + delete panel.options.stat; + delete panel.options.decimals; + delete panel.options.prefix; + delete panel.options.suffix; + delete panel['options-gauge']; + } + }); + } + + if (oldVersion < 19) { + // migrate change to gauge options + panelUpgrades.push((panel: any) => { + if (panel.links && _.isArray(panel.links)) { + panel.links = panel.links.map(upgradePanelLink); + } + }); + } + + if (oldVersion < 20) { + const updateLinks = (link: DataLink) => { + return { + ...link, + url: updateVariablesSyntax(link.url), + }; + }; + panelUpgrades.push((panel: any) => { + // For graph panel + if (panel.options && panel.options.dataLinks && _.isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + + // For panel with fieldOptions + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (panel.options.fieldOptions.defaults.links && _.isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + if (panel.options.fieldOptions.defaults.title) { + panel.options.fieldOptions.defaults.title = updateVariablesSyntax( + panel.options.fieldOptions.defaults.title + ); + } + } + }); + } + + if (oldVersion < 21) { + const updateLinks = (link: DataLink) => { + return { + ...link, + url: link.url.replace(/__series.labels/g, '__field.labels'), + }; + }; + panelUpgrades.push((panel: any) => { + // For graph panel + if (panel.options && panel.options.dataLinks && _.isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + + // For panel with fieldOptions + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (panel.options.fieldOptions.defaults.links && _.isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + } + }); + } + + if (oldVersion < 22) { + panelUpgrades.push((panel: any) => { + if (panel.type !== 'table') { + return; + } + + _.each(panel.styles, (style) => { + style.align = 'auto'; + }); + }); + } + + if (oldVersion < 23) { + for (const variable of this.dashboard.templating.list) { + if (!isMulti(variable)) { + continue; + } + const { multi, current } = variable; + variable.current = alignCurrentWithMulti(current, multi); + } + } + + if (oldVersion < 24) { + // 7.0 + // - migrate existing tables to 'table-old' + panelUpgrades.push((panel: any) => { + const wasAngularTable = panel.type === 'table'; + if (wasAngularTable && !panel.styles) { + return; // styles are missing so assumes default settings + } + const wasReactTable = panel.table === 'table2'; + if (!wasAngularTable || wasReactTable) { + return; + } + panel.type = wasAngularTable ? 'table-old' : 'table'; + }); + } + + if (oldVersion < 25) { + for (const variable of this.dashboard.templating.list) { + if (!isQuery(variable)) { + continue; + } + + const { tags, current } = variable; + if (!Array.isArray(tags)) { + variable.tags = []; + continue; + } + + const currentTags = current?.tags ?? []; + const currents = currentTags.reduce((all, tag) => { + if (tag && tag.hasOwnProperty('text') && typeof tag['text'] === 'string') { + all[tag.text] = tag; + } + return all; + }, {} as Record); + + const newTags: VariableTag[] = []; + + for (const tag of tags) { + if (typeof tag === 'object') { + // new format let's assume it's correct + newTags.push(tag); + continue; + } + + if (typeof tag !== 'string') { + // something that we do not support + continue; + } + + newTags.push(defaults(currents[tag], { text: tag, selected: false })); + } + variable.tags = newTags; + } + } + + if (oldVersion < 26) { + panelUpgrades.push((panel: any) => { + const wasReactText = panel.type === 'text2'; + if (!wasReactText) { + return; + } + + panel.type = 'text'; + delete panel.options.angular; + }); + } + + if (oldVersion < 27) { + for (const variable of this.dashboard.templating.list) { + if (!isConstant(variable)) { + continue; + } + + if (variable.hide === VariableHide.dontHide || variable.hide === VariableHide.hideLabel) { + variable.type = 'textbox'; + } + + variable.current = { selected: true, text: variable.query ?? '', value: variable.query ?? '' }; + variable.options = [variable.current]; + } + } + + if (panelUpgrades.length === 0) { + return; + } + + for (j = 0; j < this.dashboard.panels.length; j++) { + for (k = 0; k < panelUpgrades.length; k++) { + panelUpgrades[k].call(this, this.dashboard.panels[j]); + if (this.dashboard.panels[j].panels) { + for (n = 0; n < this.dashboard.panels[j].panels.length; n++) { + panelUpgrades[k].call(this, this.dashboard.panels[j].panels[n]); + } + } + } + } + } + + upgradeToGridLayout(old: any) { + let yPos = 0; + const widthFactor = GRID_COLUMN_COUNT / 12; + + const maxPanelId = _.max( + _.flattenDeep( + _.map(old.rows, (row) => { + return _.map(row.panels, 'id'); + }) + ) + ); + let nextRowId = maxPanelId + 1; + + if (!old.rows) { + return; + } + + // Add special "row" panels if even one row is collapsed, repeated or has visible title + const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle || row.repeat); + + for (const row of old.rows) { + if (row.repeatIteration) { + continue; + } + + const height: any = row.height || DEFAULT_ROW_HEIGHT; + const rowGridHeight = getGridHeight(height); + + const rowPanel: any = {}; + let rowPanelModel: PanelModel | undefined; + + if (showRows) { + // add special row panel + rowPanel.id = nextRowId; + rowPanel.type = 'row'; + rowPanel.title = row.title; + rowPanel.collapsed = row.collapse; + rowPanel.repeat = row.repeat; + rowPanel.panels = []; + rowPanel.gridPos = { + x: 0, + y: yPos, + w: GRID_COLUMN_COUNT, + h: rowGridHeight, + }; + rowPanelModel = new PanelModel(rowPanel); + nextRowId++; + yPos++; + } + + const rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos); + + for (const panel of row.panels) { + panel.span = panel.span || DEFAULT_PANEL_SPAN; + if (panel.minSpan) { + panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan); + } + const panelWidth = Math.floor(panel.span) * widthFactor; + const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight; + + const panelPos = rowArea.getPanelPosition(panelHeight, panelWidth); + yPos = rowArea.yPos; + panel.gridPos = { + x: panelPos.x, + y: yPos + panelPos.y, + w: panelWidth, + h: panelHeight, + }; + rowArea.addPanel(panel.gridPos); + + delete panel.span; + + if (rowPanelModel && rowPanel.collapsed) { + rowPanelModel.panels.push(panel); + } else { + this.dashboard.panels.push(new PanelModel(panel)); + } + } + + if (rowPanelModel) { + this.dashboard.panels.push(rowPanelModel); + } + + if (!(rowPanelModel && rowPanel.collapsed)) { + yPos += rowGridHeight; + } + } + } +} + +function getGridHeight(height: number | string) { + if (_.isString(height)) { + height = parseInt(height.replace('px', ''), 10); + } + + if (height < MIN_PANEL_HEIGHT) { + height = MIN_PANEL_HEIGHT; + } + + const gridHeight = Math.ceil(height / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); + return gridHeight; +} + +/** + * RowArea represents dashboard row filled by panels + * area is an array of numbers represented filled column's cells like + * ----------------------- + * |******** **** + * |******** **** + * |******** + * ----------------------- + * 33333333 2222 00000 ... + */ +class RowArea { + area: number[]; + yPos: number; + height: number; + + constructor(height: number, width = GRID_COLUMN_COUNT, rowYPos = 0) { + this.area = new Array(width).fill(0); + this.yPos = rowYPos; + this.height = height; + } + + reset() { + this.area.fill(0); + } + + /** + * Update area after adding the panel. + */ + addPanel(gridPos: any) { + for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) { + if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) { + this.area[i] = gridPos.y + gridPos.h - this.yPos; + } + } + return this.area; + } + + /** + * Calculate position for the new panel in the row. + */ + getPanelPosition(panelHeight: number, panelWidth: number, callOnce = false): any { + let startPlace, endPlace; + let place; + for (let i = this.area.length - 1; i >= 0; i--) { + if (this.height - this.area[i] > 0) { + if (endPlace === undefined) { + endPlace = i; + } else { + if (i < this.area.length - 1 && this.area[i] <= this.area[i + 1]) { + startPlace = i; + } else { + break; + } + } + } else { + break; + } + } + + if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) { + const yPos = _.max(this.area.slice(startPlace)); + place = { + x: startPlace, + y: yPos, + }; + } else if (!callOnce) { + // wrap to next row + this.yPos += this.height; + this.reset(); + return this.getPanelPosition(panelHeight, panelWidth, true); + } else { + return null; + } + + return place; + } +} + +function upgradePanelLink(link: any): DataLink { + let url = link.url; + + if (!url && link.dashboard) { + url = `dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`; + } + + if (!url && link.dashUri) { + url = `dashboard/${link.dashUri}`; + } + + // some models are incomplete and have no dashboard or dashUri + if (!url) { + url = '/'; + } + + if (link.keepTime) { + url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.keepTime}`); + } + + if (link.includeVars) { + url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.includeVars}`); + } + + if (link.params) { + url = urlUtil.appendQueryToUrl(url, link.params); + } + + return { + url: url, + title: link.title, + targetBlank: link.targetBlank, + }; +} + +function updateVariablesSyntax(text: string) { + const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g; + + return text.replace(legacyVariableNamesRegex, (match, seriesName, seriesName1, valueTime, fieldName, fieldName1) => { + if (seriesName) { + return '__series.name'; + } + if (seriesName1) { + return '${__series.name}'; + } + if (valueTime) { + return '__value.time'; + } + if (fieldName) { + return '__field.name'; + } + if (fieldName1) { + return '${__field.name}'; + } + return match; + }); +} diff --git a/public/app/features/dashboard/state/DashboardModel.repeat.test.ts b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts new file mode 100644 index 0000000..859d53b --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts @@ -0,0 +1,731 @@ +import _ from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { expect } from 'test/lib/common'; +import { getDashboardModel } from '../../../../test/helpers/getDashboardModel'; +import { PanelModel } from './PanelModel'; + +jest.mock('app/core/services/context_srv', () => ({})); + +describe('given dashboard with panel repeat', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } }, + ], + templating: { + list: [ + { + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat panels when row is expanding', () => { + expect(dashboard.panels.length).toBe(4); + + // toggle row + dashboard.toggleRow(dashboard.panels[0]); + expect(dashboard.panels.length).toBe(1); + + // change variable + dashboard.templating.list[0].options[2].selected = false; + dashboard.templating.list[0].current = { + text: 'se1, se2', + value: ['se1', 'se2'], + }; + + // toggle row back + dashboard.toggleRow(dashboard.panels[0]); + expect(dashboard.panels.length).toBe(3); + }); +}); + +describe('given dashboard with panel repeat in horizontal direction', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { + id: 2, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + templating: { + list: [ + { + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat panel 3 times', () => { + expect(dashboard.panels.length).toBe(3); + }); + + it('should mark panel repeated', () => { + expect(dashboard.panels[0].repeat).toBe('apps'); + expect(dashboard.panels[1].repeatPanelId).toBe(2); + }); + + it('should set scopedVars on panels', () => { + expect(dashboard.panels[0].scopedVars.apps.value).toBe('se1'); + expect(dashboard.panels[1].scopedVars.apps.value).toBe('se2'); + expect(dashboard.panels[2].scopedVars.apps.value).toBe('se3'); + }); + + it('should place on first row and adjust width so all fit', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ + x: 0, + y: 0, + h: 2, + w: 8, + }); + expect(dashboard.panels[1].gridPos).toMatchObject({ + x: 8, + y: 0, + h: 2, + w: 8, + }); + expect(dashboard.panels[2].gridPos).toMatchObject({ + x: 16, + y: 0, + h: 2, + w: 8, + }); + }); + + describe('After a second iteration', () => { + beforeEach(() => { + dashboard.panels[0].fill = 10; + dashboard.processRepeats(); + }); + + it('reused panel should copy properties from source', () => { + expect(dashboard.panels[1].fill).toBe(10); + }); + + it('should have same panel count', () => { + expect(dashboard.panels.length).toBe(3); + }); + }); + + describe('After a second iteration with different variable', () => { + beforeEach(() => { + dashboard.templating.list.push({ + name: 'server', + current: { text: 'se1, se2, se3', value: ['se1'] }, + options: [{ text: 'se1', value: 'se1', selected: true }], + }); + dashboard.panels[0].repeat = 'server'; + dashboard.processRepeats(); + }); + + it('should remove scopedVars value for last variable', () => { + expect(dashboard.panels[0].scopedVars.apps).toBe(undefined); + }); + + it('should have new variable value in scopedVars', () => { + expect(dashboard.panels[0].scopedVars.server.value).toBe('se1'); + }); + }); + + describe('After a second iteration and selected values reduced', () => { + beforeEach(() => { + dashboard.templating.list[0].options[1].selected = false; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('After a second iteration and panel repeat is turned off', () => { + beforeEach(() => { + dashboard.panels[0].repeat = null; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', () => { + expect(dashboard.panels.length).toBe(1); + }); + + it('should remove scoped vars from reused panel', () => { + expect(dashboard.panels[0].scopedVars).toBe(undefined); + }); + }); +}); + +describe('given dashboard with panel repeat in vertical direction', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: { x: 5, y: 1, h: 2, w: 8 } }, + { id: 3, type: 'row', gridPos: { x: 0, y: 3, h: 1, w: 24 } }, + ], + templating: { + list: [ + { + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should place on items on top of each other and keep witdh', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 5, y: 3, h: 2, w: 8 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 5, y: 5, h: 2, w: 8 }); + + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, y: 7, h: 1, w: 24 }); // last row + }); +}); + +describe('given dashboard with row repeat and panel repeat in horizontal direction', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { id: 1, type: 'row', repeat: 'region', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 2, w: 6 } }, + ], + templating: { + list: [ + { + name: 'region', + current: { + text: 'reg1, reg2', + value: ['reg1', 'reg2'], + }, + options: [ + { text: 'reg1', value: 'reg1', selected: true }, + { text: 'reg2', value: 'reg2', selected: true }, + ], + }, + { + name: 'app', + current: { + text: 'se1, se2, se3, se4, se5, se6', + value: ['se1', 'se2', 'se3', 'se4', 'se5', 'se6'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: true }, + { text: 'se5', value: 'se5', selected: true }, + { text: 'se6', value: 'se6', selected: true }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(false); + }); + + it('should panels in self row', () => { + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual([ + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + ]); + }); + + it('should be placed in their places', () => { + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // 1st row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 6, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 12, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 18, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); // next row + expect(dashboard.panels[6].gridPos).toMatchObject({ x: 6, y: 3, h: 2, w: 6 }); + + expect(dashboard.panels[7].gridPos).toMatchObject({ x: 0, y: 5, h: 1, w: 24 }); + + expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); // 2nd row + expect(dashboard.panels[9].gridPos).toMatchObject({ x: 6, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[10].gridPos).toMatchObject({ x: 12, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); // next row + expect(dashboard.panels[12].gridPos).toMatchObject({ x: 0, y: 8, h: 2, w: 6 }); + expect(dashboard.panels[13].gridPos).toMatchObject({ x: 6, y: 8, h: 2, w: 6 }); + }); +}); + +describe('given dashboard with row repeat', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { + id: 1, + type: 'row', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + repeat: 'apps', + }, + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + { id: 4, type: 'row', gridPos: { x: 0, y: 2, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 3, h: 1, w: 12 } }, + ], + templating: { + list: [ + { + name: 'apps', + current: { + text: 'se1, se2', + value: ['se1', 'se2'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should not repeat only row', () => { + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph', 'row', 'graph']); + }); + + it('should set scopedVars for each panel', () => { + dashboardJSON.templating.list[0].options[2].selected = true; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + apps: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[4].scopedVars).toMatchObject({ + apps: { text: 'se2', value: 'se2' }, + }); + + const scopedVars = _.compact( + _.map(dashboard.panels, (panel) => { + return panel.scopedVars ? panel.scopedVars.apps.value : null; + }) + ); + + expect(scopedVars).toEqual(['se1', 'se1', 'se1', 'se2', 'se2', 'se2', 'se3', 'se3', 'se3']); + }); + + it('should repeat only configured row', () => { + expect(dashboard.panels[6].id).toBe(4); + expect(dashboard.panels[7].id).toBe(5); + }); + + it('should repeat only row if it is collapsed', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + collapsed: true, + repeat: 'apps', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + panels: [ + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + ], + }, + { id: 4, type: 'row', gridPos: { x: 0, y: 1, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 2, h: 1, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'row', 'row', 'graph']); + expect(dashboard.panels[0].panels).toHaveLength(2); + expect(dashboard.panels[1].panels).toHaveLength(2); + }); + + it('should properly repeat multiple rows', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + repeat: 'apps', + }, // repeat + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + { id: 4, type: 'row', gridPos: { x: 0, y: 2, h: 1, w: 24 } }, // don't touch + { id: 5, type: 'graph', gridPos: { x: 0, y: 3, h: 1, w: 12 } }, + { + id: 6, + type: 'row', + gridPos: { x: 0, y: 4, h: 1, w: 24 }, + repeat: 'hosts', + }, // repeat + { id: 7, type: 'graph', gridPos: { x: 0, y: 5, h: 1, w: 6 } }, + { id: 8, type: 'graph', gridPos: { x: 6, y: 5, h: 1, w: 6 } }, + ]; + dashboardJSON.templating.list.push({ + name: 'hosts', + current: { + text: 'backend01, backend02', + value: ['backend01', 'backend02'], + }, + options: [ + { text: 'backend01', value: 'backend01', selected: true }, + { text: 'backend02', value: 'backend02', selected: true }, + { text: 'backend03', value: 'backend03', selected: false }, + ], + }); + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual([ + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'row', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + ]); + + expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1'); + expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1'); + expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2'); + expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2'); + expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01'); + expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01'); + expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02'); + expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02'); + }); + + it('should assign unique ids for repeated panels', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + collapsed: true, + repeat: 'apps', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + panels: [ + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 1, w: 6 } }, + ], + }, + { id: 4, type: 'row', gridPos: { x: 0, y: 1, h: 1, w: 24 } }, + { id: 5, type: 'graph', gridPos: { x: 0, y: 2, h: 1, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelIds = _.flattenDeep( + _.map(dashboard.panels, (panel) => { + let ids = []; + if (panel.panels && panel.panels.length) { + ids = _.map(panel.panels, 'id'); + } + ids.push(panel.id); + return ids; + }) + ); + expect(panelIds.length).toEqual(_.uniq(panelIds).length); + }); + + it('should place new panels in proper order', () => { + dashboardJSON.panels = [ + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 }, repeat: 'apps' }, + { id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 3, w: 12 } }, + { id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 4, w: 12 } }, + { id: 4, type: 'graph', gridPos: { x: 0, y: 5, h: 2, w: 12 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'graph', 'row', 'graph', 'graph', 'graph']); + const panelYPositions = _.map(dashboard.panels, (p) => p.gridPos.y); + expect(panelYPositions).toEqual([0, 1, 1, 5, 7, 8, 8, 12]); + }); +}); + +describe('given dashboard with row and panel repeat', () => { + let dashboard: any, dashboardJSON: any; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { + id: 1, + type: 'row', + repeat: 'region', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + ], + templating: { + list: [ + { + name: 'region', + current: { + text: 'reg1, reg2', + value: ['reg1', 'reg2'], + }, + options: [ + { text: 'reg1', value: 'reg1', selected: true }, + { text: 'reg2', value: 'reg2', selected: true }, + { text: 'reg3', value: 'reg3', selected: false }, + ], + }, + { + name: 'app', + current: { + text: 'se1, se2', + value: ['se1', 'se2'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: false }, + ], + }, + ], + }, + }; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + }); + + it('should repeat row and panels for each row', () => { + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']); + }); + + it('should clean up old repeated panels', () => { + dashboardJSON.panels = [ + { + id: 1, + type: 'row', + repeat: 'region', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } }, + { id: 3, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 7, y: 1, h: 1, w: 6 } }, + { + id: 11, + type: 'row', + repeatPanelId: 1, + repeatIteration: 101, + gridPos: { x: 0, y: 2, h: 1, w: 24 }, + }, + { id: 12, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 0, y: 3, h: 1, w: 6 } }, + ]; + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + const panelTypes = _.map(dashboard.panels, 'type'); + expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']); + }); + + it('should set scopedVars for each row', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[0].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + expect(dashboard.panels[3].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + }); + + it('should set panel-repeat variable for each panel', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + app: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[2].scopedVars).toMatchObject({ + app: { text: 'se2', value: 'se2' }, + }); + + expect(dashboard.panels[4].scopedVars).toMatchObject({ + app: { text: 'se1', value: 'se1' }, + }); + expect(dashboard.panels[5].scopedVars).toMatchObject({ + app: { text: 'se2', value: 'se2' }, + }); + }); + + it('should set row-repeat variable for each panel', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels[1].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + expect(dashboard.panels[2].scopedVars).toMatchObject({ + region: { text: 'reg1', value: 'reg1' }, + }); + + expect(dashboard.panels[4].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + expect(dashboard.panels[5].scopedVars).toMatchObject({ + region: { text: 'reg2', value: 'reg2' }, + }); + }); + + it('should repeat panels when row is expanding', () => { + dashboard = getDashboardModel(dashboardJSON); + dashboard.processRepeats(); + + expect(dashboard.panels.length).toBe(6); + + // toggle row + dashboard.toggleRow(dashboard.panels[0]); + dashboard.toggleRow(dashboard.panels[1]); + expect(dashboard.panels.length).toBe(2); + + // change variable + dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3']; + + // toggle row back + dashboard.toggleRow(dashboard.panels[1]); + expect(dashboard.panels.length).toBe(4); + }); +}); + +describe('given panel is in view mode', () => { + let dashboard: any; + + beforeEach(() => { + const dashboardJSON = { + panels: [ + { + id: 1, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + templating: { + list: [ + { + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: false }, + ], + }, + ], + }, + }; + + dashboard = getDashboardModel(dashboardJSON); + dashboard.initViewPanel( + new PanelModel({ + id: 2, + repeat: undefined, + repeatDirection: 'h', + panels: [ + { + id: 2, + repeat: 'apps', + repeatDirection: 'h', + gridPos: { x: 0, y: 0, h: 2, w: 24 }, + }, + ], + repeatPanelId: 2, + }) + ); + dashboard.processRepeats(); + }); + + it('should set correct repeated panel to be in view', () => { + expect(dashboard.panels[1].isViewing).toBeTruthy(); + }); +}); diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts new file mode 100644 index 0000000..17f240e --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -0,0 +1,708 @@ +import _ from 'lodash'; +import { DashboardModel } from '../state/DashboardModel'; +import { PanelModel } from '../state/PanelModel'; +import { getDashboardModel } from '../../../../test/helpers/getDashboardModel'; +import { variableAdapters } from '../../variables/adapters'; +import { createAdHocVariableAdapter } from '../../variables/adhoc/adapter'; +import { createQueryVariableAdapter } from '../../variables/query/adapter'; + +jest.mock('app/core/services/context_srv', () => ({})); +variableAdapters.setInit(() => [createQueryVariableAdapter(), createAdHocVariableAdapter()]); + +describe('DashboardModel', () => { + describe('when creating new dashboard model defaults only', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({}, {}); + }); + + it('should have title', () => { + expect(model.title).toBe('No Title'); + }); + + it('should have meta', () => { + expect(model.meta.canSave).toBe(true); + expect(model.meta.canShare).toBe(true); + }); + + it('should have default properties', () => { + expect(model.panels.length).toBe(0); + }); + }); + + describe('when getting next panel id', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + panels: [{ id: 5 }], + }); + }); + + it('should return max id + 1', () => { + expect(model.getNextPanelId()).toBe(6); + }); + }); + + describe('getSaveModelClone', () => { + it('should sort keys', () => { + const model = new DashboardModel({}); + const saveModel = model.getSaveModelClone(); + const keys = _.keys(saveModel); + + expect(keys[0]).toBe('annotations'); + expect(keys[1]).toBe('autoUpdate'); + }); + + it('should remove add panel panels', () => { + const model = new DashboardModel({}); + model.addPanel({ + type: 'add-panel', + }); + model.addPanel({ + type: 'graph', + }); + model.addPanel({ + type: 'add-panel', + }); + const saveModel = model.getSaveModelClone(); + const panels = saveModel.panels; + + expect(panels.length).toBe(1); + }); + + it('should save model in edit mode', () => { + const model = new DashboardModel({}); + model.addPanel({ type: 'graph' }); + + const panel = model.initEditPanel(model.panels[0]); + panel.title = 'updated'; + + const saveModel = model.getSaveModelClone(); + const savedPanel = saveModel.panels[0]; + + expect(savedPanel.title).toBe('updated'); + expect(savedPanel.id).toBe(model.panels[0].id); + }); + }); + + describe('row and panel manipulation', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({}); + }); + + it('adding panel should new up panel model', () => { + dashboard.addPanel({ type: 'test', title: 'test' }); + + expect(dashboard.panels[0] instanceof PanelModel).toBe(true); + }); + + it('duplicate panel should try to add to the right if there is space', () => { + const panel = { id: 10, gridPos: { x: 0, y: 0, w: 6, h: 2 } }; + + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); + + expect(dashboard.panels[1].gridPos).toMatchObject({ + x: 6, + y: 0, + h: 2, + w: 6, + }); + }); + + it('duplicate panel should remove repeat data', () => { + const panel = { + id: 10, + gridPos: { x: 0, y: 0, w: 6, h: 2 }, + repeat: 'asd', + scopedVars: { test: 'asd' }, + }; + + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); + + expect(dashboard.panels[1].repeat).toBe(undefined); + expect(dashboard.panels[1].scopedVars).toBe(undefined); + }); + }); + + describe('Given editable false dashboard', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ editable: false }); + }); + + it('Should set meta canEdit and canSave to false', () => { + expect(model.meta.canSave).toBe(false); + expect(model.meta.canEdit).toBe(false); + }); + + it('getSaveModelClone should remove meta', () => { + const clone = model.getSaveModelClone(); + expect(clone.meta).toBe(undefined); + }); + }); + + describe('when loading dashboard with old influxdb query schema', () => { + let model: DashboardModel; + let target: any; + + beforeEach(() => { + model = new DashboardModel({ + panels: [ + { + type: 'graph', + grid: {}, + yaxes: [{}, {}], + targets: [ + { + alias: '$tag_datacenter $tag_source $col', + column: 'value', + measurement: 'logins.count', + fields: [ + { + func: 'mean', + name: 'value', + mathExpr: '*2', + asExpr: 'value', + }, + { + name: 'one-minute', + func: 'mean', + mathExpr: '*3', + asExpr: 'one-minute', + }, + ], + tags: [], + fill: 'previous', + function: 'mean', + groupBy: [ + { + interval: 'auto', + type: 'time', + }, + { + key: 'source', + type: 'tag', + }, + { + type: 'tag', + key: 'datacenter', + }, + ], + }, + ], + }, + ], + }); + + target = model.panels[0].targets[0]; + }); + + it('should update query schema', () => { + expect(target.fields).toBe(undefined); + expect(target.select.length).toBe(2); + expect(target.select[0].length).toBe(4); + expect(target.select[0][0].type).toBe('field'); + expect(target.select[0][1].type).toBe('mean'); + expect(target.select[0][2].type).toBe('math'); + expect(target.select[0][3].type).toBe('alias'); + }); + }); + + describe('when creating dashboard model with missing list for annoations or templating', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + annotations: { + enable: true, + }, + templating: { + enable: true, + }, + }); + }); + + it('should add empty list', () => { + expect(model.annotations.list.length).toBe(1); + expect(model.templating.list.length).toBe(0); + }); + + it('should add builtin annotation query', () => { + expect(model.annotations.list[0].builtIn).toBe(1); + expect(model.templating.list.length).toBe(0); + }); + }); + + describe('Formatting epoch timestamp when timezone is set as utc', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ timezone: 'utc' }); + }); + + it('Should format timestamp with second resolution by default', () => { + expect(dashboard.formatDate(1234567890000)).toBe('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with second resolution even if second format is passed as parameter', () => { + expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss')).toBe('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with millisecond resolution if format is passed as parameter', () => { + expect(dashboard.formatDate(1234567890007, 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2009-02-13 23:31:30.007'); + }); + }); + + describe('updateSubmenuVisibility with empty lists', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({}); + model.updateSubmenuVisibility(); + }); + + it('should not enable submmenu', () => { + expect(model.meta.submenuEnabled).toBe(false); + }); + }); + + describe('updateSubmenuVisibility with annotation', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + annotations: { + list: [{}], + }, + }); + model.updateSubmenuVisibility(); + }); + + it('should enable submmenu', () => { + expect(model.meta.submenuEnabled).toBe(true); + }); + }); + + describe('updateSubmenuVisibility with template var', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [{}], + }, + }); + model.updateSubmenuVisibility(); + }); + + it('should enable submmenu', () => { + expect(model.meta.submenuEnabled).toBe(true); + }); + }); + + describe('updateSubmenuVisibility with hidden template var', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + templating: { + list: [{ hide: 2 }], + }, + }); + model.updateSubmenuVisibility(); + }); + + it('should not enable submmenu', () => { + expect(model.meta.submenuEnabled).toBe(false); + }); + }); + + describe('updateSubmenuVisibility with hidden annotation toggle', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + annotations: { + list: [{ hide: true }], + }, + }); + dashboard.updateSubmenuVisibility(); + }); + + it('should not enable submmenu', () => { + expect(dashboard.meta.submenuEnabled).toBe(false); + }); + }); + + describe('When collapsing row', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 } }, + { id: 2, type: 'row', gridPos: { x: 0, y: 2, w: 24, h: 2 } }, + { id: 3, type: 'graph', gridPos: { x: 0, y: 4, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 4, w: 12, h: 2 } }, + { id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 24, h: 2 } }, + ], + }); + dashboard.toggleRow(dashboard.panels[1]); + }); + + it('should remove panels and put them inside collapsed row', () => { + expect(dashboard.panels.length).toBe(3); + expect(dashboard.panels[1].panels.length).toBe(2); + }); + + describe('and when removing row and its panels', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], true); + }); + + it('should remove row and its panels', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('and when removing only the row', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], false); + }); + + it('should only remove row', () => { + expect(dashboard.panels.length).toBe(4); + }); + }); + }); + + describe('When expanding row', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 6 } }, + { + id: 2, + type: 'row', + gridPos: { x: 0, y: 6, w: 24, h: 1 }, + collapsed: true, + panels: [ + { id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 7, w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } }, + ], + }); + dashboard.toggleRow(dashboard.panels[1]); + }); + + it('should add panels back', () => { + expect(dashboard.panels.length).toBe(5); + }); + + it('should add them below row in array', () => { + expect(dashboard.panels[2].id).toBe(3); + expect(dashboard.panels[3].id).toBe(4); + }); + + it('should position them below row', () => { + expect(dashboard.panels[2].gridPos).toMatchObject({ + x: 0, + y: 7, + w: 12, + h: 2, + }); + }); + + it('should move panels below down', () => { + expect(dashboard.panels[4].gridPos).toMatchObject({ + x: 0, + y: 9, + w: 1, + h: 1, + }); + }); + + describe('and when removing row and its panels', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], true); + }); + + it('should remove row and its panels', () => { + expect(dashboard.panels.length).toBe(2); + }); + }); + + describe('and when removing only the row', () => { + beforeEach(() => { + dashboard.removeRow(dashboard.panels[1], false); + }); + + it('should only remove row', () => { + expect(dashboard.panels.length).toBe(4); + }); + }); + }); + + describe('Given model with time', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + time: { + from: 'now-6h', + to: 'now', + }, + }); + expect(model.hasTimeChanged()).toBeFalsy(); + model.time = { + from: 'now-3h', + to: 'now-1h', + }; + }); + + it('hasTimeChanged should be true', () => { + expect(model.hasTimeChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original time when saveTimerange=false', () => { + const options = { saveTimerange: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-6h'); + expect(saveModel.time.to).toBe('now'); + }); + + it('getSaveModelClone should return updated time when saveTimerange=true', () => { + const options = { saveTimerange: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-3h'); + expect(saveModel.time.to).toBe('now-1h'); + }); + + it('hasTimeChanged should be false when reset original time', () => { + model.resetOriginalTime(); + expect(model.hasTimeChanged()).toBeFalsy(); + }); + + it('getSaveModelClone should return original time when saveTimerange=false', () => { + const options = { saveTimerange: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-6h'); + expect(saveModel.time.to).toBe('now'); + }); + + it('getSaveModelClone should return updated time when saveTimerange=true', () => { + const options = { saveTimerange: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.time.from).toBe('now-3h'); + expect(saveModel.time.to).toBe('now-1h'); + }); + }); + + describe('Given model with template variable of type query', () => { + let model: DashboardModel; + + beforeEach(() => { + const json = { + templating: { + list: [ + { + name: 'Server', + type: 'query', + current: { + selected: true, + text: 'server_001', + value: 'server_001', + }, + }, + ], + }, + }; + model = getDashboardModel(json); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when adding a template variable', () => { + model.templating.list.push({ + name: 'Server2', + type: 'query', + current: { + selected: true, + text: 'server_002', + value: 'server_002', + }, + }); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when removing existing template variable', () => { + model.templating.list = []; + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be true when changing value of template variable', () => { + model.templating.list[0].current.text = 'server_002'; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original variable when saveVariables=false', () => { + model.templating.list[0].current.text = 'server_002'; + + const options = { saveVariables: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].current.text).toBe('server_001'); + }); + + it('getSaveModelClone should return updated variable when saveVariables=true', () => { + model.templating.list[0].current.text = 'server_002'; + + const options = { saveVariables: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].current.text).toBe('server_002'); + }); + }); + + describe('Given model with template variable of type adhoc', () => { + let model: DashboardModel; + + beforeEach(() => { + const json = { + templating: { + list: [ + { + name: 'Filter', + type: 'adhoc', + filters: [ + { + key: '@hostname', + operator: '=', + value: 'server 20', + }, + ], + }, + ], + }, + }; + model = getDashboardModel(json); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when adding a template variable', () => { + model.templating.list.push({ + name: 'Filter', + type: 'adhoc', + filters: [ + { + key: '@hostname', + operator: '=', + value: 'server 1', + }, + ], + }); + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be false when removing existing template variable', () => { + model.templating.list = []; + expect(model.hasVariableValuesChanged()).toBeFalsy(); + }); + + it('hasVariableValuesChanged should be true when changing value of filter', () => { + model.templating.list[0].filters[0].value = 'server 1'; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('hasVariableValuesChanged should be true when adding an additional condition', () => { + model.templating.list[0].filters[0].condition = 'AND'; + model.templating.list[0].filters[1] = { + key: '@metric', + operator: '=', + value: 'logins.count', + }; + expect(model.hasVariableValuesChanged()).toBeTruthy(); + }); + + it('getSaveModelClone should return original variable when saveVariables=false', () => { + model.templating.list[0].filters[0].value = 'server 1'; + + const options = { saveVariables: false }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].filters[0].value).toBe('server 20'); + }); + + it('getSaveModelClone should return updated variable when saveVariables=true', () => { + model.templating.list[0].filters[0].value = 'server 1'; + + const options = { saveVariables: true }; + const saveModel = model.getSaveModelClone(options); + + expect(saveModel.templating.list[0].filters[0].value).toBe('server 1'); + }); + }); + + describe('Given a dashboard with one panel legend on and two off', () => { + let model: DashboardModel; + + beforeEach(() => { + const data = { + panels: [ + { id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 }, legend: { show: true } }, + { id: 3, type: 'graph', gridPos: { x: 0, y: 4, w: 12, h: 2 }, legend: { show: false } }, + { id: 4, type: 'graph', gridPos: { x: 12, y: 4, w: 12, h: 2 }, legend: { show: false } }, + ], + }; + model = new DashboardModel(data); + }); + + it('toggleLegendsForAll should toggle all legends on on first execution', () => { + model.toggleLegendsForAll(); + const legendsOn = model.panels.filter((panel) => panel.legend!.show === true); + expect(legendsOn.length).toBe(3); + }); + + it('toggleLegendsForAll should toggle all legends off on second execution', () => { + model.toggleLegendsForAll(); + model.toggleLegendsForAll(); + const legendsOn = model.panels.filter((panel) => panel.legend!.show === true); + expect(legendsOn.length).toBe(0); + }); + }); + + describe('canAddAnnotations', () => { + it.each` + canEdit | canMakeEditable | expected + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${true} + ${true} | ${true} | ${true} + `( + 'when called with canEdit:{$canEdit}, canMakeEditable:{$canMakeEditable} and expected:{$expected}', + ({ canEdit, canMakeEditable, expected }) => { + const dashboard = new DashboardModel({}); + dashboard.meta.canEdit = canEdit; + dashboard.meta.canMakeEditable = canMakeEditable; + + const result = dashboard.canAddAnnotations(); + + expect(result).toBe(expected); + } + ); + }); +}); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts new file mode 100644 index 0000000..f71b608 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -0,0 +1,1067 @@ +// Libaries +import _ from 'lodash'; +// Constants +import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants'; +// Utils & Services +import { contextSrv } from 'app/core/services/context_srv'; +import sortByKeys from 'app/core/utils/sort_by_keys'; +// Types +import { GridPos, PanelModel } from './PanelModel'; +import { DashboardMigrator } from './DashboardMigrator'; +import { + AppEvent, + dateTimeFormat, + dateTimeFormatTimeAgo, + DateTimeInput, + EventBusExtended, + EventBusSrv, + TimeRange, + TimeZone, + UrlQueryValue, +} from '@grafana/data'; +import { CoreEvents, DashboardMeta, KIOSK_MODE_TV } from 'app/types'; +import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; +import { variableAdapters } from 'app/features/variables/adapters'; +import { onTimeRangeUpdated } from 'app/features/variables/state/actions'; +import { dispatch } from '../../../store/store'; +import { isAllVariable } from '../../variables/utils'; +import { DashboardPanelsChangedEvent, RefreshEvent, RenderEvent } from 'app/types/events'; + +export interface CloneOptions { + saveVariables?: boolean; + saveTimerange?: boolean; + message?: string; +} + +export type DashboardLinkType = 'link' | 'dashboards'; + +export interface DashboardLink { + icon: string; + title: string; + tooltip: string; + type: DashboardLinkType; + url: string; + asDropdown: boolean; + tags: any[]; + searchHits?: any[]; + targetBlank: boolean; + keepTime: boolean; + includeVars: boolean; +} + +export class DashboardModel { + id: any; + uid: string; + title: string; + autoUpdate: any; + description: any; + tags: any; + style: any; + timezone: any; + editable: any; + graphTooltip: any; + time: any; + private originalTime: any; + timepicker: any; + templating: { list: any[] }; + private originalTemplating: any; + annotations: { list: any[] }; + refresh: any; + snapshot: any; + schemaVersion: number; + version: number; + revision: number; + links: DashboardLink[]; + gnetId: any; + panels: PanelModel[]; + panelInEdit?: PanelModel; + panelInView?: PanelModel; + + // ------------------ + // not persisted + // ------------------ + + // repeat process cycles + iteration?: number; + meta: DashboardMeta; + events: EventBusExtended; + + static nonPersistedProperties: { [str: string]: boolean } = { + events: true, + meta: true, + panels: true, // needs special handling + templating: true, // needs special handling + originalTime: true, + originalTemplating: true, + originalLibraryPanels: true, + panelInEdit: true, + panelInView: true, + getVariablesFromState: true, + formatDate: true, + }; + + constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { + if (!data) { + data = {}; + } + + this.events = new EventBusSrv(); + this.id = data.id || null; + this.uid = data.uid || null; + this.revision = data.revision; + this.title = data.title || 'No Title'; + this.autoUpdate = data.autoUpdate; + this.description = data.description; + this.tags = data.tags || []; + this.style = data.style || 'dark'; + this.timezone = data.timezone || ''; + this.editable = data.editable !== false; + this.graphTooltip = data.graphTooltip || 0; + this.time = data.time || { from: 'now-6h', to: 'now' }; + this.timepicker = data.timepicker || {}; + this.templating = this.ensureListExist(data.templating); + this.annotations = this.ensureListExist(data.annotations); + this.refresh = data.refresh; + this.snapshot = data.snapshot; + this.schemaVersion = data.schemaVersion || 0; + this.version = data.version || 0; + this.links = data.links || []; + this.gnetId = data.gnetId || null; + this.panels = _.map(data.panels || [], (panelData: any) => new PanelModel(panelData)); + this.formatDate = this.formatDate.bind(this); + + this.resetOriginalVariables(true); + this.resetOriginalTime(); + + this.initMeta(meta); + this.updateSchema(data); + + this.addBuiltInAnnotationQuery(); + this.sortPanelsByGridPos(); + } + + addBuiltInAnnotationQuery() { + let found = false; + for (const item of this.annotations.list) { + if (item.builtIn === 1) { + found = true; + break; + } + } + + if (found) { + return; + } + + this.annotations.list.unshift({ + datasource: '-- Grafana --', + name: 'Annotations & Alerts', + type: 'dashboard', + iconColor: DEFAULT_ANNOTATION_COLOR, + enable: true, + hide: true, + builtIn: 1, + }); + } + + private initMeta(meta?: DashboardMeta) { + meta = meta || {}; + + meta.canShare = meta.canShare !== false; + meta.canSave = meta.canSave !== false; + meta.canStar = meta.canStar !== false; + meta.canEdit = meta.canEdit !== false; + meta.showSettings = meta.canEdit; + meta.canMakeEditable = meta.canSave && !this.editable; + meta.hasUnsavedFolderChange = false; + + if (!this.editable) { + meta.canEdit = false; + meta.canDelete = false; + meta.canSave = false; + } + + this.meta = meta; + } + + // cleans meta data and other non persistent state + getSaveModelClone(options?: CloneOptions): DashboardModel { + const defaults = _.defaults(options || {}, { + saveVariables: true, + saveTimerange: true, + }); + + // make clone + let copy: any = {}; + for (const property in this) { + if (DashboardModel.nonPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + copy[property] = _.cloneDeep(this[property]); + } + + this.updateTemplatingSaveModelClone(copy, defaults); + + if (!defaults.saveTimerange) { + copy.time = this.originalTime; + } + + // get panel save models + copy.panels = this.panels + .filter((panel: PanelModel) => panel.type !== 'add-panel') + .map((panel: PanelModel) => { + // If we save while editing we should include the panel in edit mode instead of the + // unmodified source panel + if (this.panelInEdit && this.panelInEdit.editSourceId === panel.id) { + const saveModel = this.panelInEdit.getSaveModel(); + // while editing a panel we modify its id, need to restore it here + saveModel.id = this.panelInEdit.editSourceId; + return saveModel; + } + + return panel.getSaveModel(); + }); + + // sort by keys + copy = sortByKeys(copy); + copy.getVariables = () => { + return copy.templating.list; + }; + + return copy; + } + + private updateTemplatingSaveModelClone( + copy: any, + defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions + ) { + const originalVariables = this.originalTemplating; + const currentVariables = this.getVariablesFromState(); + + copy.templating = { + list: currentVariables.map((variable) => + variableAdapters.get(variable.type).getSaveModel(variable, defaults.saveVariables) + ), + }; + + if (!defaults.saveVariables) { + for (let i = 0; i < copy.templating.list.length; i++) { + const current = copy.templating.list[i]; + const original: any = _.find(originalVariables, { name: current.name, type: current.type }); + + if (!original) { + continue; + } + + if (current.type === 'adhoc') { + copy.templating.list[i].filters = original.filters; + } else { + copy.templating.list[i].current = original.current; + } + } + } + } + + timeRangeUpdated(timeRange: TimeRange) { + this.events.emit(CoreEvents.timeRangeUpdated, timeRange); + dispatch(onTimeRangeUpdated(timeRange)); + } + + startRefresh() { + this.events.publish(new RefreshEvent()); + + if (this.panelInEdit) { + this.panelInEdit.refresh(); + return; + } + + for (const panel of this.panels) { + if (!this.otherPanelInFullscreen(panel)) { + panel.refresh(); + } + } + } + + render() { + this.events.publish(new RenderEvent()); + for (const panel of this.panels) { + panel.render(); + } + } + + panelInitialized(panel: PanelModel) { + const lastResult = panel.getQueryRunner().getLastResult(); + + if (!this.otherPanelInFullscreen(panel) && !lastResult) { + panel.refresh(); + } + } + + otherPanelInFullscreen(panel: PanelModel) { + return (this.panelInEdit || this.panelInView) && !(panel.isViewing || panel.isEditing); + } + + initEditPanel(sourcePanel: PanelModel): PanelModel { + this.panelInEdit = sourcePanel.getEditClone(); + return this.panelInEdit; + } + + initViewPanel(panel: PanelModel) { + this.panelInView = panel; + panel.setIsViewing(true); + } + + exitViewPanel(panel: PanelModel) { + this.panelInView = undefined; + panel.setIsViewing(false); + } + + exitPanelEditor() { + this.panelInEdit!.destroy(); + this.panelInEdit = undefined; + } + + private ensureListExist(data: any) { + if (!data) { + data = {}; + } + if (!data.list) { + data.list = []; + } + return data; + } + + getNextPanelId() { + let max = 0; + + for (const panel of this.panels) { + if (panel.id > max) { + max = panel.id; + } + + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id > max) { + max = rowPanel.id; + } + } + } + } + + return max + 1; + } + + forEachPanel(callback: (panel: PanelModel, index: number) => void) { + for (let i = 0; i < this.panels.length; i++) { + callback(this.panels[i], i); + } + } + + getPanelById(id: number): PanelModel | null { + if (this.panelInEdit && this.panelInEdit.id === id) { + return this.panelInEdit; + } + + for (const panel of this.panels) { + if (panel.id === id) { + return panel; + } + } + + return null; + } + + canEditPanel(panel?: PanelModel | null): boolean | undefined | null { + return this.meta.canEdit && panel && !panel.repeatPanelId; + } + + canEditPanelById(id: number): boolean | undefined | null { + return this.canEditPanel(this.getPanelById(id)); + } + + addPanel(panelData: any) { + panelData.id = this.getNextPanelId(); + + this.panels.unshift(new PanelModel(panelData)); + + this.sortPanelsByGridPos(); + + this.events.publish(new DashboardPanelsChangedEvent()); + } + + sortPanelsByGridPos() { + this.panels.sort((panelA, panelB) => { + if (panelA.gridPos.y === panelB.gridPos.y) { + return panelA.gridPos.x - panelB.gridPos.x; + } else { + return panelA.gridPos.y - panelB.gridPos.y; + } + }); + } + + cleanUpRepeats() { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + this.iteration = (this.iteration || new Date().getTime()) + 1; + const panelsToRemove = []; + + // cleanup scopedVars + for (const panel of this.panels) { + delete panel.scopedVars; + } + + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + if ((!panel.repeat || panel.repeatedByRow) && panel.repeatPanelId && panel.repeatIteration !== this.iteration) { + panelsToRemove.push(panel); + } + } + + // remove panels + _.pull(this.panels, ...panelsToRemove); + panelsToRemove.map((p) => p.destroy()); + this.sortPanelsByGridPos(); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + processRepeats() { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + this.cleanUpRepeats(); + + this.iteration = (this.iteration || new Date().getTime()) + 1; + + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + if (panel.repeat) { + this.repeatPanel(panel, i); + } + } + + this.sortPanelsByGridPos(); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + cleanUpRowRepeats(rowPanels: PanelModel[]) { + const panelsToRemove = []; + for (let i = 0; i < rowPanels.length; i++) { + const panel = rowPanels[i]; + if (!panel.repeat && panel.repeatPanelId) { + panelsToRemove.push(panel); + } + } + _.pull(rowPanels, ...panelsToRemove); + _.pull(this.panels, ...panelsToRemove); + } + + processRowRepeats(row: PanelModel) { + if (this.isSnapshotTruthy() || !this.hasVariables()) { + return; + } + + let rowPanels = row.panels; + if (!row.collapsed) { + const rowPanelIndex = _.findIndex(this.panels, (p: PanelModel) => p.id === row.id); + rowPanels = this.getRowPanels(rowPanelIndex); + } + + this.cleanUpRowRepeats(rowPanels); + + for (let i = 0; i < rowPanels.length; i++) { + const panel = rowPanels[i]; + if (panel.repeat) { + const panelIndex = _.findIndex(this.panels, (p: PanelModel) => p.id === panel.id); + this.repeatPanel(panel, panelIndex); + } + } + } + + getPanelRepeatClone(sourcePanel: PanelModel, valueIndex: number, sourcePanelIndex: number) { + // if first clone return source + if (valueIndex === 0) { + return sourcePanel; + } + + const clone = new PanelModel(sourcePanel.getSaveModel()); + + clone.id = this.getNextPanelId(); + + // insert after source panel + value index + this.panels.splice(sourcePanelIndex + valueIndex, 0, clone); + + clone.repeatIteration = this.iteration; + clone.repeatPanelId = sourcePanel.id; + clone.repeat = undefined; + + if (this.panelInView?.id === clone.id) { + clone.setIsViewing(true); + this.panelInView = clone; + } + + return clone; + } + + getRowRepeatClone(sourceRowPanel: PanelModel, valueIndex: number, sourcePanelIndex: number) { + // if first clone return source + if (valueIndex === 0) { + if (!sourceRowPanel.collapsed) { + const rowPanels = this.getRowPanels(sourcePanelIndex); + sourceRowPanel.panels = rowPanels; + } + return sourceRowPanel; + } + + const clone = new PanelModel(sourceRowPanel.getSaveModel()); + // for row clones we need to figure out panels under row to clone and where to insert clone + let rowPanels: PanelModel[], insertPos: number; + if (sourceRowPanel.collapsed) { + rowPanels = _.cloneDeep(sourceRowPanel.panels); + clone.panels = rowPanels; + // insert copied row after preceding row + insertPos = sourcePanelIndex + valueIndex; + } else { + rowPanels = this.getRowPanels(sourcePanelIndex); + clone.panels = _.map(rowPanels, (panel: PanelModel) => panel.getSaveModel()); + // insert copied row after preceding row's panels + insertPos = sourcePanelIndex + (rowPanels.length + 1) * valueIndex; + } + this.panels.splice(insertPos, 0, clone); + + this.updateRepeatedPanelIds(clone); + return clone; + } + + repeatPanel(panel: PanelModel, panelIndex: number) { + const variable: any = this.getPanelRepeatVariable(panel); + if (!variable) { + return; + } + + if (panel.type === 'row') { + this.repeatRow(panel, panelIndex, variable); + return; + } + + const selectedOptions = this.getSelectedVariableOptions(variable); + + const maxPerRow = panel.maxPerRow || 4; + let xPos = 0; + let yPos = panel.gridPos.y; + + for (let index = 0; index < selectedOptions.length; index++) { + const option = selectedOptions[index]; + let copy; + + copy = this.getPanelRepeatClone(panel, index, panelIndex); + copy.scopedVars = copy.scopedVars || {}; + copy.scopedVars[variable.name] = option; + + if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { + if (index > 0) { + yPos += copy.gridPos.h; + } + copy.gridPos.y = yPos; + } else { + // set width based on how many are selected + // assumed the repeated panels should take up full row width + copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, GRID_COLUMN_COUNT / maxPerRow); + copy.gridPos.x = xPos; + copy.gridPos.y = yPos; + + xPos += copy.gridPos.w; + + // handle overflow by pushing down one row + if (xPos + copy.gridPos.w > GRID_COLUMN_COUNT) { + xPos = 0; + yPos += copy.gridPos.h; + } + } + } + + // Update gridPos for panels below + const yOffset = yPos - panel.gridPos.y; + if (yOffset > 0) { + const panelBelowIndex = panelIndex + selectedOptions.length; + for (let i = panelBelowIndex; i < this.panels.length; i++) { + this.panels[i].gridPos.y += yOffset; + } + } + } + + repeatRow(panel: PanelModel, panelIndex: number, variable: any) { + const selectedOptions = this.getSelectedVariableOptions(variable); + let yPos = panel.gridPos.y; + + function setScopedVars(panel: PanelModel, variableOption: any) { + panel.scopedVars = panel.scopedVars || {}; + panel.scopedVars[variable.name] = variableOption; + } + + for (let optionIndex = 0; optionIndex < selectedOptions.length; optionIndex++) { + const option = selectedOptions[optionIndex]; + const rowCopy = this.getRowRepeatClone(panel, optionIndex, panelIndex); + setScopedVars(rowCopy, option); + + const rowHeight = this.getRowHeight(rowCopy); + const rowPanels = rowCopy.panels || []; + let panelBelowIndex; + + if (panel.collapsed) { + // For collapsed row just copy its panels and set scoped vars and proper IDs + _.each(rowPanels, (rowPanel: PanelModel, i: number) => { + setScopedVars(rowPanel, option); + if (optionIndex > 0) { + this.updateRepeatedPanelIds(rowPanel, true); + } + }); + rowCopy.gridPos.y += optionIndex; + yPos += optionIndex; + panelBelowIndex = panelIndex + optionIndex + 1; + } else { + // insert after 'row' panel + const insertPos = panelIndex + (rowPanels.length + 1) * optionIndex + 1; + _.each(rowPanels, (rowPanel: PanelModel, i: number) => { + setScopedVars(rowPanel, option); + if (optionIndex > 0) { + const cloneRowPanel = new PanelModel(rowPanel); + this.updateRepeatedPanelIds(cloneRowPanel, true); + // For exposed row additionally set proper Y grid position and add it to dashboard panels + cloneRowPanel.gridPos.y += rowHeight * optionIndex; + this.panels.splice(insertPos + i, 0, cloneRowPanel); + } + }); + rowCopy.panels = []; + rowCopy.gridPos.y += rowHeight * optionIndex; + yPos += rowHeight; + panelBelowIndex = insertPos + rowPanels.length; + } + + // Update gridPos for panels below + for (let i = panelBelowIndex; i < this.panels.length; i++) { + this.panels[i].gridPos.y += yPos; + } + } + } + + updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) { + panel.repeatPanelId = panel.id; + panel.id = this.getNextPanelId(); + panel.repeatIteration = this.iteration; + if (repeatedByRow) { + panel.repeatedByRow = true; + } else { + panel.repeat = undefined; + } + return panel; + } + + getSelectedVariableOptions(variable: any) { + let selectedOptions: any[]; + if (isAllVariable(variable)) { + selectedOptions = variable.options.slice(1, variable.options.length); + } else { + selectedOptions = _.filter(variable.options, { selected: true }); + } + return selectedOptions; + } + + getRowHeight(rowPanel: PanelModel): number { + if (!rowPanel.panels || rowPanel.panels.length === 0) { + return 0; + } + const rowYPos = rowPanel.gridPos.y; + const positions = _.map(rowPanel.panels, 'gridPos'); + const maxPos = _.maxBy(positions, (pos: GridPos) => { + return pos.y + pos.h; + }); + return maxPos.y + maxPos.h - rowYPos; + } + + removePanel(panel: PanelModel) { + this.panels = this.panels.filter((item) => item !== panel); + this.events.publish(new DashboardPanelsChangedEvent()); + } + + removeRow(row: PanelModel, removePanels: boolean) { + const needToogle = (!removePanels && row.collapsed) || (removePanels && !row.collapsed); + + if (needToogle) { + this.toggleRow(row); + } + + this.removePanel(row); + } + + expandRows() { + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (panel.collapsed) { + this.toggleRow(panel); + } + } + } + + collapseRows() { + for (let i = 0; i < this.panels.length; i++) { + const panel = this.panels[i]; + + if (panel.type !== 'row') { + continue; + } + + if (!panel.collapsed) { + this.toggleRow(panel); + } + } + } + + setPanelFocus(id: number) { + this.meta.focusPanelId = id; + } + + updateSubmenuVisibility() { + this.meta.submenuEnabled = (() => { + if (this.links.length > 0) { + return true; + } + + const visibleVars = _.filter(this.templating.list, (variable: any) => variable.hide !== 2); + if (visibleVars.length > 0) { + return true; + } + + const visibleAnnotations = _.filter(this.annotations.list, (annotation: any) => annotation.hide !== true); + if (visibleAnnotations.length > 0) { + return true; + } + + return false; + })(); + this.events.emit(CoreEvents.submenuVisibilityChanged, this.meta.submenuEnabled); + } + + getPanelInfoById(panelId: number) { + for (let i = 0; i < this.panels.length; i++) { + if (this.panels[i].id === panelId) { + return { + panel: this.panels[i], + index: i, + }; + } + } + + return null; + } + + duplicatePanel(panel: PanelModel) { + const newPanel = panel.getSaveModel(); + newPanel.id = this.getNextPanelId(); + + delete newPanel.repeat; + delete newPanel.repeatIteration; + delete newPanel.repeatPanelId; + delete newPanel.scopedVars; + if (newPanel.alert) { + delete newPanel.thresholds; + } + delete newPanel.alert; + + // does it fit to the right? + if (panel.gridPos.x + panel.gridPos.w * 2 <= GRID_COLUMN_COUNT) { + newPanel.gridPos.x += panel.gridPos.w; + } else { + // add below + newPanel.gridPos.y += panel.gridPos.h; + } + + this.addPanel(newPanel); + return newPanel; + } + + formatDate(date: DateTimeInput, format?: string) { + return dateTimeFormat(date, { + format, + timeZone: this.getTimezone(), + }); + } + + destroy() { + this.events.removeAllListeners(); + for (const panel of this.panels) { + panel.destroy(); + } + } + + toggleRow(row: PanelModel) { + const rowIndex = _.indexOf(this.panels, row); + + if (row.collapsed) { + row.collapsed = false; + const hasRepeat = _.some(row.panels as PanelModel[], (p: PanelModel) => p.repeat); + + if (row.panels.length > 0) { + // Use first panel to figure out if it was moved or pushed + const firstPanel = row.panels[0]; + const yDiff = firstPanel.gridPos.y - (row.gridPos.y + row.gridPos.h); + + // start inserting after row + let insertPos = rowIndex + 1; + // y max will represent the bottom y pos after all panels have been added + // needed to know home much panels below should be pushed down + let yMax = row.gridPos.y; + + for (const panel of row.panels) { + // make sure y is adjusted (in case row moved while collapsed) + // console.log('yDiff', yDiff); + panel.gridPos.y -= yDiff; + // insert after row + this.panels.splice(insertPos, 0, new PanelModel(panel)); + // update insert post and y max + insertPos += 1; + yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h); + } + + const pushDownAmount = yMax - row.gridPos.y - 1; + + // push panels below down + for (let panelIndex = insertPos; panelIndex < this.panels.length; panelIndex++) { + this.panels[panelIndex].gridPos.y += pushDownAmount; + } + + row.panels = []; + + if (hasRepeat) { + this.processRowRepeats(row); + } + } + + // sort panels + this.sortPanelsByGridPos(); + + // emit change event + this.events.publish(new DashboardPanelsChangedEvent()); + return; + } + + const rowPanels = this.getRowPanels(rowIndex); + + // remove panels + _.pull(this.panels, ...rowPanels); + // save panel models inside row panel + row.panels = _.map(rowPanels, (panel: PanelModel) => panel.getSaveModel()); + row.collapsed = true; + + // emit change event + this.events.publish(new DashboardPanelsChangedEvent()); + } + + /** + * Will return all panels after rowIndex until it encounters another row + */ + getRowPanels(rowIndex: number): PanelModel[] { + const rowPanels = []; + + for (let index = rowIndex + 1; index < this.panels.length; index++) { + const panel = this.panels[index]; + + // break when encountering another row + if (panel.type === 'row') { + break; + } + + // this panel must belong to row + rowPanels.push(panel); + } + + return rowPanels; + } + + /** @deprecated */ + on(event: AppEvent, callback: (payload?: T) => void) { + console.log('DashboardModel.on is deprecated use events.subscribe'); + this.events.on(event, callback); + } + + /** @deprecated */ + off(event: AppEvent, callback: (payload?: T) => void) { + console.log('DashboardModel.off is deprecated'); + this.events.off(event, callback); + } + + cycleGraphTooltip() { + this.graphTooltip = (this.graphTooltip + 1) % 3; + } + + sharedTooltipModeEnabled() { + return this.graphTooltip > 0; + } + + sharedCrosshairModeOnly() { + return this.graphTooltip === 1; + } + + getRelativeTime(date: DateTimeInput) { + return dateTimeFormatTimeAgo(date, { + timeZone: this.getTimezone(), + }); + } + + isSnapshot() { + return this.snapshot !== undefined; + } + + getTimezone(): TimeZone { + return (this.timezone ? this.timezone : contextSrv?.user?.timezone) as TimeZone; + } + + private updateSchema(old: any) { + const migrator = new DashboardMigrator(this); + migrator.updateSchema(old); + } + + resetOriginalTime() { + this.originalTime = _.cloneDeep(this.time); + } + + hasTimeChanged() { + return !_.isEqual(this.time, this.originalTime); + } + + resetOriginalVariables(initial = false) { + if (initial) { + this.originalTemplating = this.cloneVariablesFrom(this.templating.list); + return; + } + + this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState()); + } + + hasVariableValuesChanged() { + return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState()); + } + + autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { + const currentGridHeight = Math.max( + ...this.panels.map((panel) => { + return panel.gridPos.h + panel.gridPos.y; + }) + ); + + const navbarHeight = 55; + const margin = 20; + const submenuHeight = 50; + + let visibleHeight = viewHeight - navbarHeight - margin; + + // Remove submenu height if visible + if (this.meta.submenuEnabled && !kioskMode) { + visibleHeight -= submenuHeight; + } + + // add back navbar height + if (kioskMode && kioskMode !== KIOSK_MODE_TV) { + visibleHeight += navbarHeight; + } + + const visibleGridHeight = Math.floor(visibleHeight / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); + const scaleFactor = currentGridHeight / visibleGridHeight; + + this.panels.forEach((panel, i) => { + panel.gridPos.y = Math.round(panel.gridPos.y / scaleFactor) || 1; + panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1; + }); + } + + templateVariableValueUpdated() { + this.processRepeats(); + this.events.emit(CoreEvents.templateVariableValueUpdated); + } + + expandParentRowFor(panelId: number) { + for (const panel of this.panels) { + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id === panelId) { + this.toggleRow(panel); + return; + } + } + } + } + } + + toggleLegendsForAll() { + const panelsWithLegends = this.panels.filter((panel) => { + return panel.legend !== undefined && panel.legend !== null; + }); + + // determine if more panels are displaying legends or not + const onCount = panelsWithLegends.filter((panel) => panel.legend!.show).length; + const offCount = panelsWithLegends.length - onCount; + const panelLegendsOn = onCount >= offCount; + + for (const panel of panelsWithLegends) { + panel.legend!.show = !panelLegendsOn; + panel.render(); + } + } + + getVariables = () => { + return this.getVariablesFromState(); + }; + + canAddAnnotations() { + return this.meta.canEdit || this.meta.canMakeEditable; + } + + private getPanelRepeatVariable(panel: PanelModel) { + return this.getVariablesFromState().find((variable) => variable.name === panel.repeat); + } + + private isSnapshotTruthy() { + return this.snapshot; + } + + private hasVariables() { + return this.getVariablesFromState().length > 0; + } + + private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean { + if (originalVariables.length !== currentVariables.length) { + return false; + } + + const updated = _.map(currentVariables, (variable: any) => { + return { + name: variable.name, + type: variable.type, + current: _.cloneDeep(variable.current), + filters: _.cloneDeep(variable.filters), + }; + }); + + return !_.isEqual(updated, originalVariables); + } + + private cloneVariablesFrom(variables: any[]): any[] { + return variables.map((variable) => { + return { + name: variable.name, + type: variable.type, + current: _.cloneDeep(variable.current), + filters: _.cloneDeep(variable.filters), + }; + }); + } +} diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts new file mode 100644 index 0000000..6315ac6 --- /dev/null +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -0,0 +1,468 @@ +import { PanelModel } from './PanelModel'; +import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { + DataLinkBuiltInVars, + FieldConfigProperty, + PanelData, + PanelProps, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, +} from '@grafana/data'; +import { ComponentClass } from 'react'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { setTimeSrv } from '../services/TimeSrv'; +import { TemplateSrv } from '../../templating/template_srv'; +import { setTemplateSrv } from '@grafana/runtime'; +import { variableAdapters } from '../../variables/adapters'; +import { createQueryVariableAdapter } from '../../variables/query/adapter'; +import { mockStandardFieldConfigOptions } from '../../../../test/helpers/fieldConfig'; +import { queryBuilder } from 'app/features/variables/shared/testing/builders'; + +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + +setTimeSrv({ + timeRangeForUrl: () => ({ + from: 1607687293000, + to: 1607687293100, + }), +} as any); + +const getVariables = () => variablesMock; +const getVariableWithName = (name: string) => variablesMock.filter((v) => v.name === name)[0]; +const getFilteredVariables = jest.fn(); + +setTemplateSrv( + new TemplateSrv({ + getVariables, + getVariableWithName, + getFilteredVariables, + }) +); + +variableAdapters.setInit(() => [createQueryVariableAdapter()]); + +describe('PanelModel', () => { + describe('when creating new panel model', () => { + let model: any; + let modelJson: any; + let persistedOptionsMock; + + const tablePlugin = getPanelPlugin( + { + id: 'table', + }, + (null as unknown) as ComponentClass, // react + {} // angular + ); + + tablePlugin.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + description: '', + }); + }); + + tablePlugin.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Unit]: { + defaultValue: 'flop', + }, + [FieldConfigProperty.Decimals]: { + defaultValue: 2, + }, + }, + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'CustomProp', + path: 'customProp', + defaultValue: false, + }); + }, + }); + + beforeEach(() => { + persistedOptionsMock = { + fieldOptions: { + thresholds: [ + { + color: '#F2495C', + index: 1, + value: 50, + }, + { + color: '#73BF69', + index: 0, + value: null, + }, + ], + }, + arrayWith2Values: [{ name: 'changed to only one value' }], + }; + + modelJson = { + type: 'table', + maxDataPoints: 100, + interval: '5m', + showColumns: true, + targets: [{ refId: 'A' }, { noRefId: true }], + options: persistedOptionsMock, + fieldConfig: { + defaults: { + unit: 'mpg', + thresholds: { + mode: 'absolute', + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + }, + }, + overrides: [ + { + matcher: { + id: '1', + options: {}, + }, + properties: [ + { + id: 'thresholds', + value: { + mode: 'absolute', + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + }, + }, + ], + }, + ], + }, + }; + + model = new PanelModel(modelJson); + model.pluginLoaded(tablePlugin); + }); + + it('should apply defaults', () => { + expect(model.gridPos.h).toBe(3); + }); + + it('should apply option defaults', () => { + expect(model.getOptions().showThresholds).toBeTruthy(); + }); + + it('should change null thresholds to negative infinity', () => { + expect(model.fieldConfig.defaults.thresholds.steps[0].value).toBe(-Infinity); + expect(model.fieldConfig.overrides[0].properties[0].value.steps[0].value).toBe(-Infinity); + }); + + it('should apply option defaults but not override if array is changed', () => { + expect(model.getOptions().arrayWith2Values.length).toBe(1); + }); + + it('should apply field config defaults', () => { + // default unit is overriden by model + expect(model.getFieldOverrideOptions().fieldConfig.defaults.unit).toBe('mpg'); + // default decimals are aplied + expect(model.getFieldOverrideOptions().fieldConfig.defaults.decimals).toBe(2); + }); + + it('should set model props on instance', () => { + expect(model.showColumns).toBe(true); + }); + + it('should add missing refIds', () => { + expect(model.targets[1].refId).toBe('B'); + }); + + it("shouldn't break panel with non-array targets", () => { + modelJson.targets = { + 0: { refId: 'A' }, + foo: { bar: 'baz' }, + }; + model = new PanelModel(modelJson); + expect(model.targets[0].refId).toBe('A'); + }); + + it('getSaveModel should remove defaults', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.gridPos).toBe(undefined); + }); + + it('getSaveModel should not remove datasource default', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.datasource).toBe(null); + }); + + it('getSaveModel should remove nonPersistedProperties', () => { + const saveModel = model.getSaveModel(); + expect(saveModel.events).toBe(undefined); + }); + + describe('variables interpolation', () => { + beforeEach(() => { + model.scopedVars = { + aaa: { value: 'AAA', text: 'upperA' }, + bbb: { value: 'BBB', text: 'upperB' }, + }; + }); + it('should interpolate variables', () => { + const out = model.replaceVariables('hello $aaa'); + expect(out).toBe('hello AAA'); + }); + + it('should interpolate $__url_time_range variable', () => { + const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.keepTime}`); + expect(out).toBe('/d/1?from=1607687293000&to=1607687293100'); + }); + + it('should interpolate $__all_variables variable', () => { + const out = model.replaceVariables(`/d/1?$${DataLinkBuiltInVars.includeVars}`); + expect(out).toBe('/d/1?var-test1=val1&var-test2=val2&var-test3=Value%203&var-test4=A&var-test4=B'); + }); + + it('should prefer the local variable value', () => { + const extra = { aaa: { text: '???', value: 'XXX' } }; + const out = model.replaceVariables('hello $aaa and $bbb', extra); + expect(out).toBe('hello XXX and BBB'); + }); + }); + + describe('when changing panel type', () => { + beforeEach(() => { + const newPlugin = getPanelPlugin({ id: 'graph' }); + + newPlugin.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byThresholdsSupport: true, + }, + }, + }, + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'customProp', + name: 'customProp', + defaultValue: 100, + }); + }, + }); + + newPlugin.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds labels', + path: 'showThresholdLabels', + defaultValue: false, + description: '', + }); + }); + + model.editSourceId = 1001; + model.fieldConfig.defaults.decimals = 3; + model.fieldConfig.defaults.custom = { + customProp: true, + }; + model.fieldConfig.overrides = [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: false, + }, + { + id: 'decimals', + value: 0, + }, + ], + }, + ]; + model.changePlugin(newPlugin); + model.alert = { id: 2 }; + }); + + it('should keep editSourceId', () => { + expect(model.editSourceId).toBe(1001); + }); + + it('should keep maxDataPoints', () => { + expect(model.maxDataPoints).toBe(100); + }); + + it('should keep interval', () => { + expect(model.interval).toBe('5m'); + }); + + it('should preseve standard field config', () => { + expect(model.fieldConfig.defaults.decimals).toEqual(3); + }); + + it('should clear custom field config and apply new defaults', () => { + expect(model.fieldConfig.defaults.custom).toEqual({ + customProp: 100, + }); + }); + + it('should remove overrides with custom props', () => { + expect(model.fieldConfig.overrides.length).toEqual(1); + expect(model.fieldConfig.overrides[0].properties[0].id).toEqual('decimals'); + }); + + it('should apply next panel option defaults', () => { + expect(model.getOptions().showThresholdLabels).toBeFalsy(); + expect(model.getOptions().showThresholds).toBeUndefined(); + }); + + it('should remove table properties but keep core props', () => { + expect(model.showColumns).toBe(undefined); + }); + + it('should restore table properties when changing back', () => { + model.changePlugin(tablePlugin); + expect(model.showColumns).toBe(true); + }); + + it('should restore custom field config to what it was and preserve standard options', () => { + model.changePlugin(tablePlugin); + expect(model.fieldConfig.defaults.custom.customProp).toBe(true); + }); + + it('should remove alert rule when changing type that does not support it', () => { + model.changePlugin(getPanelPlugin({ id: 'table' })); + expect(model.alert).toBe(undefined); + }); + }); + + describe('when changing to react panel from angular panel', () => { + let panelQueryRunner: any; + + const onPanelTypeChanged = jest.fn(); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + + beforeEach(() => { + model.changePlugin(reactPlugin); + panelQueryRunner = model.getQueryRunner(); + }); + + it('should call react onPanelTypeChanged', () => { + expect(onPanelTypeChanged.mock.calls.length).toBe(1); + expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table'); + expect(onPanelTypeChanged.mock.calls[0][2].angular).toBeDefined(); + }); + + it('getQueryRunner() should return same instance after changing to another react panel', () => { + model.changePlugin(getPanelPlugin({ id: 'react2' })); + const sameQueryRunner = model.getQueryRunner(); + expect(panelQueryRunner).toBe(sameQueryRunner); + }); + }); + + describe('variables interpolation', () => { + let panelQueryRunner: any; + + const onPanelTypeChanged = jest.fn(); + const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any); + + beforeEach(() => { + model.changePlugin(reactPlugin); + panelQueryRunner = model.getQueryRunner(); + }); + + it('should call react onPanelTypeChanged', () => { + expect(onPanelTypeChanged.mock.calls.length).toBe(1); + expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table'); + expect(onPanelTypeChanged.mock.calls[0][2].angular).toBeDefined(); + }); + + it('getQueryRunner() should return same instance after changing to another react panel', () => { + model.changePlugin(getPanelPlugin({ id: 'react2' })); + const sameQueryRunner = model.getQueryRunner(); + expect(panelQueryRunner).toBe(sameQueryRunner); + }); + }); + + describe('restoreModel', () => { + it('Should clean state and set properties from model', () => { + model.restoreModel({ + title: 'New title', + options: { new: true }, + }); + expect(model.title).toBe('New title'); + expect(model.options.new).toBe(true); + }); + + it('Should delete properties that are now gone on new model', () => { + model.someProperty = 'value'; + model.restoreModel({ + title: 'New title', + options: {}, + }); + + expect(model.someProperty).toBeUndefined(); + }); + + it('Should remove old angular panel specific props', () => { + model.axes = [{ prop: 1 }]; + model.thresholds = []; + + model.restoreModel({ + title: 'New title', + options: {}, + }); + + expect(model.axes).toBeUndefined(); + expect(model.thresholds).toBeUndefined(); + }); + + it('Should be able to set defaults back to default', () => { + model.transparent = true; + + model.restoreModel({}); + expect(model.transparent).toBe(false); + }); + }); + + describe('destroy', () => { + it('Should still preserve last query result', () => { + model.getQueryRunner().useLastResultFrom({ + getLastResult: () => ({} as PanelData), + } as PanelQueryRunner); + + model.destroy(); + expect(model.getQueryRunner().getLastResult()).toBeDefined(); + }); + }); + + describe('getDisplayTitle', () => { + it('when called then it should interpolate singe value variables in title', () => { + const model = new PanelModel({ + title: 'Single value variable [[test3]] ${test3} ${test3:percentencode}', + }); + const title = model.getDisplayTitle(); + + expect(title).toEqual('Single value variable Value 3 Value 3 Value%203'); + }); + + it('when called then it should interpolate multi value variables in title', () => { + const model = new PanelModel({ + title: 'Multi value variable [[test4]] ${test4} ${test4:percentencode}', + }); + const title = model.getDisplayTitle(); + + expect(title).toEqual('Multi value variable A + B A + B %7BA%2CB%7D'); + }); + }); + }); +}); + +const variablesMock = [ + queryBuilder().withId('test1').withName('test1').withCurrent('val1').build(), + queryBuilder().withId('test2').withName('test2').withCurrent('val2').build(), + queryBuilder().withId('test3').withName('test3').withCurrent('Value 3').build(), + queryBuilder().withId('test4').withName('test4').withCurrent(['A', 'B']).build(), +]; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts new file mode 100644 index 0000000..ccb175b --- /dev/null +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -0,0 +1,577 @@ +// Libraries +import _ from 'lodash'; +// Utils +import { getTemplateSrv } from '@grafana/runtime'; +import { getNextRefIdChar } from 'app/core/utils/query'; +// Types +import { + DataConfigSource, + DataFrameDTO, + DataLink, + DataLinkBuiltInVars, + DataQuery, + DataTransformerConfig, + EventBus, + EventBusSrv, + FieldConfigSource, + PanelPlugin, + ScopedVars, + urlUtil, +} from '@grafana/data'; +import { EDIT_PANEL_ID } from 'app/core/constants'; +import config from 'app/core/config'; +import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; +import { + PanelOptionsChangedEvent, + PanelQueriesChangedEvent, + PanelTransformationsChangedEvent, + RefreshEvent, + RenderEvent, +} from 'app/types/events'; +import { getTimeSrv } from '../services/TimeSrv'; +import { getAllVariableValuesForUrl } from '../../variables/getAllVariableValuesForUrl'; +import { + filterFieldConfigOverrides, + getPanelOptionsWithDefaults, + isStandardFieldProp, + restoreCustomOverrideRules, +} from './getPanelOptionsWithDefaults'; +import { QueryGroupOptions } from 'app/types'; +import { PanelModelLibraryPanel } from '../../library-panels/types'; + +export interface GridPos { + x: number; + y: number; + w: number; + h: number; + static?: boolean; +} + +const notPersistedProperties: { [str: string]: boolean } = { + events: true, + isViewing: true, + isEditing: true, + isInView: true, + hasRefreshed: true, + cachedPluginOptions: true, + plugin: true, + queryRunner: true, + replaceVariables: true, + editSourceId: true, + hasChanged: true, + getDisplayTitle: true, +}; + +// For angular panels we need to clean up properties when changing type +// To make sure the change happens without strange bugs happening when panels use same +// named property with different type / value expectations +// This is not required for react panels +const mustKeepProps: { [str: string]: boolean } = { + id: true, + gridPos: true, + type: true, + title: true, + scopedVars: true, + repeat: true, + repeatIteration: true, + repeatPanelId: true, + repeatDirection: true, + repeatedByRow: true, + minSpan: true, + collapsed: true, + panels: true, + targets: true, + datasource: true, + timeFrom: true, + timeShift: true, + hideTimeOverride: true, + description: true, + links: true, + fullscreen: true, + isEditing: true, + hasRefreshed: true, + events: true, + cacheTimeout: true, + cachedPluginOptions: true, + transparent: true, + pluginVersion: true, + queryRunner: true, + transformations: true, + fieldConfig: true, + editSourceId: true, + maxDataPoints: true, + interval: true, + replaceVariables: true, + libraryPanel: true, + getDisplayTitle: true, +}; + +const defaults: any = { + gridPos: { x: 0, y: 0, h: 3, w: 6 }, + targets: [{ refId: 'A' }], + cachedPluginOptions: {}, + transparent: false, + options: {}, + datasource: null, + title: '', +}; + +export class PanelModel implements DataConfigSource { + /* persisted id, used in URL to identify a panel */ + id!: number; + editSourceId?: number; + gridPos!: GridPos; + type!: string; + title!: string; + alert?: any; + scopedVars?: ScopedVars; + repeat?: string; + repeatIteration?: number; + repeatPanelId?: number; + repeatDirection?: string; + repeatedByRow?: boolean; + maxPerRow?: number; + collapsed?: boolean; + + panels?: any; + targets: DataQuery[]; + transformations?: DataTransformerConfig[]; + datasource: string | null; + thresholds?: any; + pluginVersion?: string; + + snapshotData?: DataFrameDTO[]; + timeFrom?: any; + timeShift?: any; + hideTimeOverride?: any; + options: { + [key: string]: any; + }; + fieldConfig: FieldConfigSource; + + maxDataPoints?: number | null; + interval?: string | null; + description?: string; + links?: DataLink[]; + transparent: boolean; + + libraryPanel?: { uid: undefined; name: string } | PanelModelLibraryPanel; + + // non persisted + isViewing: boolean; + isEditing: boolean; + isInView: boolean; + hasChanged: boolean; + + hasRefreshed: boolean; + events: EventBus; + cacheTimeout?: any; + cachedPluginOptions: Record; + legend?: { show: boolean; sort?: string; sortDesc?: boolean }; + plugin?: PanelPlugin; + + private queryRunner?: PanelQueryRunner; + + constructor(model: any) { + this.events = new EventBusSrv(); + this.restoreModel(model); + this.replaceVariables = this.replaceVariables.bind(this); + } + + /** Given a persistened PanelModel restores property values */ + restoreModel(model: any) { + // Start with clean-up + for (const property in this) { + if (notPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + if (model[property]) { + continue; + } + + if (typeof (this as any)[property] === 'function') { + continue; + } + + if (typeof (this as any)[property] === 'symbol') { + continue; + } + + delete (this as any)[property]; + } + + // copy properties from persisted model + for (const property in model) { + (this as any)[property] = model[property]; + } + + // defaults + _.defaultsDeep(this, _.cloneDeep(defaults)); + + // queries must have refId + this.ensureQueryIds(); + } + + ensureQueryIds() { + if (this.targets && _.isArray(this.targets)) { + for (const query of this.targets) { + if (!query.refId) { + query.refId = getNextRefIdChar(this.targets); + } + } + } + } + + getOptions() { + return this.options; + } + + getFieldConfig() { + return this.fieldConfig; + } + + updateOptions(options: object) { + this.options = options; + this.hasChanged = true; + this.events.publish(new PanelOptionsChangedEvent()); + this.render(); + } + + updateFieldConfig(config: FieldConfigSource) { + this.fieldConfig = config; + this.hasChanged = true; + this.events.publish(new PanelOptionsChangedEvent()); + + this.resendLastResult(); + this.render(); + } + + getSaveModel() { + const model: any = {}; + + for (const property in this) { + if (notPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + if (_.isEqual(this[property], defaults[property])) { + continue; + } + + model[property] = _.cloneDeep(this[property]); + } + + if (model.datasource === undefined) { + // This is part of defaults as defaults are removed in save model and + // this should not be removed in save model as exporter needs to templatize it + model.datasource = null; + } + + return model; + } + + setIsViewing(isViewing: boolean) { + this.isViewing = isViewing; + } + + updateGridPos(newPos: GridPos) { + this.gridPos.x = newPos.x; + this.gridPos.y = newPos.y; + this.gridPos.w = newPos.w; + this.gridPos.h = newPos.h; + } + + refresh() { + this.hasRefreshed = true; + this.events.publish(new RefreshEvent()); + } + + render() { + if (!this.hasRefreshed) { + this.refresh(); + } else { + this.events.publish(new RenderEvent()); + } + } + + private getOptionsToRemember() { + return Object.keys(this).reduce((acc, property) => { + if (notPersistedProperties[property] || mustKeepProps[property]) { + return acc; + } + return { + ...acc, + [property]: (this as any)[property], + }; + }, {}); + } + + private restorePanelOptions(pluginId: string) { + if (!this.cachedPluginOptions) { + return; + } + + const prevOptions = this.cachedPluginOptions[pluginId]; + + if (!prevOptions) { + return; + } + + Object.keys(prevOptions.properties).map((property) => { + (this as any)[property] = prevOptions.properties[property]; + }); + + this.fieldConfig = restoreCustomOverrideRules(this.fieldConfig, prevOptions.fieldConfig); + } + + applyPluginOptionDefaults(plugin: PanelPlugin, isAfterPluginChange: boolean) { + const options = getPanelOptionsWithDefaults({ + plugin, + currentOptions: this.options, + currentFieldConfig: this.fieldConfig, + isAfterPluginChange: isAfterPluginChange, + }); + + this.fieldConfig = options.fieldConfig; + this.options = options.options; + } + + pluginLoaded(plugin: PanelPlugin) { + this.plugin = plugin; + + if (plugin.onPanelMigration) { + const version = getPluginVersion(plugin); + + if (version !== this.pluginVersion) { + this.options = plugin.onPanelMigration(this); + this.pluginVersion = version; + } + } + + this.applyPluginOptionDefaults(plugin, false); + this.resendLastResult(); + } + + clearPropertiesBeforePluginChange() { + // remove panel type specific options + for (const key of _.keys(this)) { + if (mustKeepProps[key]) { + continue; + } + delete (this as any)[key]; + } + + this.options = {}; + + // clear custom options + this.fieldConfig = { + defaults: { + ...this.fieldConfig.defaults, + custom: {}, + }, + // filter out custom overrides + overrides: filterFieldConfigOverrides(this.fieldConfig.overrides, isStandardFieldProp), + }; + } + + changePlugin(newPlugin: PanelPlugin) { + const pluginId = newPlugin.meta.id; + const oldOptions: any = this.getOptionsToRemember(); + const prevFieldConfig = this.fieldConfig; + const oldPluginId = this.type; + const wasAngular = this.isAngularPlugin(); + + this.cachedPluginOptions[oldPluginId] = { + properties: oldOptions, + fieldConfig: prevFieldConfig, + }; + + this.clearPropertiesBeforePluginChange(); + this.restorePanelOptions(pluginId); + + // Let panel plugins inspect options from previous panel and keep any that it can use + if (newPlugin.onPanelTypeChanged) { + const prevOptions = wasAngular ? { angular: oldOptions } : oldOptions.options; + Object.assign(this.options, newPlugin.onPanelTypeChanged(this, oldPluginId, prevOptions, prevFieldConfig)); + } + + // switch + this.type = pluginId; + this.plugin = newPlugin; + this.hasChanged = true; + + // For some reason I need to rebind replace variables here, otherwise the viz repeater does not work + this.replaceVariables = this.replaceVariables.bind(this); + this.applyPluginOptionDefaults(newPlugin, true); + + if (newPlugin.onPanelMigration) { + this.pluginVersion = getPluginVersion(newPlugin); + } + } + + updateQueries(options: QueryGroupOptions) { + this.datasource = options.dataSource.default ? null : options.dataSource.name!; + this.timeFrom = options.timeRange?.from; + this.timeShift = options.timeRange?.shift; + this.hideTimeOverride = options.timeRange?.hide; + this.interval = options.minInterval; + this.maxDataPoints = options.maxDataPoints; + this.targets = options.queries; + this.hasChanged = true; + + this.events.publish(new PanelQueriesChangedEvent()); + } + + addQuery(query?: Partial) { + query = query || { refId: 'A' }; + query.refId = getNextRefIdChar(this.targets); + this.targets.push(query as DataQuery); + this.hasChanged = true; + } + + changeQuery(query: DataQuery, index: number) { + // ensure refId is maintained + query.refId = this.targets[index].refId; + this.hasChanged = true; + + // update query in array + this.targets = this.targets.map((item, itemIndex) => { + if (itemIndex === index) { + return query; + } + return item; + }); + } + + getEditClone() { + const sourceModel = this.getSaveModel(); + + // Temporary id for the clone, restored later in redux action when changes are saved + sourceModel.id = EDIT_PANEL_ID; + sourceModel.editSourceId = this.id; + + const clone = new PanelModel(sourceModel); + clone.isEditing = true; + const sourceQueryRunner = this.getQueryRunner(); + + // Copy last query result + clone.getQueryRunner().useLastResultFrom(sourceQueryRunner); + + return clone; + } + + getTransformations() { + return this.transformations; + } + + getFieldOverrideOptions() { + if (!this.plugin) { + return undefined; + } + + return { + fieldConfig: this.fieldConfig, + replaceVariables: this.replaceVariables, + fieldConfigRegistry: this.plugin.fieldConfigRegistry, + theme: config.theme, + }; + } + + getQueryRunner(): PanelQueryRunner { + if (!this.queryRunner) { + this.queryRunner = new PanelQueryRunner(this); + } + return this.queryRunner; + } + + hasTitle() { + return this.title && this.title.length > 0; + } + + isAngularPlugin(): boolean { + return (this.plugin && this.plugin.angularPanelCtrl) !== undefined; + } + + destroy() { + this.events.removeAllListeners(); + + if (this.queryRunner) { + this.queryRunner.destroy(); + } + } + + setTransformations(transformations: DataTransformerConfig[]) { + this.transformations = transformations; + this.resendLastResult(); + this.hasChanged = true; + this.events.publish(new PanelTransformationsChangedEvent()); + } + + setProperty(key: keyof this, value: any) { + this[key] = value; + this.hasChanged = true; + } + + replaceVariables(value: string, extraVars: ScopedVars | undefined, format?: string | Function) { + let vars = this.scopedVars; + + if (extraVars) { + vars = vars ? { ...vars, ...extraVars } : extraVars; + } + const allVariablesParams = getAllVariableValuesForUrl(vars); + const variablesQuery = urlUtil.toUrlParams(allVariablesParams); + const timeRangeUrl = urlUtil.toUrlParams(getTimeSrv().timeRangeForUrl()); + + vars = { + ...vars, + [DataLinkBuiltInVars.keepTime]: { + text: timeRangeUrl, + value: timeRangeUrl, + }, + [DataLinkBuiltInVars.includeVars]: { + text: variablesQuery, + value: variablesQuery, + }, + }; + + return getTemplateSrv().replace(value, vars, format); + } + + resendLastResult() { + if (!this.plugin) { + return; + } + + this.getQueryRunner().resendLastResult(); + } + + /* + * Panel have a different id while in edit mode (to more easily be able to discard changes) + * Use this to always get the underlying source id + * */ + getSavedId(): number { + return this.editSourceId ?? this.id; + } + + /* + * This is the title used when displaying the title in the UI so it will include any interpolated variables. + * If you need the raw title without interpolation use title property instead. + * */ + getDisplayTitle(): string { + return this.replaceVariables(this.title, {}, 'text'); + } +} + +function getPluginVersion(plugin: PanelPlugin): string { + return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version; +} + +export function isLibraryPanel(panel: PanelModel): panel is PanelModel & Required> { + return panel.libraryPanel !== undefined; +} + +interface PanelOptionsCache { + properties: any; + fieldConfig: FieldConfigSource; +} diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts new file mode 100644 index 0000000..4d81d9e --- /dev/null +++ b/public/app/features/dashboard/state/actions.ts @@ -0,0 +1,166 @@ +// Services & Utils +import { getBackendSrv } from '@grafana/runtime'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +// Actions +import { loadPluginDashboards } from '../../plugins/state/actions'; +import { + cleanUpDashboard, + loadDashboardPermissions, + panelModelAndPluginReady, + setPanelAngularComponent, +} from './reducers'; +import { notifyApp } from 'app/core/actions'; +import { loadPanelPlugin } from 'app/features/plugins/state/actions'; +// Types +import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel, ThunkResult } from 'app/types'; +import { PanelModel } from './PanelModel'; +import { cancelVariables } from '../../variables/state/actions'; + +export function getDashboardPermissions(id: number): ThunkResult { + return async (dispatch) => { + const permissions = await getBackendSrv().get(`/api/dashboards/id/${id}/permissions`); + dispatch(loadDashboardPermissions(permissions)); + }; +} + +function toUpdateItem(item: DashboardAcl): DashboardAclUpdateDTO { + return { + userId: item.userId, + teamId: item.teamId, + role: item.role, + permission: item.permission, + }; +} + +export function updateDashboardPermission( + dashboardId: number, + itemToUpdate: DashboardAcl, + level: PermissionLevel +): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + + const updated = toUpdateItem(item); + + // if this is the item we want to update, update it's permission + if (itemToUpdate === item) { + updated.permission = level; + } + + itemsToUpdate.push(updated); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function removeDashboardPermission(dashboardId: number, itemToDelete: DashboardAcl): ThunkResult { + return async (dispatch, getStore) => { + const dashboard = getStore().dashboard; + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited || item === itemToDelete) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function addDashboardPermission(dashboardId: number, newItem: NewDashboardAclItem): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + itemsToUpdate.push({ + userId: newItem.userId, + teamId: newItem.teamId, + role: newItem.role, + permission: newItem.permission, + }); + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function importDashboard(data: any, dashboardTitle: string): ThunkResult { + return async (dispatch) => { + await getBackendSrv().post('/api/dashboards/import', data); + dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle))); + dispatch(loadPluginDashboards()); + }; +} + +export function removeDashboard(uri: string): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`/api/dashboards/${uri}`); + dispatch(loadPluginDashboards()); + }; +} + +export function initDashboardPanel(panel: PanelModel): ThunkResult { + return async (dispatch, getStore) => { + let plugin = getStore().plugins.panels[panel.type]; + + if (!plugin) { + plugin = await dispatch(loadPanelPlugin(panel.type)); + } + + if (!panel.plugin) { + panel.pluginLoaded(plugin); + } + + dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); + }; +} + +export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult { + return async (dispatch, getStore) => { + // ignore action is no change + if (panel.type === pluginId) { + return; + } + + const store = getStore(); + let plugin = store.plugins.panels[pluginId]; + + if (!plugin) { + plugin = await dispatch(loadPanelPlugin(pluginId)); + } + + // clean up angular component (scope / ctrl state) + const angularComponent = store.dashboard.panels[panel.id].angularComponent; + if (angularComponent) { + angularComponent.destroy(); + dispatch(setPanelAngularComponent({ panelId: panel.id, angularComponent: null })); + } + + panel.changePlugin(plugin); + + dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin })); + }; +} + +export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch) => { + dispatch(cleanUpDashboard()); + dispatch(cancelVariables()); +}; diff --git a/public/app/features/dashboard/state/analyticsProcessor.ts b/public/app/features/dashboard/state/analyticsProcessor.ts new file mode 100644 index 0000000..9834d86 --- /dev/null +++ b/public/app/features/dashboard/state/analyticsProcessor.ts @@ -0,0 +1,14 @@ +import { DashboardModel } from './DashboardModel'; +import { reportMetaAnalytics, MetaAnalyticsEventName, DashboardViewEventPayload } from '@grafana/runtime'; + +export function emitDashboardViewEvent(dashboard: DashboardModel) { + const eventData: DashboardViewEventPayload = { + dashboardId: dashboard.id, + dashboardName: dashboard.title, + dashboardUid: dashboard.uid, + folderName: dashboard.meta.folderTitle, + eventName: MetaAnalyticsEventName.DashboardView, + }; + + reportMetaAnalytics(eventData); +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts new file mode 100644 index 0000000..fdbdf90 --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts @@ -0,0 +1,419 @@ +import { + ConfigOverrideRule, + FieldColorModeId, + FieldConfig, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, + StandardOptionConfig, + ThresholdsMode, +} from '@grafana/data'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; +import { mockStandardFieldConfigOptions } from 'test/helpers/fieldConfig'; +import { getPanelOptionsWithDefaults, restoreCustomOverrideRules } from './getPanelOptionsWithDefaults'; + +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + +const pluginA = getPanelPlugin({ id: 'graph' }); + +pluginA.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'Hide lines', + path: 'hideLines', + defaultValue: false, + }); + }, +}); + +pluginA.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + }); + builder.addTextInput({ + name: 'Name', + path: 'name', + defaultValue: 'hello', + }); + builder.addNumberInput({ + name: 'Number', + path: 'number', + defaultValue: 10, + }); +}); + +describe('getPanelOptionsWithDefaults', () => { + describe('When panel plugin has no options', () => { + it('Should set defaults', () => { + const result = runScenario({ + plugin: getPanelPlugin({ id: 'graph' }), + options: {}, + defaults: {}, + overrides: [], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object {}, + "overrides": Array [], + }, + "options": Object {}, + } + `); + }); + }); + + describe('When current options are emtpy', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: {}, + currentFieldConfig: { + defaults: {}, + overrides: [], + }, + isAfterPluginChange: false, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 10, + "showThresholds": true, + }, + } + `); + }); + }); + + describe('When there are current options and overrides', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: { + number: 20, + showThresholds: false, + }, + currentFieldConfig: { + defaults: { + unit: 'bytes', + decimals: 2, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "decimals": 2, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 20, + "showThresholds": false, + }, + } + `); + }); + }); + + describe('when changing panel type to one that does not support by value color mode', () => { + it('should change color mode', () => { + const plugin = getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + }, + }, + }); + + const result = getPanelOptionsWithDefaults({ + plugin, + currentOptions: {}, + currentFieldConfig: { + defaults: { + color: { mode: FieldColorModeId.Thresholds }, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + }); + + describe('when changing panel type from one not supporting by value color mode to one that supports it', () => { + it('should keep supported mode', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + }, + }, + }, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + + it('should change to thresholds mode when it prefers to', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + preferThresholdsMode: true, + }, + }, + }, + isAfterPluginChange: true, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.Thresholds); + }); + }); + + describe('when changing panel type to one that does not use standard field config', () => { + it('should clean defaults', () => { + const plugin = getPanelPlugin({ id: 'graph' }); + + const result = getPanelOptionsWithDefaults({ + plugin, + currentOptions: {}, + currentFieldConfig: { + defaults: { + color: { mode: FieldColorModeId.Thresholds }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [], + }, + }, + overrides: [], + }, + isAfterPluginChange: true, + }); + + expect(result.fieldConfig.defaults.thresholds).toBeUndefined(); + }); + }); + + describe('when applying defaults clean properties that are no longer part of the registry', () => { + it('should remove custom defaults that no longer exist', () => { + const result = runScenario({ + defaults: { + unit: 'bytes', + custom: { + customProp: 20, + customPropNoExist: true, + nested: { + nestedA: 'A', + nestedB: 'B', + }, + }, + }, + }); + + expect(result.fieldConfig.defaults).toMatchInlineSnapshot(` + Object { + "custom": Object { + "customProp": 20, + "nested": Object { + "nestedA": "A", + }, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + } + `); + }); + + it('should remove custom overrides that no longer exist', () => { + const result = runScenario({ + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: 30, + }, + ], + }, + ], + }); + + expect(result.fieldConfig.overrides.length).toBe(1); + expect(result.fieldConfig.overrides[0].properties[0].id).toBe('custom.customProp'); + }); + }); +}); + +describe('restoreCustomOverrideRules', () => { + it('should add back custom rules', () => { + const current = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'decimals', + value: 2, + }, + ], + }, + ], + }; + const old = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'custom.propName', + value: 10, + }, + ], + }, + { + matcher: { id: 'byName', options: 'SeriesB' }, + properties: [ + { + id: 'custom.propName', + value: 20, + }, + ], + }, + ], + }; + + const result = restoreCustomOverrideRules(current, old); + expect(result.overrides.length).toBe(2); + expect(result.overrides[0].properties[0].id).toBe('decimals'); + expect(result.overrides[0].properties[1].id).toBe('custom.propName'); + expect(result.overrides[1].properties.length).toBe(1); + expect(result.overrides[1].matcher.options).toBe('SeriesB'); + }); +}); + +interface ScenarioOptions { + defaults?: FieldConfig; + overrides?: ConfigOverrideRule[]; + disabledStandardOptions?: FieldConfigProperty[]; + standardOptions?: Partial>; + plugin?: PanelPlugin; + options?: any; + isAfterPluginChange?: boolean; +} + +function runScenario(options: ScenarioOptions) { + const fieldConfig: FieldConfigSource = { + defaults: options.defaults || {}, + overrides: options.overrides || [], + }; + + const plugin = + options.plugin ?? + getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: options.standardOptions, + useCustomConfig: (builder) => { + builder.addNumberInput({ + name: 'Custom prop', + path: 'customProp', + defaultValue: 10, + }); + builder.addTextInput({ + name: 'Nested prop', + path: 'nested.nestedA', + }); + }, + }); + + return getPanelOptionsWithDefaults({ + plugin, + currentOptions: options.options || {}, + currentFieldConfig: fieldConfig, + isAfterPluginChange: !!options.isAfterPluginChange, + }); +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts new file mode 100644 index 0000000..2bf513a --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts @@ -0,0 +1,218 @@ +import { + ConfigOverrideRule, + DynamicConfigValue, + FieldColorConfigSettings, + FieldColorModeId, + fieldColorModeRegistry, + FieldConfigOptionsRegistry, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + ThresholdsConfig, + ThresholdsMode, +} from '@grafana/data'; +import { mergeWith, isArray, isObject, unset, isEqual } from 'lodash'; + +export interface Props { + plugin: PanelPlugin; + currentFieldConfig: FieldConfigSource; + currentOptions: Record; + isAfterPluginChange: boolean; +} + +export interface OptionDefaults { + options: any; + fieldConfig: FieldConfigSource; +} + +export function getPanelOptionsWithDefaults({ + plugin, + currentOptions, + currentFieldConfig, + isAfterPluginChange, +}: Props): OptionDefaults { + const optionsWithDefaults = mergeWith( + {}, + plugin.defaults, + currentOptions || {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ); + + const fieldConfigWithDefaults = applyFieldConfigDefaults(currentFieldConfig, plugin); + const fieldConfigWithOptimalColorMode = adaptFieldColorMode(plugin, fieldConfigWithDefaults, isAfterPluginChange); + + return { options: optionsWithDefaults, fieldConfig: fieldConfigWithOptimalColorMode }; +} + +function applyFieldConfigDefaults(existingFieldConfig: FieldConfigSource, plugin: PanelPlugin): FieldConfigSource { + const pluginDefaults = plugin.fieldConfigDefaults; + + const result: FieldConfigSource = { + defaults: mergeWith( + {}, + pluginDefaults.defaults, + existingFieldConfig ? existingFieldConfig.defaults : {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ), + overrides: existingFieldConfig?.overrides ?? [], + }; + + cleanProperties(result.defaults, '', plugin.fieldConfigRegistry); + + // Thresholds base values are null in JSON but need to be converted to -Infinity + if (result.defaults.thresholds) { + fixThresholds(result.defaults.thresholds); + } + + // Filter out overrides for properties that cannot be found in registry + result.overrides = filterFieldConfigOverrides(result.overrides, (prop) => { + return plugin.fieldConfigRegistry.getIfExists(prop.id) !== undefined; + }); + + for (const override of result.overrides) { + for (const property of override.properties) { + if (property.id === 'thresholds') { + fixThresholds(property.value as ThresholdsConfig); + } + } + } + + return result; +} + +export function filterFieldConfigOverrides( + overrides: ConfigOverrideRule[], + condition: (value: DynamicConfigValue) => boolean +): ConfigOverrideRule[] { + return overrides + .map((x) => { + const properties = x.properties.filter(condition); + + return { + ...x, + properties, + }; + }) + .filter((x) => x.properties.length > 0); +} + +function cleanProperties(obj: any, parentPath: string, fieldConfigRegistry: FieldConfigOptionsRegistry) { + let found = false; + + for (const propName of Object.keys(obj)) { + const value = obj[propName]; + const fullPath = `${parentPath}${propName}`; + const existsInRegistry = !!fieldConfigRegistry.getIfExists(fullPath); + + // need to check early here as some standard properties have nested properies + if (existsInRegistry) { + found = true; + continue; + } + + if (isArray(value) || !isObject(value)) { + if (!existsInRegistry) { + unset(obj, propName); + } + } else { + const childPropFound = cleanProperties(value, `${fullPath}.`, fieldConfigRegistry); + // If no child props found unset the main object + if (!childPropFound) { + unset(obj, propName); + } + } + } + + return found; +} + +function adaptFieldColorMode( + plugin: PanelPlugin, + fieldConfig: FieldConfigSource, + isAfterPluginChange: boolean +): FieldConfigSource { + if (!isAfterPluginChange) { + return fieldConfig; + } + + // adjust to prefered field color setting if needed + const color = plugin.fieldConfigRegistry.getIfExists(FieldConfigProperty.Color); + + if (color && color.settings) { + const colorSettings = color.settings as FieldColorConfigSettings; + const mode = fieldColorModeRegistry.getIfExists(fieldConfig.defaults.color?.mode); + + // When no support fo value colors, use classic palette + if (!colorSettings.byValueSupport) { + if (!mode || mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.PaletteClassic }; + return fieldConfig; + } + } + + // When supporting value colors and prefering thresholds, use Thresholds mode. + // Otherwise keep current mode + if (colorSettings.byValueSupport && colorSettings.preferThresholdsMode) { + if (!mode || !mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.Thresholds }; + return fieldConfig; + } + } + } + return fieldConfig; +} + +function fixThresholds(thresholds: ThresholdsConfig) { + if (!thresholds.mode) { + thresholds.mode = ThresholdsMode.Absolute; + } + + if (!thresholds.steps) { + thresholds.steps = []; + } else if (thresholds.steps.length) { + // First value is always -Infinity + // JSON saves it as null + thresholds.steps[0].value = -Infinity; + } +} + +export function restoreCustomOverrideRules(current: FieldConfigSource, old: FieldConfigSource): FieldConfigSource { + const result = { + defaults: { + ...current.defaults, + custom: old.defaults.custom, + }, + overrides: [...current.overrides], + }; + + for (const override of old.overrides) { + for (const prop of override.properties) { + if (isCustomFieldProp(prop)) { + const currentOverride = result.overrides.find((o) => isEqual(o.matcher, override.matcher)); + if (currentOverride) { + currentOverride.properties.push(prop); + } else { + result.overrides.push(override); + } + } + } + } + + return result; +} + +export function isCustomFieldProp(prop: DynamicConfigValue): boolean { + return prop.id.startsWith('custom.'); +} + +export function isStandardFieldProp(prop: DynamicConfigValue): boolean { + return !isCustomFieldProp(prop); +} diff --git a/public/app/features/dashboard/state/index.ts b/public/app/features/dashboard/state/index.ts new file mode 100644 index 0000000..253d4aa --- /dev/null +++ b/public/app/features/dashboard/state/index.ts @@ -0,0 +1,2 @@ +export { DashboardModel } from './DashboardModel'; +export { PanelModel } from './PanelModel'; diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts new file mode 100644 index 0000000..6f76e76 --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -0,0 +1,307 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { initDashboard, InitDashboardArgs } from './initDashboard'; +import { DashboardInitPhase, DashboardRouteInfo } from 'app/types'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { dashboardInitCompleted, dashboardInitFetching, dashboardInitServices } from './reducers'; +import { updateLocation } from '../../../core/actions'; +import { setEchoSrv } from '@grafana/runtime'; +import { Echo } from '../../../core/services/echo/Echo'; +import { variableAdapters } from 'app/features/variables/adapters'; +import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter'; +import { constantBuilder } from 'app/features/variables/shared/testing/builders'; +import { TransactionStatus, variablesInitTransaction } from '../../variables/state/transactionReducer'; + +jest.mock('app/core/services/backend_srv'); +jest.mock('app/features/dashboard/services/TimeSrv', () => { + const original = jest.requireActual('app/features/dashboard/services/TimeSrv'); + return { + ...original, + getTimeSrv: () => ({ + ...original.getTimeSrv(), + timeRange: jest.fn().mockReturnValue(undefined), + }), + }; +}); +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + user: { orgId: 1, orgName: 'TestOrg' }, + }, +})); + +variableAdapters.register(createConstantVariableAdapter()); +const mockStore = configureMockStore([thunk]); + +interface ScenarioContext { + args: InitDashboardArgs; + timeSrv: any; + annotationsSrv: any; + unsavedChangesSrv: any; + dashboardSrv: any; + loaderSrv: any; + keybindingSrv: any; + backendSrv: any; + setup: (fn: () => void) => void; + actions: any[]; + storeState: any; +} + +type ScenarioFn = (ctx: ScenarioContext) => void; + +function describeInitScenario(description: string, scenarioFn: ScenarioFn) { + describe(description, () => { + const timeSrv = { init: jest.fn() }; + const annotationsSrv = { init: jest.fn() }; + const unsavedChangesSrv = { init: jest.fn() }; + const dashboardSrv = { setCurrent: jest.fn() }; + const keybindingSrv = { setupDashboardBindings: jest.fn() }; + const loaderSrv = { + loadDashboard: jest.fn(() => ({ + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'My cool dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + id: 2, + targets: [ + { + refId: 'A', + expr: 'old expr', + }, + ], + }, + ], + templating: { + list: [constantBuilder().build()], + }, + }, + })), + }; + + const injectorMock = { + get: (name: string) => { + switch (name) { + case 'timeSrv': + return timeSrv; + case 'annotationsSrv': + return annotationsSrv; + case 'dashboardLoaderSrv': + return loaderSrv; + case 'unsavedChangesSrv': + return unsavedChangesSrv; + case 'dashboardSrv': + return dashboardSrv; + case 'keybindingSrv': + return keybindingSrv; + default: + throw { message: 'Unknown service ' + name }; + } + }, + }; + + let setupFn = () => {}; + + const ctx: ScenarioContext = { + args: { + urlUid: 'DGmvKKxZz', + $injector: injectorMock, + $scope: {}, + fixUrl: false, + routeInfo: DashboardRouteInfo.Normal, + }, + backendSrv: getBackendSrv(), + timeSrv, + annotationsSrv, + unsavedChangesSrv, + dashboardSrv, + keybindingSrv, + loaderSrv, + actions: [], + storeState: { + location: { + query: {}, + }, + dashboard: { + initPhase: DashboardInitPhase.Services, + }, + user: {}, + explore: { + left: { + originPanelId: undefined, + queries: [], + }, + }, + templating: { + variables: {}, + transaction: { uid: 'DGmvKKxZz', status: TransactionStatus.Completed }, + }, + }, + setup: (fn: () => void) => { + setupFn = fn; + }, + }; + + beforeEach(async () => { + setupFn(); + setEchoSrv(new Echo()); + + const store = mockStore(ctx.storeState); + // @ts-ignore + await store.dispatch(initDashboard(ctx.args)); + + ctx.actions = store.getActions(); + }); + + scenarioFn(ctx); + }); +} + +describeInitScenario('Initializing new dashboard', (ctx) => { + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.args.routeInfo = DashboardRouteInfo.New; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should update location with orgId query param', () => { + expect(ctx.actions[2].type).toBe(updateLocation.type); + expect(ctx.actions[2].payload.query.orgId).toBe(12); + }); + + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[8].type).toBe(dashboardInitCompleted.type); + expect(ctx.actions[8].payload.title).toBe('New dashboard'); + }); + + it('Should initialize services', () => { + expect(ctx.timeSrv.init).toBeCalled(); + expect(ctx.annotationsSrv.init).toBeCalled(); + expect(ctx.unsavedChangesSrv.init).toBeCalled(); + expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled(); + expect(ctx.dashboardSrv.setCurrent).toBeCalled(); + }); +}); + +describeInitScenario('Initializing home dashboard', (ctx) => { + ctx.setup(() => { + ctx.args.routeInfo = DashboardRouteInfo.Home; + ctx.backendSrv.get.mockResolvedValue({ + redirectUri: '/u/123/my-home', + }); + }); + + it('Should redirect to custom home dashboard', () => { + expect(ctx.actions[1].type).toBe(updateLocation.type); + expect(ctx.actions[1].payload.path).toBe('/u/123/my-home'); + }); +}); + +describeInitScenario('Initializing home dashboard cancelled', (ctx) => { + ctx.setup(() => { + ctx.args.routeInfo = DashboardRouteInfo.Home; + ctx.backendSrv.get.mockRejectedValue({ cancelled: true }); + }); + + it('Should abort init process', () => { + expect(ctx.actions.length).toBe(1); + }); +}); + +describeInitScenario('Initializing existing dashboard', (ctx) => { + const mockQueries = [ + { + context: 'explore', + key: 'jdasldsa98dsa9', + refId: 'A', + expr: 'new expr', + }, + { + context: 'explore', + key: 'fdsjkfds78fd', + refId: 'B', + }, + ]; + + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.storeState.explore.left.originPanelId = 2; + ctx.storeState.explore.left.queries = mockQueries; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should update location with orgId query param', () => { + expect(ctx.actions[2].type).toBe(updateLocation.type); + expect(ctx.actions[2].payload.query.orgId).toBe(12); + }); + + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[9].type).toBe(dashboardInitCompleted.type); + expect(ctx.actions[9].payload.title).toBe('My cool dashboard'); + }); + + it('Should initialize services', () => { + expect(ctx.timeSrv.init).toBeCalled(); + expect(ctx.annotationsSrv.init).toBeCalled(); + expect(ctx.unsavedChangesSrv.init).toBeCalled(); + expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled(); + expect(ctx.dashboardSrv.setCurrent).toBeCalled(); + }); + + it('Should initialize redux variables if newVariables is enabled', () => { + expect(ctx.actions[3].type).toBe(variablesInitTransaction.type); + }); +}); + +describeInitScenario('Initializing previously canceled dashboard initialization', (ctx) => { + ctx.setup(() => { + ctx.storeState.dashboard.initPhase = DashboardInitPhase.Fetching; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should not send action dashboardInitCompleted', () => { + const dashboardInitCompletedAction = ctx.actions.find((a) => { + return a.type === dashboardInitCompleted.type; + }); + expect(dashboardInitCompletedAction).toBe(undefined); + }); + + it('Should initialize timeSrv and annotationsSrv', () => { + expect(ctx.timeSrv.init).toBeCalled(); + expect(ctx.annotationsSrv.init).toBeCalled(); + }); + + it('Should not initialize other services', () => { + expect(ctx.unsavedChangesSrv.init).not.toBeCalled(); + expect(ctx.keybindingSrv.setupDashboardBindings).not.toBeCalled(); + expect(ctx.dashboardSrv.setCurrent).not.toBeCalled(); + }); +}); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts new file mode 100644 index 0000000..72ec4b4 --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -0,0 +1,287 @@ +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; +import { KeybindingSrv } from 'app/core/services/keybindingSrv'; +// Actions +import { notifyApp, updateLocation } from 'app/core/actions'; +import { + clearDashboardQueriesToUpdateOnLoad, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitFetching, + dashboardInitServices, + dashboardInitSlow, +} from './reducers'; +// Types +import { + DashboardDTO, + DashboardRouteInfo, + StoreState, + ThunkDispatch, + ThunkResult, + DashboardInitPhase, +} from 'app/types'; +import { DashboardModel } from './DashboardModel'; +import { DataQuery, locationUtil } from '@grafana/data'; +import { initVariablesTransaction } from '../../variables/state/actions'; +import { emitDashboardViewEvent } from './analyticsProcessor'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; + +export interface InitDashboardArgs { + $injector: any; + $scope: any; + urlUid?: string; + urlSlug?: string; + urlType?: string; + urlFolderId?: string; + routeInfo: DashboardRouteInfo; + fixUrl: boolean; +} + +async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) { + const res = await backendSrv.getDashboardBySlug(slug); + + if (res) { + let newUrl = res.meta.url; + + // fix solo route urls + if (currentPath.indexOf('dashboard-solo') !== -1) { + newUrl = newUrl.replace('/d/', '/d-solo/'); + } + + const url = locationUtil.stripBaseFromUrl(newUrl); + dispatch(updateLocation({ path: url, partial: true, replace: true })); + } +} + +async function fetchDashboard( + args: InitDashboardArgs, + dispatch: ThunkDispatch, + getState: () => StoreState +): Promise { + try { + switch (args.routeInfo) { + case DashboardRouteInfo.Home: { + // load home dash + const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home'); + + // if user specified a custom home dashboard redirect to that + if (dashDTO.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); + dispatch(updateLocation({ path: newUrl, replace: true })); + return null; + } + + // disable some actions on the default home dashboard + dashDTO.meta.canSave = false; + dashDTO.meta.canShare = false; + dashDTO.meta.canStar = false; + return dashDTO; + } + case DashboardRouteInfo.Normal: { + // for old db routes we redirect + if (args.urlType === 'db') { + redirectToNewUrl(args.urlSlug!, dispatch, getState().location.path); + return null; + } + + const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv'); + const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); + + if (args.fixUrl && dashDTO.meta.url) { + // check if the current url is correct (might be old slug) + const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); + const currentPath = getState().location.path; + + if (dashboardUrl !== currentPath) { + // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. + dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true })); + return null; + } + } + return dashDTO; + } + case DashboardRouteInfo.New: { + return getNewDashboardModelData(args.urlFolderId); + } + default: + throw { message: 'Unknown route ' + args.routeInfo }; + } + } catch (err) { + // Ignore cancelled errors + if (err.cancelled) { + return null; + } + + dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err })); + console.error(err); + return null; + } +} + +/** + * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model. + * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates) + * + * This is used both for single dashboard & solo panel routes, home & new dashboard routes. + * + * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on + * + */ +export function initDashboard(args: InitDashboardArgs): ThunkResult { + return async (dispatch, getState) => { + // set fetching state + dispatch(dashboardInitFetching()); + + // Detect slow loading / initializing and set state flag + // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing + setTimeout(() => { + if (getState().dashboard.getModel() === null) { + dispatch(dashboardInitSlow()); + } + }, 500); + + // fetch dashboard data + const dashDTO = await fetchDashboard(args, dispatch, getState); + + // returns null if there was a redirect or error + if (!dashDTO) { + return; + } + + // set initializing state + dispatch(dashboardInitServices()); + + // create model + let dashboard: DashboardModel; + try { + dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + } catch (err) { + dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err })); + console.error(err); + return; + } + + // add missing orgId query param + const storeState = getState(); + if (!storeState.location.query.orgId) { + dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true })); + } + + // init services + const timeSrv: TimeSrv = args.$injector.get('timeSrv'); + const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv'); + const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv'); + const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv'); + const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv'); + + timeSrv.init(dashboard); + annotationsSrv.init(dashboard); + + if (storeState.dashboard.modifiedQueries) { + const { panelId, queries } = storeState.dashboard.modifiedQueries; + dashboard.meta.fromExplore = !!(panelId && queries); + } + + // template values service needs to initialize completely before the rest of the dashboard can load + await dispatch(initVariablesTransaction(args.urlUid!, dashboard)); + + if (getState().templating.transaction.uid !== args.urlUid) { + // if a previous dashboard has slow running variable queries the batch uid will be the new one + // but the args.urlUid will be the same as before initVariablesTransaction was called so then we can't continue initializing + // the previous dashboard. + return; + } + + // If dashboard is in a different init phase it means it cancelled during service init + if (getState().dashboard.initPhase !== DashboardInitPhase.Services) { + return; + } + + try { + dashboard.processRepeats(); + dashboard.updateSubmenuVisibility(); + + // handle auto fix experimental feature + const queryParams = getState().location.query; + if (queryParams.autofitpanels) { + dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk); + } + + // init unsaved changes tracking + unsavedChangesSrv.init(dashboard, args.$scope); + keybindingSrv.setupDashboardBindings(args.$scope, dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Dashboard init failed', err))); + console.error(err); + } + + if (storeState.dashboard.modifiedQueries) { + const { panelId, queries } = storeState.dashboard.modifiedQueries; + updateQueriesWhenComingFromExplore(dispatch, dashboard, panelId, queries); + } + + // legacy srv state + dashboardSrv.setCurrent(dashboard); + + // send open dashboard event + if (args.routeInfo !== DashboardRouteInfo.New) { + emitDashboardViewEvent(dashboard); + + // Listen for changes on the current dashboard + dashboardWatcher.watch(dashboard.uid); + } else { + dashboardWatcher.leave(); + } + + // yay we are done + dispatch(dashboardInitCompleted(dashboard)); + }; +} + +function getNewDashboardModelData(urlFolderId?: string): any { + const data = { + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'New dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + }, + ], + }, + }; + + if (urlFolderId) { + data.meta.folderId = parseInt(urlFolderId, 10); + } + + return data; +} + +function updateQueriesWhenComingFromExplore( + dispatch: ThunkDispatch, + dashboard: DashboardModel, + originPanelId: number, + queries: DataQuery[] +) { + const panelArrId = dashboard.panels.findIndex((panel) => panel.id === originPanelId); + + if (panelArrId > -1) { + dashboard.panels[panelArrId].targets = queries; + } + + // Clear update state now that we're done + dispatch(clearDashboardQueriesToUpdateOnLoad()); +} diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts new file mode 100644 index 0000000..2674b32 --- /dev/null +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -0,0 +1,85 @@ +import { + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitFetching, + dashboardInitSlow, + loadDashboardPermissions, + dashboardReducer, + initialState, +} from './reducers'; +import { DashboardInitPhase, DashboardState, OrgRole, PermissionLevel } from 'app/types'; +import { DashboardModel } from './DashboardModel'; + +describe('dashboard reducer', () => { + describe('loadDashboardPermissions', () => { + let state: DashboardState; + + beforeEach(() => { + const action = loadDashboardPermissions([ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + ]); + state = dashboardReducer(initialState, action); + }); + + it('should add permissions to state', async () => { + expect(state.permissions?.length).toBe(2); + }); + }); + + describe('dashboardInitCompleted', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitSlow()); + state = dashboardReducer( + state, + dashboardInitCompleted( + new DashboardModel({ + title: 'My dashboard', + panels: [{ id: 1 }, { id: 2 }], + }) + ) + ); + }); + + it('should set model', async () => { + expect(state.getModel()!.title).toBe('My dashboard'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should create panel state', async () => { + expect(state.panels['1']).toBeDefined(); + expect(state.panels['2']).toBeDefined(); + }); + }); + + describe('dashboardInitFailed', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitFailed({ message: 'Oh no', error: 'sad' })); + }); + + it('should set model', async () => { + expect(state.getModel()?.title).toBe('Dashboard init failed'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should set initError', async () => { + expect(state.initError?.message).toBe('Oh no'); + }); + + it('should set phase failed', async () => { + expect(state.initPhase).toBe(DashboardInitPhase.Failed); + }); + }); +}); diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts new file mode 100644 index 0000000..0460b9c --- /dev/null +++ b/public/app/features/dashboard/state/reducers.ts @@ -0,0 +1,135 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + DashboardAclDTO, + DashboardInitError, + DashboardInitPhase, + DashboardState, + PanelState, + QueriesToUpdateOnDashboardLoad, +} from 'app/types'; +import { AngularComponent } from '@grafana/runtime'; +import { EDIT_PANEL_ID } from 'app/core/constants'; +import { processAclItems } from 'app/core/utils/acl'; +import { panelEditorReducer } from '../components/PanelEditor/state/reducers'; +import { DashboardModel } from './DashboardModel'; +import { PanelModel } from './PanelModel'; +import { PanelPlugin } from '@grafana/data'; + +export const initialState: DashboardState = { + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + getModel: () => null, + permissions: [], + modifiedQueries: null, + panels: {}, + initError: null, +}; + +const dashbardSlice = createSlice({ + name: 'dashboard', + initialState, + reducers: { + loadDashboardPermissions: (state, action: PayloadAction) => { + state.permissions = processAclItems(action.payload); + }, + dashboardInitFetching: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Fetching; + }, + dashboardInitServices: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Services; + }, + dashboardInitSlow: (state, action: PayloadAction) => { + state.isInitSlow = true; + }, + dashboardInitCompleted: (state, action: PayloadAction) => { + state.getModel = () => action.payload; + state.initPhase = DashboardInitPhase.Completed; + state.isInitSlow = false; + + for (const panel of action.payload.panels) { + state.panels[panel.id] = { + pluginId: panel.type, + }; + } + }, + dashboardInitFailed: (state, action: PayloadAction) => { + state.initPhase = DashboardInitPhase.Failed; + state.initError = action.payload; + state.getModel = () => { + return new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }); + }; + }, + cleanUpDashboard: (state, action: PayloadAction) => { + if (state.getModel()) { + state.getModel()!.destroy(); + state.getModel = () => null; + } + + state.panels = {}; + state.initPhase = DashboardInitPhase.NotStarted; + state.isInitSlow = false; + state.initError = null; + }, + setDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => { + state.modifiedQueries = action.payload; + }, + clearDashboardQueriesToUpdateOnLoad: (state, action: PayloadAction) => { + state.modifiedQueries = null; + }, + panelModelAndPluginReady: (state: DashboardState, action: PayloadAction) => { + updatePanelState(state, action.payload.panelId, { plugin: action.payload.plugin }); + }, + cleanUpEditPanel: (state, action: PayloadAction) => { + // TODO: refactor, since the state should be mutated by copying only + delete state.panels[EDIT_PANEL_ID]; + }, + setPanelAngularComponent: (state: DashboardState, action: PayloadAction) => { + updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent }); + }, + addPanel: (state, action: PayloadAction) => { + // TODO: refactor, since the state should be mutated by copying only + state.panels[action.payload.id] = { pluginId: action.payload.type }; + }, + }, +}); + +export function updatePanelState(state: DashboardState, panelId: number, ps: Partial) { + if (!state.panels[panelId]) { + state.panels[panelId] = ps as PanelState; + } else { + Object.assign(state.panels[panelId], ps); + } +} + +export interface PanelModelAndPluginReadyPayload { + panelId: number; + plugin: PanelPlugin; +} + +export interface SetPanelAngularComponentPayload { + panelId: number; + angularComponent: AngularComponent | null; +} + +export const { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitFailed, + dashboardInitSlow, + dashboardInitCompleted, + dashboardInitServices, + cleanUpDashboard, + setDashboardQueriesToUpdateOnLoad, + clearDashboardQueriesToUpdateOnLoad, + panelModelAndPluginReady, + addPanel, + cleanUpEditPanel, + setPanelAngularComponent, +} = dashbardSlice.actions; + +export const dashboardReducer = dashbardSlice.reducer; + +export default { + dashboard: dashboardReducer, + panelEditor: panelEditorReducer, +}; diff --git a/public/app/features/dashboard/state/selectors.ts b/public/app/features/dashboard/state/selectors.ts new file mode 100644 index 0000000..1f2fd77 --- /dev/null +++ b/public/app/features/dashboard/state/selectors.ts @@ -0,0 +1,9 @@ +import { DashboardState, PanelState } from 'app/types'; + +export function getPanelStateById(state: DashboardState, panelId: number): PanelState { + if (!panelId) { + return {} as PanelState; + } + + return state.panels[panelId] ?? ({} as PanelState); +} diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts new file mode 100644 index 0000000..0d52d18 --- /dev/null +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -0,0 +1,204 @@ +import { PanelMenuItem } from '@grafana/data'; +import { DashboardModel, PanelModel } from '../state'; +import { getPanelMenu } from './getPanelMenu'; +import { describe } from '../../../../test/lib/common'; +import { setStore } from 'app/store/store'; +import config from 'app/core/config'; +import * as actions from 'app/features/explore/state/main'; + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + hasAccessToExplore: () => true, + }, +})); + +describe('getPanelMenu', () => { + it('should return the correct panel menu items', () => { + const panel = new PanelModel({}); + const dashboard = new DashboardModel({}); + + const menuItems = getPanelMenu(dashboard, panel); + expect(menuItems).toMatchInlineSnapshot(` + Array [ + Object { + "iconClassName": "eye", + "onClick": [Function], + "shortcut": "v", + "text": "View", + }, + Object { + "iconClassName": "edit", + "onClick": [Function], + "shortcut": "e", + "text": "Edit", + }, + Object { + "iconClassName": "share-alt", + "onClick": [Function], + "shortcut": "p s", + "text": "Share", + }, + Object { + "iconClassName": "compass", + "onClick": [Function], + "shortcut": "x", + "text": "Explore", + }, + Object { + "iconClassName": "info-circle", + "onClick": [Function], + "shortcut": "i", + "subMenu": Array [ + Object { + "onClick": [Function], + "text": "Panel JSON", + }, + ], + "text": "Inspect", + "type": "submenu", + }, + Object { + "iconClassName": "cube", + "onClick": [Function], + "subMenu": Array [ + Object { + "onClick": [Function], + "shortcut": "p d", + "text": "Duplicate", + }, + Object { + "onClick": [Function], + "text": "Copy", + }, + ], + "text": "More...", + "type": "submenu", + }, + Object { + "text": "", + "type": "divider", + }, + Object { + "iconClassName": "trash-alt", + "onClick": [Function], + "shortcut": "p r", + "text": "Remove", + }, + ] + `); + }); + + describe('when panel is in view mode', () => { + it('should return the correct panel menu items', () => { + const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }]; + const ctrl: any = { getExtendedMenu }; + const scope: any = { $$childHead: { ctrl } }; + const angularComponent: any = { getScope: () => scope }; + const panel = new PanelModel({ isViewing: true }); + const dashboard = new DashboardModel({}); + + const menuItems = getPanelMenu(dashboard, panel, angularComponent); + expect(menuItems).toMatchInlineSnapshot(` + Array [ + Object { + "iconClassName": "eye", + "onClick": [Function], + "shortcut": "v", + "text": "View", + }, + Object { + "iconClassName": "edit", + "onClick": [Function], + "shortcut": "e", + "text": "Edit", + }, + Object { + "iconClassName": "share-alt", + "onClick": [Function], + "shortcut": "p s", + "text": "Share", + }, + Object { + "iconClassName": "compass", + "onClick": [Function], + "shortcut": "x", + "text": "Explore", + }, + Object { + "iconClassName": "info-circle", + "onClick": [Function], + "shortcut": "i", + "subMenu": Array [ + Object { + "onClick": [Function], + "text": "Panel JSON", + }, + ], + "text": "Inspect", + "type": "submenu", + }, + Object { + "iconClassName": "cube", + "onClick": [Function], + "subMenu": Array [ + Object { + "href": undefined, + "onClick": [Function], + "shortcut": "p l", + "text": "Toggle legend", + }, + ], + "text": "More...", + "type": "submenu", + }, + ] + `); + }); + }); + + describe('onNavigateToExplore', () => { + const testSubUrl = '/testSubUrl'; + const testUrl = '/testUrl'; + const windowOpen = jest.fn(); + let event: any; + let explore: PanelMenuItem; + let navigateSpy: any; + + beforeAll(() => { + const panel = new PanelModel({}); + const dashboard = new DashboardModel({}); + const menuItems = getPanelMenu(dashboard, panel); + explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; + navigateSpy = jest.spyOn(actions, 'navigateToExplore'); + window.open = windowOpen; + + event = { + ctrlKey: true, + preventDefault: jest.fn(), + }; + + setStore({ dispatch: jest.fn() } as any); + }); + + it('should navigate to url without subUrl', () => { + explore.onClick!(event); + + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + + openInNewWindow(testUrl); + + expect(windowOpen).toHaveBeenLastCalledWith(testUrl); + }); + + it('should navigate to url with subUrl', () => { + config.appSubUrl = testSubUrl; + explore.onClick!(event); + + const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; + + openInNewWindow(testUrl); + + expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); + }); + }); +}); diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts new file mode 100644 index 0000000..b1b0f9f --- /dev/null +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -0,0 +1,212 @@ +import { updateLocation } from 'app/core/actions'; +import { store } from 'app/store/store'; +import { AngularComponent, getDataSourceSrv, getLocationSrv } from '@grafana/runtime'; +import { PanelMenuItem } from '@grafana/data'; +import { copyPanel, duplicatePanel, removePanel, sharePanel } from 'app/features/dashboard/utils/panel'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { contextSrv } from '../../../core/services/context_srv'; +import { navigateToExplore } from '../../explore/state/main'; +import { getExploreUrl } from '../../../core/utils/explore'; +import { getTimeSrv } from '../services/TimeSrv'; +import { PanelCtrl } from '../../panel/panel_ctrl'; +import config from 'app/core/config'; + +export function getPanelMenu( + dashboard: DashboardModel, + panel: PanelModel, + angularComponent?: AngularComponent | null +): PanelMenuItem[] { + const onViewPanel = (event: React.MouseEvent) => { + event.preventDefault(); + store.dispatch( + updateLocation({ + query: { + viewPanel: panel.id, + }, + partial: true, + }) + ); + }; + + const onEditPanel = (event: React.MouseEvent) => { + event.preventDefault(); + store.dispatch( + updateLocation({ + query: { + editPanel: panel.id, + }, + partial: true, + }) + ); + }; + + const onSharePanel = (event: React.MouseEvent) => { + event.preventDefault(); + sharePanel(dashboard, panel); + }; + + const onInspectPanel = (tab?: string) => { + getLocationSrv().update({ + partial: true, + query: { + inspect: panel.id, + inspectTab: tab, + }, + }); + }; + + const onMore = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + const onDuplicatePanel = (event: React.MouseEvent) => { + event.preventDefault(); + duplicatePanel(dashboard, panel); + }; + + const onCopyPanel = (event: React.MouseEvent) => { + event.preventDefault(); + copyPanel(panel); + }; + + const onRemovePanel = (event: React.MouseEvent) => { + event.preventDefault(); + removePanel(dashboard, panel, true); + }; + + const onNavigateToExplore = (event: React.MouseEvent) => { + event.preventDefault(); + const openInNewWindow = + event.ctrlKey || event.metaKey ? (url: string) => window.open(`${config.appSubUrl}${url}`) : undefined; + store.dispatch(navigateToExplore(panel, { getDataSourceSrv, getTimeSrv, getExploreUrl, openInNewWindow }) as any); + }; + + const menu: PanelMenuItem[] = []; + + if (!panel.isEditing) { + menu.push({ + text: 'View', + iconClassName: 'eye', + onClick: onViewPanel, + shortcut: 'v', + }); + } + + if (dashboard.canEditPanel(panel) && !panel.isEditing) { + menu.push({ + text: 'Edit', + iconClassName: 'edit', + onClick: onEditPanel, + shortcut: 'e', + }); + } + + menu.push({ + text: 'Share', + iconClassName: 'share-alt', + onClick: onSharePanel, + shortcut: 'p s', + }); + + if (contextSrv.hasAccessToExplore() && !(panel.plugin && panel.plugin.meta.skipDataQuery)) { + menu.push({ + text: 'Explore', + iconClassName: 'compass', + shortcut: 'x', + onClick: onNavigateToExplore, + }); + } + + const inspectMenu: PanelMenuItem[] = []; + + // Only show these inspect actions for data plugins + if (panel.plugin && !panel.plugin.meta.skipDataQuery) { + inspectMenu.push({ + text: 'Data', + onClick: (e: React.MouseEvent) => onInspectPanel('data'), + }); + + if (dashboard.meta.canEdit) { + inspectMenu.push({ + text: 'Query', + onClick: (e: React.MouseEvent) => onInspectPanel('query'), + }); + } + } + + inspectMenu.push({ + text: 'Panel JSON', + onClick: (e: React.MouseEvent) => onInspectPanel('json'), + }); + + menu.push({ + type: 'submenu', + text: 'Inspect', + iconClassName: 'info-circle', + onClick: (e: React.MouseEvent) => onInspectPanel(), + shortcut: 'i', + subMenu: inspectMenu, + }); + + const subMenu: PanelMenuItem[] = []; + + if (dashboard.canEditPanel(panel) && !(panel.isViewing || panel.isEditing)) { + subMenu.push({ + text: 'Duplicate', + onClick: onDuplicatePanel, + shortcut: 'p d', + }); + + subMenu.push({ + text: 'Copy', + onClick: onCopyPanel, + }); + } + + // add old angular panel options + if (angularComponent) { + const scope = angularComponent.getScope(); + const panelCtrl: PanelCtrl = scope.$$childHead.ctrl; + const angularMenuItems = panelCtrl.getExtendedMenu(); + + for (const item of angularMenuItems) { + const reactItem: PanelMenuItem = { + text: item.text, + href: item.href, + shortcut: item.shortcut, + }; + + if (item.click) { + reactItem.onClick = () => { + scope.$eval(item.click, { ctrl: panelCtrl }); + }; + } + + subMenu.push(reactItem); + } + } + + if (!panel.isEditing && subMenu.length) { + menu.push({ + type: 'submenu', + text: 'More...', + iconClassName: 'cube', + subMenu, + onClick: onMore, + }); + } + + if (dashboard.canEditPanel(panel) && !panel.isEditing && !panel.isViewing) { + menu.push({ type: 'divider', text: '' }); + + menu.push({ + text: 'Remove', + iconClassName: 'trash-alt', + onClick: onRemovePanel, + shortcut: 'p r', + }); + } + + return menu; +} diff --git a/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts b/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts new file mode 100644 index 0000000..c63ab1b --- /dev/null +++ b/public/app/features/dashboard/utils/getRefreshFromUrl.test.ts @@ -0,0 +1,75 @@ +import { getRefreshFromUrl } from './getRefreshFromUrl'; + +describe('getRefreshFromUrl', () => { + describe('when refresh is not part of params', () => { + it('then it should return current refresh value', () => { + const params = {}; + const currentRefresh = false; + const minRefreshInterval = '5s'; + const isAllowedIntervalFn = () => false; + + const actual = getRefreshFromUrl({ + params, + currentRefresh, + minRefreshInterval, + isAllowedIntervalFn, + }); + + expect(actual).toBe(false); + }); + }); + + describe('when refresh is part of params', () => { + describe('and refresh is an existing and valid interval', () => { + it('then it should return the refresh value', () => { + const params = { refresh: '10s' }; + const currentRefresh = ''; + const minRefreshInterval = '5s'; + const isAllowedIntervalFn = () => true; + const refreshIntervals = ['5s', '10s', '30s']; + + const actual = getRefreshFromUrl({ + params, + currentRefresh, + minRefreshInterval, + isAllowedIntervalFn, + refreshIntervals, + }); + + expect(actual).toBe('10s'); + }); + }); + + it.each` + refresh | isAllowedInterval | minRefreshInterval | refreshIntervals | expected + ${'6s'} | ${true} | ${'1s'} | ${['5s', '6s', '10s', '30s']} | ${'6s'} + ${'6s'} | ${true} | ${'10s'} | ${['5s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${true} | ${'1s'} | ${['5s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${true} | ${'1s'} | ${undefined} | ${'5s'} + ${'6s'} | ${true} | ${'10s'} | ${undefined} | ${'10s'} + ${'6s'} | ${true} | ${'1s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${true} | ${'10s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${false} | ${'1s'} | ${['5s', '6s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${['5s', '6s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${['5s', '10s', '30s']} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${['5s', '10s', '30s']} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${undefined} | ${'5s'} + ${'6s'} | ${false} | ${'10s'} | ${undefined} | ${'10s'} + ${'6s'} | ${false} | ${'1s'} | ${[]} | ${'currentRefresh'} + ${'6s'} | ${false} | ${'10s'} | ${[]} | ${'currentRefresh'} + `( + 'when called with refresh:{$refresh}, isAllowedInterval:{$isAllowedInterval}, minRefreshInterval:{$minRefreshInterval}, refreshIntervals:{$refreshIntervals} then it should return: $expected', + ({ refresh, isAllowedInterval, minRefreshInterval, refreshIntervals, expected }) => { + const actual = getRefreshFromUrl({ + params: { refresh }, + currentRefresh: 'currentRefresh', + minRefreshInterval, + isAllowedIntervalFn: () => isAllowedInterval, + refreshIntervals, + }); + + expect(actual).toBe(expected); + } + ); + }); +}); diff --git a/public/app/features/dashboard/utils/getRefreshFromUrl.ts b/public/app/features/dashboard/utils/getRefreshFromUrl.ts new file mode 100644 index 0000000..a838313 --- /dev/null +++ b/public/app/features/dashboard/utils/getRefreshFromUrl.ts @@ -0,0 +1,39 @@ +import { defaultIntervals } from '@grafana/ui'; + +interface Args { + params: Record; + currentRefresh: string | boolean | undefined; + isAllowedIntervalFn: (interval: string) => boolean; + minRefreshInterval: string; + refreshIntervals?: string[]; +} + +// getRefreshFromUrl function returns the value from the supplied &refresh= param in url. +// If the supplied interval is not allowed or does not exist in the refresh intervals for the dashboard then we +// try to find the first refresh interval that matches the minRefreshInterval (min_refresh_interval in ini) +// or just take the first interval. +export function getRefreshFromUrl({ + params, + currentRefresh, + isAllowedIntervalFn, + minRefreshInterval, + refreshIntervals = defaultIntervals, +}: Args): string | boolean | undefined { + if (!params.refresh) { + return currentRefresh; + } + + const isAllowedInterval = isAllowedIntervalFn(params.refresh); + const isExistingInterval = refreshIntervals.find((interval) => interval === params.refresh); + + if (!isAllowedInterval || !isExistingInterval) { + const minRefreshIntervalInIntervals = minRefreshInterval + ? refreshIntervals.find((interval) => interval === minRefreshInterval) + : undefined; + const lowestRefreshInterval = refreshIntervals?.length ? refreshIntervals[0] : undefined; + + return minRefreshIntervalInIntervals ?? lowestRefreshInterval ?? currentRefresh; + } + + return params.refresh || currentRefresh; +} diff --git a/public/app/features/dashboard/utils/loadSnapshotData.ts b/public/app/features/dashboard/utils/loadSnapshotData.ts new file mode 100644 index 0000000..33e2650 --- /dev/null +++ b/public/app/features/dashboard/utils/loadSnapshotData.ts @@ -0,0 +1,24 @@ +import { applyFieldOverrides, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; +import { config } from 'app/core/config'; +import { DashboardModel, PanelModel } from '../state'; +import { getProcessedDataFrames } from '../../query/state/runRequest'; + +export function loadSnapshotData(panel: PanelModel, dashboard: DashboardModel): PanelData { + const data = getProcessedDataFrames(panel.snapshotData); + + return { + timeRange: getDefaultTimeRange(), + state: LoadingState.Done, + series: applyFieldOverrides({ + data, + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: panel.replaceVariables, + fieldConfigRegistry: panel.plugin!.fieldConfigRegistry, + theme: config.theme, + timeZone: dashboard.getTimezone(), + }), + }; +} diff --git a/public/app/features/dashboard/utils/panel.test.ts b/public/app/features/dashboard/utils/panel.test.ts new file mode 100644 index 0000000..9b57ffc --- /dev/null +++ b/public/app/features/dashboard/utils/panel.test.ts @@ -0,0 +1,93 @@ +import { dateTime, DateTime, PanelProps, TimeRange } from '@grafana/data'; +import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel'; +import { advanceTo, clear } from 'jest-date-mock'; +import { PanelModel } from '../state'; +import { getPanelPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { ComponentClass } from 'react'; + +const dashboardTimeRange: TimeRange = { + from: dateTime([2019, 1, 11, 12, 0]), + to: dateTime([2019, 1, 11, 18, 0]), + raw: { + from: 'now-6h', + to: 'now', + }, +}; + +describe('applyPanelTimeOverrides', () => { + const fakeCurrentDate = dateTime([2019, 1, 11, 14, 0, 0]).toDate(); + + beforeAll(() => { + advanceTo(fakeCurrentDate); + }); + + afterAll(() => { + clear(); + }); + + it('should apply relative time override', () => { + const panelModel = { + timeFrom: '2h', + }; + + // @ts-ignore: PanelModel type incositency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(dateTime([2019, 1, 11, 12]).toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(fakeCurrentDate.toISOString()); + expect(overrides.timeRange.raw.from).toBe('now-2h'); + expect(overrides.timeRange.raw.to).toBe('now'); + }); + + it('should apply time shift', () => { + const panelModel = { + timeShift: '2h', + }; + + const expectedFromDate = dateTime([2019, 1, 11, 10, 0, 0]).toDate(); + const expectedToDate = dateTime([2019, 1, 11, 16, 0, 0]).toDate(); + + // @ts-ignore: PanelModel type incositency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString()); + expect((overrides.timeRange.raw.from as DateTime).toISOString()).toEqual(expectedFromDate.toISOString()); + expect((overrides.timeRange.raw.to as DateTime).toISOString()).toEqual(expectedToDate.toISOString()); + }); + + it('should apply both relative time and time shift', () => { + const panelModel = { + timeFrom: '2h', + timeShift: '2h', + }; + + const expectedFromDate = dateTime([2019, 1, 11, 10, 0, 0]).toDate(); + const expectedToDate = dateTime([2019, 1, 11, 12, 0, 0]).toDate(); + + // @ts-ignore: PanelModel type incositency + const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange); + + expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString()); + expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString()); + expect((overrides.timeRange.raw.from as DateTime).toISOString()).toEqual(expectedFromDate.toISOString()); + expect((overrides.timeRange.raw.to as DateTime).toISOString()).toEqual(expectedToDate.toISOString()); + }); + + it('Calculate panel height', () => { + const panelModel = new PanelModel({}); + const height = calculateInnerPanelHeight(panelModel, 100); + + expect(height).toBe(82); + }); + + it('Calculate panel height with panel plugin zeroChromePadding', () => { + const panelModel = new PanelModel({}); + panelModel.pluginLoaded( + getPanelPlugin({ id: 'table' }, (null as unknown) as ComponentClass, null).setNoPadding() + ); + + const height = calculateInnerPanelHeight(panelModel, 100); + expect(height).toBe(98); + }); +}); diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts new file mode 100644 index 0000000..bed7a6d --- /dev/null +++ b/public/app/features/dashboard/utils/panel.ts @@ -0,0 +1,153 @@ +// Store +import store from 'app/core/store'; + +// Models +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { TimeRange, AppEvents, rangeUtil, dateMath } from '@grafana/data'; + +// Utils +import { isString as _isString } from 'lodash'; +import appEvents from 'app/core/app_events'; +import config from 'app/core/config'; + +// Services +import { getTemplateSrv } from '@grafana/runtime'; + +// Constants +import { LS_PANEL_COPY_KEY, PANEL_BORDER } from 'app/core/constants'; +import { CoreEvents } from 'app/types'; + +import { ShareModal } from 'app/features/dashboard/components/ShareModal'; + +export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => { + // confirm deletion + if (ask !== false) { + const text2 = panel.alert ? 'Panel includes an alert rule, removing panel will also remove alert rule' : undefined; + const confirmText = panel.alert ? 'YES' : undefined; + + appEvents.emit(CoreEvents.showConfirmModal, { + title: 'Remove Panel', + text: 'Are you sure you want to remove this panel?', + text2: text2, + icon: 'trash-alt', + confirmText: confirmText, + yesText: 'Remove', + onConfirm: () => removePanel(dashboard, panel, false), + }); + return; + } + + dashboard.removePanel(panel); +}; + +export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => { + dashboard.duplicatePanel(panel); +}; + +export const copyPanel = (panel: PanelModel) => { + let saveModel = panel; + if (panel instanceof PanelModel) { + saveModel = panel.getSaveModel(); + } + + store.set(LS_PANEL_COPY_KEY, JSON.stringify(saveModel)); + appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Open Add Panel to paste']); +}; + +export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => { + appEvents.emit(CoreEvents.showModalReact, { + component: ShareModal, + props: { + dashboard: dashboard, + panel: panel, + }, + }); +}; + +export const refreshPanel = (panel: PanelModel) => { + panel.refresh(); +}; + +export const toggleLegend = (panel: PanelModel) => { + console.warn('Toggle legend is not implemented yet'); + // We need to set panel.legend defaults first + // panel.legend.show = !panel.legend.show; + refreshPanel(panel); +}; + +export interface TimeOverrideResult { + timeRange: TimeRange; + timeInfo: string; +} + +export function applyPanelTimeOverrides(panel: PanelModel, timeRange: TimeRange): TimeOverrideResult { + const newTimeData = { + timeInfo: '', + timeRange: timeRange, + }; + + if (panel.timeFrom) { + const timeFromInterpolated = getTemplateSrv().replace(panel.timeFrom, panel.scopedVars); + const timeFromInfo = rangeUtil.describeTextRange(timeFromInterpolated); + if (timeFromInfo.invalid) { + newTimeData.timeInfo = 'invalid time override'; + return newTimeData; + } + + if (_isString(timeRange.raw.from)) { + const timeFromDate = dateMath.parse(timeFromInfo.from)!; + newTimeData.timeInfo = timeFromInfo.display; + newTimeData.timeRange = { + from: timeFromDate, + to: dateMath.parse(timeFromInfo.to)!, + raw: { + from: timeFromInfo.from, + to: timeFromInfo.to, + }, + }; + } + } + + if (panel.timeShift) { + const timeShiftInterpolated = getTemplateSrv().replace(panel.timeShift, panel.scopedVars); + const timeShiftInfo = rangeUtil.describeTextRange(timeShiftInterpolated); + if (timeShiftInfo.invalid) { + newTimeData.timeInfo = 'invalid timeshift'; + return newTimeData; + } + + const timeShift = '-' + timeShiftInterpolated; + newTimeData.timeInfo += ' timeshift ' + timeShift; + const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!; + const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!; + + newTimeData.timeRange = { + from, + to, + raw: { + from, + to, + }, + }; + } + + if (panel.hideTimeOverride) { + newTimeData.timeInfo = ''; + } + + return newTimeData; +} + +export function getResolution(panel: PanelModel): number { + const htmlEl = document.getElementsByTagName('html')[0]; + const width = htmlEl.getBoundingClientRect().width; // https://stackoverflow.com/a/21454625 + + return panel.maxDataPoints ? panel.maxDataPoints : Math.ceil(width * (panel.gridPos.w / 24)); +} + +export function calculateInnerPanelHeight(panel: PanelModel, containerHeight: number): number { + const chromePadding = panel.plugin && panel.plugin.noPadding ? 0 : config.theme.panelPadding * 2; + const headerHeight = panel.hasTitle() ? config.theme.panelHeaderHeight : 0; + return containerHeight - headerHeight - chromePadding - PANEL_BORDER; +} diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/DashboardsTable.test.tsx new file mode 100644 index 0000000..10a14f4 --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DashboardsTable, { Props } from './DashboardsTable'; +import { PluginDashboard } from '../../types'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboards: [] as PluginDashboard[], + onImport: jest.fn(), + onRemove: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render table', () => { + const wrapper = setup({ + dashboards: [ + { + dashboardId: 0, + description: '', + folderId: 0, + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + path: 'dashboards/carbon_metrics.json', + pluginId: 'graphite', + removed: false, + revision: 1, + slug: '', + title: 'Graphite Carbon Metrics', + }, + { + dashboardId: 0, + description: '', + folderId: 0, + imported: true, + importedRevision: 0, + importedUri: '', + importedUrl: '', + path: 'dashboards/carbon_metrics.json', + pluginId: 'graphite', + removed: false, + revision: 1, + slug: '', + title: 'Graphite Carbon Metrics', + }, + ], + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx new file mode 100644 index 0000000..09ab435 --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.tsx @@ -0,0 +1,56 @@ +import React, { FC } from 'react'; +import { PluginDashboard } from '../../types'; +import { Icon } from '@grafana/ui'; + +export interface Props { + dashboards: PluginDashboard[]; + onImport: (dashboard: PluginDashboard, overwrite: boolean) => void; + onRemove: (dashboard: PluginDashboard) => void; +} + +const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => { + function buttonText(dashboard: PluginDashboard) { + return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import'; + } + + return ( + + + {dashboards.map((dashboard, index) => { + return ( + + + + + + ); + })} + +
    + + + {dashboard.imported ? ( + {dashboard.title} + ) : ( + {dashboard.title} + )} + + {!dashboard.imported ? ( + + ) : ( + + )} + {dashboard.imported && ( + + )} +
    + ); +}; + +export default DashboardsTable; diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx new file mode 100644 index 0000000..c8770f5 --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceDashboards, Props } from './DataSourceDashboards'; +import { DataSourceSettings, NavModel } from '@grafana/data'; +import { PluginDashboard } from 'app/types'; + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + dashboards: [] as PluginDashboard[], + dataSource: {} as DataSourceSettings, + pageId: 1, + importDashboard: jest.fn(), + loadDataSource: jest.fn(), + loadPluginDashboards: jest.fn(), + removeDashboard: jest.fn(), + isLoading: false, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx new file mode 100644 index 0000000..1f71b8a --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -0,0 +1,101 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +// Components +import Page from 'app/core/components/Page/Page'; +import DashboardTable from './DashboardsTable'; + +// Actions & Selectors +import { getNavModel } from 'app/core/selectors/navModel'; +import { getRouteParamsId } from 'app/core/selectors/location'; +import { loadDataSource } from './state/actions'; +import { loadPluginDashboards } from '../plugins/state/actions'; +import { importDashboard, removeDashboard } from '../dashboard/state/actions'; +import { getDataSource } from './state/selectors'; + +// Types +import { PluginDashboard, StoreState } from 'app/types'; +import { DataSourceSettings, NavModel } from '@grafana/data'; + +export interface Props { + navModel: NavModel; + dashboards: PluginDashboard[]; + dataSource: DataSourceSettings; + pageId: number; + importDashboard: typeof importDashboard; + loadDataSource: typeof loadDataSource; + loadPluginDashboards: typeof loadPluginDashboards; + removeDashboard: typeof removeDashboard; + isLoading: boolean; +} + +export class DataSourceDashboards extends PureComponent { + async componentDidMount() { + const { loadDataSource, pageId } = this.props; + + await loadDataSource(pageId); + this.props.loadPluginDashboards(); + } + + onImport = (dashboard: PluginDashboard, overwrite: boolean) => { + const { dataSource, importDashboard } = this.props; + const data: any = { + pluginId: dashboard.pluginId, + path: dashboard.path, + overwrite, + inputs: [], + }; + + if (dataSource) { + data.inputs.push({ + name: '*', + type: 'datasource', + pluginId: dataSource.type, + value: dataSource.name, + }); + } + + importDashboard(data, dashboard.title); + }; + + onRemove = (dashboard: PluginDashboard) => { + this.props.removeDashboard(dashboard.importedUri); + }; + + render() { + const { dashboards, navModel, isLoading } = this.props; + return ( + + + this.onImport(dashboard, overwrite)} + onRemove={(dashboard) => this.onRemove(dashboard)} + /> + + + ); + } +} + +function mapStateToProps(state: StoreState) { + const pageId = getRouteParamsId(state.location); + return { + navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`), + pageId: pageId, + dashboards: state.plugins.dashboards, + dataSource: getDataSource(state.dataSources, pageId), + isLoading: state.plugins.isLoadingPluginDashboards, + }; +} + +const mapDispatchToProps = { + importDashboard, + loadDataSource, + loadPluginDashboards, + removeDashboard, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards)); diff --git a/public/app/features/datasources/DataSourceList.test.tsx b/public/app/features/datasources/DataSourceList.test.tsx new file mode 100644 index 0000000..0cbe041 --- /dev/null +++ b/public/app/features/datasources/DataSourceList.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DataSourcesList from './DataSourcesList'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; + +const setup = () => { + const props = { + dataSources: getMockDataSources(3), + layoutMode: LayoutModes.Grid, + }; + + return render(); +}; + +describe('DataSourcesList', () => { + it('should render list of datasources', () => { + setup(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getAllByRole('heading')).toHaveLength(3); + }); + + it('should render all elements in the list item', () => { + setup(); + expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'dataSource-0 dataSource-0' })).toBeInTheDocument(); + expect(screen.getByAltText('dataSource-0')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx new file mode 100644 index 0000000..6899120 --- /dev/null +++ b/public/app/features/datasources/DataSourcesList.tsx @@ -0,0 +1,50 @@ +// Libraries +import React, { FC } from 'react'; + +// Types +import { DataSourceSettings } from '@grafana/data'; +import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import { Card, Tag, useStyles } from '@grafana/ui'; +import { css } from 'emotion'; + +export interface Props { + dataSources: DataSourceSettings[]; + layoutMode: LayoutMode; +} + +export const DataSourcesList: FC = ({ dataSources, layoutMode }) => { + const styles = useStyles(getStyles); + + return ( +
      + {dataSources.map((dataSource, index) => { + return ( +
    • + + + {dataSource.name} + + + {[ + dataSource.typeName, + dataSource.url, + dataSource.isDefault && , + ]} + + +
    • + ); + })} +
    + ); +}; + +export default DataSourcesList; + +const getStyles = () => { + return { + list: css` + list-style: none; + `, + }; +}; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx new file mode 100644 index 0000000..e6735cd --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceSettings, NavModel } from '@grafana/data'; + +import { DataSourcesListPage, Props } from './DataSourcesListPage'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dataSources: [] as DataSourceSettings[], + layoutMode: LayoutModes.Grid, + loadDataSources: jest.fn(), + navModel: { + main: { + text: 'Configuration', + }, + node: { + text: 'Data Sources', + }, + } as NavModel, + dataSourcesCount: 0, + searchQuery: '', + setDataSourcesSearchQuery, + setDataSourcesLayoutMode, + hasFetched: false, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render action bar and datasources', () => { + const wrapper = setup({ + dataSources: getMockDataSources(5), + dataSourcesCount: 5, + hasFetched: true, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx new file mode 100644 index 0000000..4784bdf --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -0,0 +1,110 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +// Components +import Page from 'app/core/components/Page/Page'; +import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import DataSourcesList from './DataSourcesList'; +// Types +import { DataSourceSettings, NavModel } from '@grafana/data'; +import { IconName } from '@grafana/ui'; +import { StoreState } from 'app/types'; +import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector'; +// Actions +import { loadDataSources } from './state/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; + +import { + getDataSources, + getDataSourcesCount, + getDataSourcesLayoutMode, + getDataSourcesSearchQuery, +} from './state/selectors'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/reducers'; + +export interface Props { + navModel: NavModel; + dataSources: DataSourceSettings[]; + dataSourcesCount: number; + layoutMode: LayoutMode; + searchQuery: string; + hasFetched: boolean; + loadDataSources: typeof loadDataSources; + setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode; + setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery; +} + +const emptyListModel = { + title: 'There are no data sources defined yet', + buttonIcon: 'database' as IconName, + buttonLink: 'datasources/new', + buttonTitle: 'Add data source', + proTip: 'You can also define data sources through configuration files.', + proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list', + proTipLinkTitle: 'Learn more', + proTipTarget: '_blank', +}; + +export class DataSourcesListPage extends PureComponent { + componentDidMount() { + this.props.loadDataSources(); + } + + render() { + const { + dataSources, + dataSourcesCount, + navModel, + layoutMode, + searchQuery, + setDataSourcesSearchQuery, + hasFetched, + } = this.props; + + const linkButton = { + href: 'datasources/new', + title: 'Add data source', + }; + + return ( + + + <> + {hasFetched && dataSourcesCount === 0 && } + {hasFetched && + dataSourcesCount > 0 && [ + setDataSourcesSearchQuery(query)} + linkButton={linkButton} + key="action-bar" + />, + , + ]} + + + + ); + } +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'datasources'), + dataSources: getDataSources(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + dataSourcesCount: getDataSourcesCount(state.dataSources), + searchQuery: getDataSourcesSearchQuery(state.dataSources), + hasFetched: state.dataSources.hasFetched, + }; +} + +const mapDispatchToProps = { + loadDataSources, + setDataSourcesSearchQuery, + setDataSourcesLayoutMode, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage)); diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx new file mode 100644 index 0000000..041dbbb --- /dev/null +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -0,0 +1,198 @@ +import React, { FC, PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { DataSourcePluginMeta, NavModel } from '@grafana/data'; +import { Button, LinkButton, List } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +import Page from 'app/core/components/Page/Page'; +import { DataSourcePluginCategory, StoreState } from 'app/types'; +import { addDataSource, loadDataSourcePlugins } from './state/actions'; +import { getDataSourcePlugins } from './state/selectors'; +import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { setDataSourceTypeSearchQuery } from './state/reducers'; +import { PluginSignatureBadge } from '../plugins/PluginSignatureBadge'; +import { Card } from 'app/core/components/Card/Card'; +import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo'; + +export interface Props { + navModel: NavModel; + plugins: DataSourcePluginMeta[]; + categories: DataSourcePluginCategory[]; + isLoading: boolean; + addDataSource: typeof addDataSource; + loadDataSourcePlugins: typeof loadDataSourcePlugins; + searchQuery: string; + setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery; +} + +class NewDataSourcePage extends PureComponent { + componentDidMount() { + this.props.loadDataSourcePlugins(); + } + + onDataSourceTypeClicked = (plugin: DataSourcePluginMeta) => { + this.props.addDataSource(plugin); + }; + + onSearchQueryChange = (value: string) => { + this.props.setDataSourceTypeSearchQuery(value); + }; + + renderPlugins(plugins: DataSourcePluginMeta[]) { + if (!plugins || !plugins.length) { + return null; + } + + return ( + item.id.toString()} + renderItem={(item) => ( + this.onDataSourceTypeClicked(item)} + onLearnMoreClick={this.onLearnMoreClick} + /> + )} + /> + ); + } + + onLearnMoreClick = (evt: React.SyntheticEvent) => { + evt.stopPropagation(); + }; + + renderCategories() { + const { categories } = this.props; + + return ( + <> + {categories.map((category) => ( +
    +
    {category.title}
    + {this.renderPlugins(category.plugins)} +
    + ))} +
    + + Find more data source plugins on grafana.com + +
    + + ); + } + + render() { + const { navModel, isLoading, searchQuery, plugins } = this.props; + + return ( + + +
    + +
    + Cancel +
    + {!searchQuery && ( + + <> +
    +

    + Note that unsigned front-end datasource plugins are still usable, but this is subject + to change in the upcoming releases of Grafana +

    + +
    + )} +
    + {searchQuery && this.renderPlugins(plugins)} + {!searchQuery && this.renderCategories()} +
    + + + ); + } +} + +interface DataSourceTypeCardProps { + plugin: DataSourcePluginMeta; + onClick: () => void; + onLearnMoreClick: (evt: React.SyntheticEvent) => void; +} + +const DataSourceTypeCard: FC = (props) => { + const { plugin, onLearnMoreClick } = props; + const isPhantom = plugin.module === 'phantom'; + const onClick = !isPhantom && !plugin.unlicensed ? props.onClick : () => {}; + // find first plugin info link + const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null; + + return ( + + {learnMoreLink && ( + + {learnMoreLink.name} + + )} + {!isPhantom && } + + } + labels={!isPhantom && } + className={isPhantom ? 'add-data-source-item--phantom' : ''} + onClick={onClick} + aria-label={selectors.pages.AddDataSource.dataSourcePlugins(plugin.name)} + /> + ); +}; + +export function getNavModel(): NavModel { + const main = { + icon: 'database', + id: 'datasource-new', + text: 'Add data source', + href: 'datasources/new', + subTitle: 'Choose a data source type', + }; + + return { + main: main, + node: main, + }; +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(), + plugins: getDataSourcePlugins(state.dataSources), + searchQuery: state.dataSources.dataSourceTypeSearchQuery, + categories: state.dataSources.categories, + isLoading: state.dataSources.isLoadingDataSources, + }; +} + +const mapDispatchToProps = { + addDataSource, + loadDataSourcePlugins, + setDataSourceTypeSearchQuery, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(NewDataSourcePage)); diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts new file mode 100644 index 0000000..cc3eaa3 --- /dev/null +++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -0,0 +1,50 @@ +import { DataSourceSettings } from '@grafana/data'; + +export const getMockDataSources = (amount: number) => { + const dataSources = []; + + for (let i = 0; i < amount; i++) { + dataSources.push({ + access: '', + basicAuth: false, + database: `database-${i}`, + id: i, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: `dataSource-${i}`, + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + }); + } + + return dataSources as DataSourceSettings[]; +}; + +export const getMockDataSource = (): DataSourceSettings => { + return { + access: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + database: '', + id: 13, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'gdev-cloudwatch', + typeName: 'Cloudwatch', + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + secureJsonFields: {}, + }; +}; diff --git a/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap new file mode 100644 index 0000000..b2327bf --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + +
    +`; + +exports[`Render should render table 1`] = ` + + + + + + + + + + + + + +
    + + + + Graphite Carbon Metrics + + + +
    + + + + Graphite Carbon Metrics + + + + +
    +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap new file mode 100644 index 0000000..28b20de --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + + +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap new file mode 100644 index 0000000..df1135c --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap @@ -0,0 +1,154 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render action bar and datasources 1`] = ` + + + + + + +`; + +exports[`Render should render component 1`] = ` + + + +`; diff --git a/public/app/features/datasources/mocks.ts b/public/app/features/datasources/mocks.ts new file mode 100644 index 0000000..bc4f9d0 --- /dev/null +++ b/public/app/features/datasources/mocks.ts @@ -0,0 +1,25 @@ +import { DataSourceSettings } from '@grafana/data'; + +export function createDatasourceSettings(jsonData: T): DataSourceSettings { + return { + id: 0, + orgId: 0, + name: 'datasource-test', + typeLogoUrl: '', + type: 'datasource', + typeName: 'Datasource', + access: 'server', + url: 'http://localhost', + password: '', + user: '', + database: '', + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + isDefault: false, + jsonData, + readOnly: false, + withCredentials: false, + secureJsonFields: {}, + }; +} diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html new file mode 100644 index 0000000..e69de29 diff --git a/public/app/features/datasources/partials/http_settings_next.html b/public/app/features/datasources/partials/http_settings_next.html new file mode 100644 index 0000000..57df2b5 --- /dev/null +++ b/public/app/features/datasources/partials/http_settings_next.html @@ -0,0 +1 @@ + diff --git a/public/app/features/datasources/partials/tls_auth_settings.html b/public/app/features/datasources/partials/tls_auth_settings.html new file mode 100644 index 0000000..f15193b --- /dev/null +++ b/public/app/features/datasources/partials/tls_auth_settings.html @@ -0,0 +1,62 @@ +
    +
    +
    TLS/SSL Auth Details
    + TLS/SSL certificates are encrypted and stored in the Grafana database. +
    +
    +
    +
    +
    + +
    + +
    + + reset +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + + reset +
    +
    + +
    +
    +
    + +
    +
    + + reset +
    +
    +
    +
    diff --git a/public/app/features/datasources/settings/BasicSettings.test.tsx b/public/app/features/datasources/settings/BasicSettings.test.tsx new file mode 100644 index 0000000..0194adc --- /dev/null +++ b/public/app/features/datasources/settings/BasicSettings.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import BasicSettings, { Props } from './BasicSettings'; + +const setup = () => { + const props: Props = { + dataSourceName: 'Graphite', + isDefault: false, + onDefaultChange: jest.fn(), + onNameChange: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/settings/BasicSettings.tsx new file mode 100644 index 0000000..5816563 --- /dev/null +++ b/public/app/features/datasources/settings/BasicSettings.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { InlineFormLabel, LegacyForms } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; + +const { Input, Switch } = LegacyForms; + +export interface Props { + dataSourceName: string; + isDefault: boolean; + onNameChange: (name: string) => void; + onDefaultChange: (value: boolean) => void; +} + +const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => { + return ( +
    +
    +
    + + Name + + onNameChange(event.target.value)} + required + aria-label={selectors.pages.DataSource.name} + /> +
    + { + // @ts-ignore + onDefaultChange(event.target.checked); + }} + /> +
    +
    + ); +}; + +export default BasicSettings; diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/settings/ButtonRow.test.tsx new file mode 100644 index 0000000..84b16d8 --- /dev/null +++ b/public/app/features/datasources/settings/ButtonRow.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ButtonRow, { Props } from './ButtonRow'; + +const setup = (propOverrides?: object) => { + const props: Props = { + isReadOnly: true, + onSubmit: jest.fn(), + onDelete: jest.fn(), + onTest: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render with buttons enabled', () => { + const wrapper = setup({ + isReadOnly: false, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx new file mode 100644 index 0000000..2a9a6df --- /dev/null +++ b/public/app/features/datasources/settings/ButtonRow.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { selectors } from '@grafana/e2e-selectors'; + +import config from 'app/core/config'; + +export interface Props { + isReadOnly: boolean; + onDelete: () => void; + onSubmit: (event: any) => void; + onTest: (event: any) => void; +} + +const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest }) => { + return ( +
    + {!isReadOnly && ( + + )} + {isReadOnly && ( + + )} + + + Back + +
    + ); +}; + +export default ButtonRow; diff --git a/public/app/features/datasources/settings/CloudInfoBox.tsx b/public/app/features/datasources/settings/CloudInfoBox.tsx new file mode 100644 index 0000000..28ee473 --- /dev/null +++ b/public/app/features/datasources/settings/CloudInfoBox.tsx @@ -0,0 +1,92 @@ +import { DataSourceSettings, GrafanaTheme } from '@grafana/data'; +import { FeatureInfoBox, useStyles } from '@grafana/ui'; +import { css } from 'emotion'; +import React, { FC } from 'react'; +import { config } from 'app/core/config'; +import { GrafanaEdition } from '@grafana/data/src/types/config'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'datasources.settings.cloudInfoBox.isDismissed'; + +export interface Props { + dataSource: DataSourceSettings; +} + +export const CloudInfoBox: FC = ({ dataSource }) => { + const styles = useStyles(getStyles); + let mainDS = ''; + let extraDS = ''; + + // don't show for already configured data sources or provisioned data sources + if (dataSource.readOnly || (dataSource.version ?? 0) > 2) { + return null; + } + + // Skip showing this info box in some editions + if (config.buildInfo.edition !== GrafanaEdition.OpenSource) { + return null; + } + + switch (dataSource.type) { + case 'prometheus': + mainDS = 'Prometheus'; + extraDS = 'Loki'; + break; + case 'loki': + mainDS = 'Loki'; + extraDS = 'Prometheus'; + break; + default: + return null; + } + + return ( + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + return ( + { + onDismiss(true); + }} + > +
    + Or skip the effort and get {mainDS} (and {extraDS}) as fully managed, scalable and hosted data sources + from Grafana Labs with the{' '} + + free-forever Grafana Cloud plan + + . +
    +
    + ); + }} + + ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + box: css` + margin: 0 0 ${theme.spacing.lg} 0; + `, + text: css` + color: ${theme.colors.textSemiWeak}; + padding: ${theme.spacing.sm} 0; + a { + text-decoration: underline; + } + `, + }; +}; diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx new file mode 100644 index 0000000..064f536 --- /dev/null +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage'; +import { DataSourceConstructor, DataSourcePlugin, DataSourceSettings, NavModel } from '@grafana/data'; +import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; +import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; + +const pluginMock = new DataSourcePlugin({} as DataSourceConstructor); + +jest.mock('app/features/plugins/plugin_loader', () => { + return { + importDataSourcePlugin: () => Promise.resolve(pluginMock), + }; +}); + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + dataSource: getMockDataSource(), + dataSourceMeta: getMockPlugin(), + pageId: 1, + deleteDataSource: jest.fn(), + loadDataSource: jest.fn(), + setDataSourceName, + updateDataSource: jest.fn(), + initDataSourceSettings: jest.fn(), + testDataSource: jest.fn(), + setIsDefault, + dataSourceLoaded, + query: {}, + ...propOverrides, + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render loader', () => { + const wrapper = setup({ + dataSource: {} as DataSourceSettings, + plugin: pluginMock, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render beta info text', () => { + const wrapper = setup({ + dataSourceMeta: { ...getMockPlugin(), state: 'beta' }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render alpha info text', () => { + const wrapper = setup({ + dataSourceMeta: { ...getMockPlugin(), state: 'alpha' }, + plugin: pluginMock, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render is ready only message', () => { + const wrapper = setup({ + dataSource: { ...getMockDataSource(), readOnly: true }, + plugin: pluginMock, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx new file mode 100644 index 0000000..5857f33 --- /dev/null +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -0,0 +1,281 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import isString from 'lodash/isString'; +// Components +import Page from 'app/core/components/Page/Page'; +import { GenericDataSourcePlugin, PluginSettings } from './PluginSettings'; +import BasicSettings from './BasicSettings'; +import ButtonRow from './ButtonRow'; +// Services & Utils +import appEvents from 'app/core/app_events'; +// Actions & selectors +import { getDataSource, getDataSourceMeta } from '../state/selectors'; +import { + deleteDataSource, + initDataSourceSettings, + loadDataSource, + testDataSource, + updateDataSource, +} from '../state/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getRouteParamsId } from 'app/core/selectors/location'; +// Types +import { CoreEvents, StoreState } from 'app/types/'; +import { DataSourcePluginMeta, DataSourceSettings, NavModel, UrlQueryMap } from '@grafana/data'; +import { Alert, InfoBox } from '@grafana/ui'; +import { getDataSourceLoadingNav } from '../state/navModel'; +import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; +import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; +import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp'; +import { selectors } from '@grafana/e2e-selectors'; +import { CloudInfoBox } from './CloudInfoBox'; + +export interface Props { + navModel: NavModel; + dataSource: DataSourceSettings; + dataSourceMeta: DataSourcePluginMeta; + pageId: number; + deleteDataSource: typeof deleteDataSource; + loadDataSource: typeof loadDataSource; + setDataSourceName: typeof setDataSourceName; + updateDataSource: typeof updateDataSource; + setIsDefault: typeof setIsDefault; + dataSourceLoaded: typeof dataSourceLoaded; + initDataSourceSettings: typeof initDataSourceSettings; + testDataSource: typeof testDataSource; + plugin?: GenericDataSourcePlugin; + query: UrlQueryMap; + page?: string; + testingStatus?: { + message?: string; + status?: string; + }; + loadError?: Error | string; +} + +export class DataSourceSettingsPage extends PureComponent { + componentDidMount() { + const { initDataSourceSettings, pageId } = this.props; + initDataSourceSettings(pageId); + } + + onSubmit = async (evt: React.FormEvent) => { + evt.preventDefault(); + + await this.props.updateDataSource({ ...this.props.dataSource }); + + this.testDataSource(); + }; + + onTest = async (evt: React.FormEvent) => { + evt.preventDefault(); + + this.testDataSource(); + }; + + onDelete = () => { + appEvents.emit(CoreEvents.showConfirmModal, { + title: 'Delete', + text: 'Are you sure you want to delete this data source?', + yesText: 'Delete', + icon: 'trash-alt', + onConfirm: () => { + this.confirmDelete(); + }, + }); + }; + + confirmDelete = () => { + this.props.deleteDataSource(); + }; + + onModelChange = (dataSource: DataSourceSettings) => { + this.props.dataSourceLoaded(dataSource); + }; + + isReadOnly() { + return this.props.dataSource.readOnly === true; + } + + renderIsReadOnlyMessage() { + return ( + + This datasource was added by config and cannot be modified using the UI. Please contact your server admin to + update this datasource. + + ); + } + + testDataSource() { + const { dataSource, testDataSource } = this.props; + testDataSource(dataSource.name); + } + + get hasDataSource() { + return this.props.dataSource.id > 0; + } + + renderLoadError(loadError: any) { + let showDelete = false; + let msg = loadError.toString(); + if (loadError.data) { + if (loadError.data.message) { + msg = loadError.data.message; + } + } else if (isString(loadError)) { + showDelete = true; + } + + const node = { + text: msg, + subTitle: 'Data Source Error', + icon: 'exclamation-triangle', + }; + const nav = { + node: node, + main: node, + }; + + return ( + + +
    +
    + {showDelete && ( + + )} + + Back + +
    +
    +
    +
    + ); + } + + renderConfigPageBody(page: string) { + const { plugin } = this.props; + if (!plugin || !plugin.configPages) { + return null; // still loading + } + + for (const p of plugin.configPages) { + if (p.id === page) { + return ; + } + } + + return
    Page Not Found: {page}
    ; + } + + renderSettings() { + const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; + + return ( +
    + {this.isReadOnly() && this.renderIsReadOnlyMessage()} + {dataSourceMeta.state && ( +
    + + +
    + )} + + + + setIsDefault(state)} + onNameChange={(name) => setDataSourceName(name)} + /> + + {plugin && ( + + )} + +
    + {testingStatus && testingStatus.message && ( + + )} +
    + + this.onSubmit(event)} + isReadOnly={this.isReadOnly()} + onDelete={this.onDelete} + onTest={(event) => this.onTest(event)} + /> + + ); + } + + render() { + const { navModel, page, loadError } = this.props; + + if (loadError) { + return this.renderLoadError(loadError); + } + + return ( + + + {this.hasDataSource ?
    {page ? this.renderConfigPageBody(page) : this.renderSettings()}
    : null} +
    +
    + ); + } +} + +function mapStateToProps(state: StoreState) { + const pageId = getRouteParamsId(state.location); + const dataSource = getDataSource(state.dataSources, pageId); + const page = state.location.query.page as string; + const { plugin, loadError, testingStatus } = state.dataSourceSettings; + + return { + navModel: getNavModel( + state.navIndex, + page ? `datasource-page-${page}` : `datasource-settings-${pageId}`, + getDataSourceLoadingNav('settings') + ), + dataSource: getDataSource(state.dataSources, pageId), + dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type), + pageId: pageId, + query: state.location.query, + page, + plugin, + loadError, + testingStatus, + }; +} + +const mapDispatchToProps = { + deleteDataSource, + loadDataSource, + setDataSourceName, + updateDataSource, + setIsDefault, + dataSourceLoaded, + initDataSourceSettings, + testDataSource, +}; + +export default hot(module)( + connectWithCleanUp(mapStateToProps, mapDispatchToProps, (state) => state.dataSourceSettings)(DataSourceSettingsPage) +); diff --git a/public/app/features/datasources/settings/HttpSettingsCtrl.ts b/public/app/features/datasources/settings/HttpSettingsCtrl.ts new file mode 100644 index 0000000..59b664a --- /dev/null +++ b/public/app/features/datasources/settings/HttpSettingsCtrl.ts @@ -0,0 +1,21 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceHttpSettings', () => { + return { + scope: { + current: '=', + suggestUrl: '@', + noDirectAccess: '@', + }, + templateUrl: 'public/app/features/datasources/partials/http_settings_next.html', + link: { + pre: ($scope: any) => { + // do not show access option if direct access is disabled + $scope.showAccessOption = $scope.noDirectAccess !== 'true'; + $scope.onChange = (datasourceSetting: any) => { + $scope.current = datasourceSetting; + }; + }, + }, + }; +}); diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/settings/PluginSettings.tsx new file mode 100644 index 0000000..9e4b84e --- /dev/null +++ b/public/app/features/datasources/settings/PluginSettings.tsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import _ from 'lodash'; +import { + DataSourceSettings, + DataSourcePlugin, + DataSourcePluginMeta, + DataSourceApi, + DataQuery, + DataSourceJsonData, +} from '@grafana/data'; +import { getAngularLoader, AngularComponent } from '@grafana/runtime'; + +export type GenericDataSourcePlugin = DataSourcePlugin>; + +export interface Props { + plugin: GenericDataSourcePlugin; + dataSource: DataSourceSettings; + dataSourceMeta: DataSourcePluginMeta; + onModelChange: (dataSource: DataSourceSettings) => void; +} + +export class PluginSettings extends PureComponent { + element: any; + component: AngularComponent; + scopeProps: { + ctrl: { datasourceMeta: DataSourcePluginMeta; current: DataSourceSettings }; + onModelChanged: (dataSource: DataSourceSettings) => void; + }; + + constructor(props: Props) { + super(props); + + this.scopeProps = { + ctrl: { datasourceMeta: props.dataSourceMeta, current: _.cloneDeep(props.dataSource) }, + onModelChanged: this.onModelChanged, + }; + this.onModelChanged = this.onModelChanged.bind(this); + } + + componentDidMount() { + const { plugin } = this.props; + + if (!this.element) { + return; + } + + if (!plugin.components.ConfigEditor) { + // React editor is not specified, let's render angular editor + // How to apprach this better? Introduce ReactDataSourcePlugin interface and typeguard it here? + const loader = getAngularLoader(); + const template = ''; + + this.component = loader.load(this.element, this.scopeProps, template); + } + } + + componentDidUpdate(prevProps: Props) { + const { plugin } = this.props; + if (!plugin.components.ConfigEditor && this.props.dataSource !== prevProps.dataSource) { + this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource); + + this.component.digest(); + } + } + + componentWillUnmount() { + if (this.component) { + this.component.destroy(); + } + } + + onModelChanged = (dataSource: DataSourceSettings) => { + this.props.onModelChange(dataSource); + }; + + render() { + const { plugin, dataSource } = this.props; + + if (!plugin) { + return null; + } + + return ( +
    (this.element = element)}> + {plugin.components.ConfigEditor && + React.createElement(plugin.components.ConfigEditor, { + options: dataSource, + onOptionsChange: this.onModelChanged, + })} +
    + ); + } +} + +export default PluginSettings; diff --git a/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts new file mode 100644 index 0000000..7c21fab --- /dev/null +++ b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts @@ -0,0 +1,10 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceTlsAuthSettings', () => { + return { + scope: { + current: '=', + }, + templateUrl: 'public/app/features/datasources/partials/tls_auth_settings.html', + }; +}); diff --git a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap new file mode 100644 index 0000000..e9ee2a6 --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    +
    +
    + + Name + + +
    + +
    +
    +`; diff --git a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap new file mode 100644 index 0000000..94823ff --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + + + + Back + +
    +`; + +exports[`Render should render with buttons enabled 1`] = ` +
    + + + + Back + +
    +`; diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap new file mode 100644 index 0000000..01fdf2d --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap @@ -0,0 +1,439 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render alpha info text 1`] = ` + + +
    +
    +
    + + +
    + + + +
    + + +
    + + +`; + +exports[`Render should render beta info text 1`] = ` + + +
    +
    +
    + + +
    + + +
    + + +
    + + +`; + +exports[`Render should render component 1`] = ` + + +
    +
    + + +
    + + +
    + + +`; + +exports[`Render should render is ready only message 1`] = ` + + +
    +
    + + This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource. + + + + +
    + + +
    + + +`; + +exports[`Render should render loader 1`] = ` + + + +`; diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts new file mode 100644 index 0000000..03decdc --- /dev/null +++ b/public/app/features/datasources/state/actions.test.ts @@ -0,0 +1,200 @@ +import { + findNewName, + nameExits, + InitDataSourceSettingDependencies, + testDataSource, + TestDataSourceDependencies, +} from './actions'; +import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; +import { thunkTester } from 'test/core/thunk/thunkTester'; +import { + initDataSourceSettingsSucceeded, + initDataSourceSettingsFailed, + testDataSourceStarting, + testDataSourceSucceeded, + testDataSourceFailed, +} from './reducers'; +import { initDataSourceSettings } from '../state/actions'; +import { ThunkResult, ThunkDispatch } from 'app/types'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +const getBackendSrvMock = () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockReturnValue({ + status: '', + message: '', + }), + }), + withNoBackendCache: jest.fn().mockImplementationOnce((cb) => cb()), + } as any); + +describe('Name exists', () => { + const plugins = getMockPlugins(5); + + it('should be true', () => { + const name = 'pretty cool plugin-1'; + + expect(nameExits(plugins, name)).toEqual(true); + }); + + it('should be false', () => { + const name = 'pretty cool plugin-6'; + + expect(nameExits(plugins, name)); + }); +}); + +describe('Find new name', () => { + it('should create a new name', () => { + const plugins = getMockPlugins(5); + const name = 'pretty cool plugin-1'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6'); + }); + + it('should create new name without suffix', () => { + const plugin = getMockPlugin(); + plugin.name = 'prometheus'; + const plugins = [plugin]; + const name = 'prometheus'; + + expect(findNewName(plugins, name)).toEqual('prometheus-1'); + }); + + it('should handle names that end with -', () => { + const plugin = getMockPlugin(); + const plugins = [plugin]; + const name = 'pretty cool plugin-'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-'); + }); +}); + +describe('initDataSourceSettings', () => { + describe('when pageId is not a number', () => { + it('then initDataSourceSettingsFailed should be dispatched', async () => { + const dispatchedActions = await thunkTester({}) + .givenThunk(initDataSourceSettings) + .whenThunkIsDispatched('some page'); + + expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]); + }); + }); + + describe('when pageId is a number', () => { + it('then initDataSourceSettingsSucceeded should be dispatched', async () => { + const thunkMock = (): ThunkResult => (dispatch: ThunkDispatch, getState) => {}; + const dataSource = { type: 'app' }; + const dataSourceMeta = { id: 'some id' }; + const dependencies: InitDataSourceSettingDependencies = { + loadDataSource: jest.fn(thunkMock), + getDataSource: jest.fn().mockReturnValue(dataSource), + getDataSourceMeta: jest.fn().mockReturnValue(dataSourceMeta), + importDataSourcePlugin: jest.fn().mockReturnValue({} as GenericDataSourcePlugin), + }; + const state = { + dataSourceSettings: {}, + dataSources: {}, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(initDataSourceSettings) + .whenThunkIsDispatched(256, dependencies); + + expect(dispatchedActions).toEqual([initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin)]); + expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.loadDataSource).toHaveBeenCalledWith(256); + + expect(dependencies.getDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.getDataSource).toHaveBeenCalledWith({}, 256); + + expect(dependencies.getDataSourceMeta).toHaveBeenCalledTimes(1); + expect(dependencies.getDataSourceMeta).toHaveBeenCalledWith({}, 'app'); + + expect(dependencies.importDataSourcePlugin).toHaveBeenCalledTimes(1); + expect(dependencies.importDataSourcePlugin).toHaveBeenCalledWith(dataSourceMeta); + }); + }); + + describe('when plugin loading fails', () => { + it('then initDataSourceSettingsFailed should be dispatched', async () => { + const dependencies: InitDataSourceSettingDependencies = { + loadDataSource: jest.fn().mockImplementation(() => { + throw new Error('Error loading plugin'); + }), + getDataSource: jest.fn(), + getDataSourceMeta: jest.fn(), + importDataSourcePlugin: jest.fn(), + }; + const state = { + dataSourceSettings: {}, + dataSources: {}, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(initDataSourceSettings) + .whenThunkIsDispatched(301, dependencies); + + expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Error loading plugin'))]); + expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1); + expect(dependencies.loadDataSource).toHaveBeenCalledWith(301); + }); + }); +}); + +describe('testDataSource', () => { + describe('when a datasource is tested', () => { + it('then testDataSourceStarting and testDataSourceSucceeded should be dispatched', async () => { + const dependencies: TestDataSourceDependencies = { + getDatasourceSrv: () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockReturnValue({ + status: '', + message: '', + }), + }), + } as any), + getBackendSrv: getBackendSrvMock, + }; + const state = { + testingStatus: { + status: '', + message: '', + }, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(testDataSource) + .whenThunkIsDispatched('Azure Monitor', dependencies); + + expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceSucceeded(state.testingStatus)]); + }); + + it('then testDataSourceFailed should be dispatched', async () => { + const dependencies: TestDataSourceDependencies = { + getDatasourceSrv: () => + ({ + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn().mockImplementation(() => { + throw new Error('Error testing datasource'); + }), + }), + } as any), + getBackendSrv: getBackendSrvMock, + }; + const result = { + message: 'Error testing datasource', + }; + const state = { + testingStatus: { + message: '', + status: '', + }, + }; + const dispatchedActions = await thunkTester(state) + .givenThunk(testDataSource) + .whenThunkIsDispatched('Azure Monitor', dependencies); + + expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]); + }); + }); +}); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts new file mode 100644 index 0000000..926377b --- /dev/null +++ b/public/app/features/datasources/state/actions.ts @@ -0,0 +1,235 @@ +import config from '../../../core/config'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { updateLocation, updateNavIndex } from 'app/core/actions'; +import { buildNavModel } from './navModel'; +import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; +import { DataSourcePluginCategory, ThunkResult, ThunkDispatch } from 'app/types'; +import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache'; +import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; +import { + dataSourceLoaded, + dataSourceMetaLoaded, + dataSourcePluginsLoad, + dataSourcePluginsLoaded, + dataSourcesLoaded, + initDataSourceSettingsFailed, + initDataSourceSettingsSucceeded, + testDataSourceStarting, + testDataSourceSucceeded, + testDataSourceFailed, +} from './reducers'; +import { buildCategories } from './buildCategories'; +import { getDataSource, getDataSourceMeta } from './selectors'; +import { getDataSourceSrv } from '@grafana/runtime'; + +export interface DataSourceTypesLoadedPayload { + plugins: DataSourcePluginMeta[]; + categories: DataSourcePluginCategory[]; +} + +export interface InitDataSourceSettingDependencies { + loadDataSource: typeof loadDataSource; + getDataSource: typeof getDataSource; + getDataSourceMeta: typeof getDataSourceMeta; + importDataSourcePlugin: typeof importDataSourcePlugin; +} + +export interface TestDataSourceDependencies { + getDatasourceSrv: typeof getDataSourceSrv; + getBackendSrv: typeof getBackendSrv; +} + +export const initDataSourceSettings = ( + pageId: number, + dependencies: InitDataSourceSettingDependencies = { + loadDataSource, + getDataSource, + getDataSourceMeta, + importDataSourcePlugin, + } +): ThunkResult => { + return async (dispatch: ThunkDispatch, getState) => { + if (isNaN(pageId)) { + dispatch(initDataSourceSettingsFailed(new Error('Invalid ID'))); + return; + } + + try { + await dispatch(dependencies.loadDataSource(pageId)); + if (getState().dataSourceSettings.plugin) { + return; + } + + const dataSource = dependencies.getDataSource(getState().dataSources, pageId); + const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource!.type); + const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta); + + dispatch(initDataSourceSettingsSucceeded(importedPlugin)); + } catch (err) { + console.error('Failed to import plugin module', err); + dispatch(initDataSourceSettingsFailed(err)); + } + }; +}; + +export const testDataSource = ( + dataSourceName: string, + dependencies: TestDataSourceDependencies = { + getDatasourceSrv, + getBackendSrv, + } +): ThunkResult => { + return async (dispatch: ThunkDispatch, getState) => { + const dsApi = await dependencies.getDatasourceSrv().get(dataSourceName); + + if (!dsApi.testDatasource) { + return; + } + + dispatch(testDataSourceStarting()); + + dependencies.getBackendSrv().withNoBackendCache(async () => { + try { + const result = await dsApi.testDatasource(); + + dispatch(testDataSourceSucceeded(result)); + } catch (err) { + let message = ''; + + if (err.statusText) { + message = 'HTTP Error ' + err.statusText; + } else { + message = err.message; + } + + dispatch(testDataSourceFailed({ message })); + } + }); + }; +}; + +export function loadDataSources(): ThunkResult { + return async (dispatch) => { + const response = await getBackendSrv().get('/api/datasources'); + dispatch(dataSourcesLoaded(response)); + }; +} + +export function loadDataSource(id: number): ThunkResult { + return async (dispatch) => { + const dataSource = (await getBackendSrv().get(`/api/datasources/${id}`)) as DataSourceSettings; + const pluginInfo = (await getPluginSettings(dataSource.type)) as DataSourcePluginMeta; + const plugin = await importDataSourcePlugin(pluginInfo); + + dispatch(dataSourceLoaded(dataSource)); + dispatch(dataSourceMetaLoaded(pluginInfo)); + dispatch(updateNavIndex(buildNavModel(dataSource, plugin))); + }; +} + +export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult { + return async (dispatch, getStore) => { + await dispatch(loadDataSources()); + + const dataSources = getStore().dataSources.dataSources; + + const newInstance = { + name: plugin.name, + type: plugin.id, + access: 'proxy', + isDefault: dataSources.length === 0, + }; + + if (nameExits(dataSources, newInstance.name)) { + newInstance.name = findNewName(dataSources, newInstance.name); + } + + const result = await getBackendSrv().post('/api/datasources', newInstance); + dispatch(updateLocation({ path: `/datasources/edit/${result.id}` })); + }; +} + +export function loadDataSourcePlugins(): ThunkResult { + return async (dispatch) => { + dispatch(dataSourcePluginsLoad()); + const plugins = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' }); + const categories = buildCategories(plugins); + dispatch(dataSourcePluginsLoaded({ plugins, categories })); + }; +} + +export function updateDataSource(dataSource: DataSourceSettings): ThunkResult { + return async (dispatch) => { + await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); + await updateFrontendSettings(); + return dispatch(loadDataSource(dataSource.id)); + }; +} + +export function deleteDataSource(): ThunkResult { + return async (dispatch, getStore) => { + const dataSource = getStore().dataSources.dataSource; + await getBackendSrv().delete(`/api/datasources/${dataSource.id}`); + await updateFrontendSettings(); + dispatch(updateLocation({ path: '/datasources' })); + }; +} + +interface ItemWithName { + name: string; +} + +export function nameExits(dataSources: ItemWithName[], name: string) { + return ( + dataSources.filter((dataSource) => { + return dataSource.name.toLowerCase() === name.toLowerCase(); + }).length > 0 + ); +} + +export function findNewName(dataSources: ItemWithName[], name: string) { + // Need to loop through current data sources to make sure + // the name doesn't exist + while (nameExits(dataSources, name)) { + // If there's a duplicate name that doesn't end with '-x' + // we can add -1 to the name and be done. + if (!nameHasSuffix(name)) { + name = `${name}-1`; + } else { + // if there's a duplicate name that ends with '-x' + // we can try to increment the last digit until the name is unique + + // remove the 'x' part and replace it with the new number + name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`; + } + } + + return name; +} + +function updateFrontendSettings() { + return getBackendSrv() + .get('/api/frontend/settings') + .then((settings: any) => { + config.datasources = settings.datasources; + config.defaultDatasource = settings.defaultDatasource; + getDatasourceSrv().init(config.datasources, settings.defaultDatasource); + }); +} + +function nameHasSuffix(name: string) { + return name.endsWith('-', name.length - 1); +} + +function getLastDigit(name: string) { + return parseInt(name.slice(-1), 10); +} + +function incrementLastDigit(digit: number) { + return isNaN(digit) ? 1 : digit + 1; +} + +function getNewName(name: string) { + return name.slice(0, name.length - 1); +} diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts new file mode 100644 index 0000000..f2c8fcf --- /dev/null +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -0,0 +1,56 @@ +import { buildCategories } from './buildCategories'; +import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { DataSourcePluginMeta } from '@grafana/data'; + +const plugins: DataSourcePluginMeta[] = [ + { + ...getMockPlugin({ id: 'graphite' }), + category: 'tsdb', + }, + { + ...getMockPlugin({ id: 'prometheus' }), + category: 'tsdb', + }, + { + ...getMockPlugin({ id: 'elasticsearch' }), + category: 'logging', + }, + { + ...getMockPlugin({ id: 'loki' }), + category: 'logging', + }, + { + ...getMockPlugin({ id: 'azure' }), + category: 'cloud', + }, +]; + +describe('buildCategories', () => { + const categories = buildCategories(plugins); + + it('should group plugins into categories and remove empty categories', () => { + expect(categories.length).toBe(4); + expect(categories[0].title).toBe('Time series databases'); + expect(categories[0].plugins.length).toBe(2); + expect(categories[1].title).toBe('Logging & document databases'); + }); + + it('should sort plugins according to hard coded sorting rules', () => { + expect(categories[1].plugins[0].id).toBe('loki'); + }); + + it('should add phantom plugin for Grafana cloud', () => { + expect(categories[2].title).toBe('Cloud'); + expect(categories[2].plugins.length).toBe(2); + expect(categories[2].plugins[1].id).toBe('gcloud'); + }); + + it('should set module to phantom on phantom plugins', () => { + expect(categories[3].plugins[0].module).toBe('phantom'); + }); + + it('should add enterprise phantom plugins', () => { + expect(categories[3].title).toBe('Enterprise plugins'); + expect(categories[3].plugins.length).toBe(10); + }); +}); diff --git a/public/app/features/datasources/state/buildCategories.ts b/public/app/features/datasources/state/buildCategories.ts new file mode 100644 index 0000000..9f1b6e9 --- /dev/null +++ b/public/app/features/datasources/state/buildCategories.ts @@ -0,0 +1,215 @@ +import { DataSourcePluginMeta, PluginType } from '@grafana/data'; +import { DataSourcePluginCategory } from 'app/types'; +import { config } from '../../../core/config'; + +export function buildCategories(plugins: DataSourcePluginMeta[]): DataSourcePluginCategory[] { + const categories: DataSourcePluginCategory[] = [ + { id: 'tsdb', title: 'Time series databases', plugins: [] }, + { id: 'logging', title: 'Logging & document databases', plugins: [] }, + { id: 'tracing', title: 'Distributed tracing', plugins: [] }, + { id: 'sql', title: 'SQL', plugins: [] }, + { id: 'cloud', title: 'Cloud', plugins: [] }, + { id: 'enterprise', title: 'Enterprise plugins', plugins: [] }, + { id: 'iot', title: 'Industrial & IoT', plugins: [] }, + { id: 'other', title: 'Others', plugins: [] }, + ].filter((item) => item); + + const categoryIndex: Record = {}; + const pluginIndex: Record = {}; + const enterprisePlugins = getEnterprisePhantomPlugins(); + + // build indices + for (const category of categories) { + categoryIndex[category.id] = category; + } + + const { edition, hasValidLicense } = config.licenseInfo; + + for (const plugin of plugins) { + const enterprisePlugin = enterprisePlugins.find((item) => item.id === plugin.id); + // Force category for enterprise plugins + if (plugin.enterprise || enterprisePlugin) { + plugin.category = 'enterprise'; + plugin.unlicensed = edition !== 'Open Source' && !hasValidLicense; + plugin.info.links = enterprisePlugin?.info?.links || plugin.info.links; + } + + // Fix link name + if (plugin.info.links) { + for (const link of plugin.info.links) { + link.name = 'Learn more'; + } + } + + const category = categories.find((item) => item.id === plugin.category) || categoryIndex['other']; + category.plugins.push(plugin); + // add to plugin index + pluginIndex[plugin.id] = plugin; + } + + for (const category of categories) { + // add phantom plugin + if (category.id === 'cloud') { + category.plugins.push(getGrafanaCloudPhantomPlugin()); + } + + // add phantom plugins + if (category.id === 'enterprise') { + for (const plugin of enterprisePlugins) { + if (!pluginIndex[plugin.id]) { + category.plugins.push(plugin); + } + } + } + + sortPlugins(category.plugins); + } + + // Only show categories with plugins + return categories.filter((c) => c.plugins.length > 0); +} + +function sortPlugins(plugins: DataSourcePluginMeta[]) { + const sortingRules: { [id: string]: number } = { + prometheus: 100, + graphite: 95, + loki: 90, + mysql: 80, + jaeger: 100, + postgres: 79, + gcloud: -1, + }; + + plugins.sort((a, b) => { + const aSort = sortingRules[a.id] || 0; + const bSort = sortingRules[b.id] || 0; + if (aSort > bSort) { + return -1; + } + if (aSort < bSort) { + return 1; + } + + return a.name > b.name ? -1 : 1; + }); +} + +function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] { + return [ + getPhantomPlugin({ + id: 'grafana-splunk-datasource', + name: 'Splunk', + description: 'Visualize & explore Splunk logs', + imgUrl: 'public/img/plugins/splunk_logo_128.png', + }), + getPhantomPlugin({ + id: 'grafana-oracle-datasource', + name: 'Oracle', + description: 'Visualize & explore Oracle SQL', + imgUrl: 'public/img/plugins/oracle.png', + }), + getPhantomPlugin({ + id: 'grafana-dynatrace-datasource', + name: 'Dynatrace', + description: 'Visualize & explore Dynatrace data', + imgUrl: 'public/img/plugins/dynatrace.png', + }), + getPhantomPlugin({ + id: 'grafana-servicenow-datasource', + description: 'ServiceNow integration & data source', + name: 'ServiceNow', + imgUrl: 'public/img/plugins/servicenow.svg', + }), + getPhantomPlugin({ + id: 'grafana-datadog-datasource', + description: 'DataDog integration & data source', + name: 'DataDog', + imgUrl: 'public/img/plugins/datadog.png', + }), + getPhantomPlugin({ + id: 'grafana-newrelic-datasource', + description: 'New Relic integration & data source', + name: 'New Relic', + imgUrl: 'public/img/plugins/newrelic.svg', + }), + getPhantomPlugin({ + id: 'grafana-mongodb-datasource', + description: 'MongoDB integration & data source', + name: 'MongoDB', + imgUrl: 'public/img/plugins/mongodb.svg', + }), + getPhantomPlugin({ + id: 'grafana-snowflake-datasource', + description: 'Snowflake integration & data source', + name: 'Snowflake', + imgUrl: 'public/img/plugins/snowflake.svg', + }), + getPhantomPlugin({ + id: 'grafana-wavefront-datasource', + description: 'Wavefront integration & data source', + name: 'Wavefront', + imgUrl: 'public/img/plugins/wavefront.svg', + }), + getPhantomPlugin({ + id: 'dlopes7-appdynamics-datasource', + description: 'AppDynamics integration & data source', + name: 'AppDynamics', + imgUrl: 'public/img/plugins/appdynamics.svg', + }), + ]; +} + +function getGrafanaCloudPhantomPlugin(): DataSourcePluginMeta { + return { + id: 'gcloud', + name: 'Grafana Cloud', + type: PluginType.datasource, + module: 'phantom', + baseUrl: '', + info: { + description: 'Hosted Graphite, Prometheus and Loki', + logos: { small: 'public/img/grafana_icon.svg', large: 'asd' }, + author: { name: 'Grafana Labs' }, + links: [ + { + url: 'https://grafana.com/products/cloud/', + name: 'Learn more', + }, + ], + screenshots: [], + updated: '2019-05-10', + version: '1.0.0', + }, + }; +} + +interface GetPhantomPluginOptions { + id: string; + name: string; + description: string; + imgUrl: string; +} + +function getPhantomPlugin(options: GetPhantomPluginOptions): DataSourcePluginMeta { + return { + id: options.id, + name: options.name, + type: PluginType.datasource, + module: 'phantom', + baseUrl: '', + info: { + description: options.description, + logos: { small: options.imgUrl, large: options.imgUrl }, + author: { name: 'Grafana Labs' }, + links: [ + { + url: config.marketplaceUrl + options.id, + name: 'Install now', + }, + ], + screenshots: [], + updated: '2019-05-10', + version: '1.0.0', + }, + }; +} diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts new file mode 100644 index 0000000..479d7a6 --- /dev/null +++ b/public/app/features/datasources/state/navModel.ts @@ -0,0 +1,142 @@ +import { DataSourceSettings, PluginType, PluginInclude, NavModel, NavModelItem } from '@grafana/data'; +import config from 'app/core/config'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDataSourcePlugin): NavModelItem { + const pluginMeta = plugin.meta; + + const navModel: NavModelItem = { + img: pluginMeta.info.logos.large, + id: 'datasource-' + dataSource.id, + subTitle: `Type: ${pluginMeta.name}`, + url: '', + text: dataSource.name, + breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }], + children: [ + { + active: false, + icon: 'sliders-v-alt', + id: `datasource-settings-${dataSource.id}`, + text: 'Settings', + url: `datasources/edit/${dataSource.id}/`, + }, + ], + }; + + if (plugin.configPages) { + for (const page of plugin.configPages) { + navModel.children!.push({ + active: false, + text: page.title, + icon: page.icon, + url: `datasources/edit/${dataSource.id}/?page=${page.id}`, + id: `datasource-page-${page.id}`, + }); + } + } + + if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) { + navModel.children!.push({ + active: false, + icon: 'apps', + id: `datasource-dashboards-${dataSource.id}`, + text: 'Dashboards', + url: `datasources/edit/${dataSource.id}/dashboards`, + }); + } + + if (config.licenseInfo.hasLicense) { + navModel.children!.push({ + active: false, + icon: 'lock', + id: `datasource-permissions-${dataSource.id}`, + text: 'Permissions', + url: `datasources/edit/${dataSource.id}/permissions`, + }); + + navModel.children!.push({ + active: false, + icon: 'info-circle', + id: `datasource-insights-${dataSource.id}`, + text: 'Insights', + url: `datasources/edit/${dataSource.id}/insights`, + }); + } + + return navModel; +} + +export function getDataSourceLoadingNav(pageName: string): NavModel { + const main = buildNavModel( + { + access: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + database: '', + id: 1, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'Loading', + orgId: 1, + password: '', + readOnly: false, + type: 'Loading', + typeName: 'Loading', + typeLogoUrl: 'public/img/icn-datasource.svg', + url: '', + user: '', + secureJsonFields: {}, + }, + { + meta: { + id: '1', + type: PluginType.datasource, + name: '', + info: { + author: { + name: '', + url: '', + }, + description: '', + links: [{ name: '', url: '' }], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + includes: [], + module: '', + baseUrl: '', + }, + } as any + ); + + let node: NavModelItem; + + // find active page + for (const child of main.children!) { + if (child.id!.indexOf(pageName) > 0) { + child.active = true; + node = child; + break; + } + } + + return { + main: main, + node: node!, + }; +} + +function hasDashboards(includes: PluginInclude[]): boolean { + return ( + includes.find((include) => { + return include.type === 'dashboard'; + }) !== undefined + ); +} diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts new file mode 100644 index 0000000..3f8094a --- /dev/null +++ b/public/app/features/datasources/state/reducers.test.ts @@ -0,0 +1,174 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { + dataSourceLoaded, + dataSourceMetaLoaded, + dataSourcePluginsLoad, + dataSourcePluginsLoaded, + dataSourceSettingsReducer, + dataSourcesLoaded, + dataSourcesReducer, + initDataSourceSettingsFailed, + initDataSourceSettingsSucceeded, + initialDataSourceSettingsState, + initialState, + setDataSourceName, + setDataSourcesLayoutMode, + setDataSourcesSearchQuery, + setDataSourceTypeSearchQuery, + setIsDefault, +} from './reducers'; +import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks'; +import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; +import { DataSourceSettingsState, DataSourcesState } from 'app/types'; +import { PluginMeta, PluginMetaInfo, PluginType } from '@grafana/data'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +const mockPlugin = () => + ({ + defaultNavUrl: 'defaultNavUrl', + enabled: true, + hasUpdate: true, + id: 'id', + info: {} as PluginMetaInfo, + latestVersion: 'latestVersion', + name: 'name', + pinned: true, + type: PluginType.datasource, + module: 'path/to/module', + } as PluginMeta); + +describe('dataSourcesReducer', () => { + describe('when dataSourcesLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSources = getMockDataSources(1); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourcesLoaded(dataSources)) + .thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 }); + }); + }); + + describe('when dataSourceLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSource = getMockDataSource(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceLoaded(dataSource)) + .thenStateShouldEqual({ ...initialState, dataSource }); + }); + }); + + describe('when setDataSourcesSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesSearchQuery('some query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'some query' }); + }); + }); + + describe('when setDataSourcesLayoutMode is dispatched', () => { + it('then state should be correct', () => { + const layoutMode: LayoutModes = LayoutModes.Grid; + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode)) + .thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid }); + }); + }); + + describe('when dataSourcePluginsLoad is dispatched', () => { + it('then state should be correct', () => { + const state: DataSourcesState = { ...initialState, plugins: [mockPlugin()] }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourcePluginsLoad()) + .thenStateShouldEqual({ ...initialState, isLoadingDataSources: true }); + }); + }); + + describe('when dataSourcePluginsLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceTypes = [mockPlugin()]; + const state: DataSourcesState = { ...initialState, isLoadingDataSources: true }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourcePluginsLoaded({ plugins: dataSourceTypes, categories: [] })) + .thenStateShouldEqual({ ...initialState, plugins: dataSourceTypes, isLoadingDataSources: false }); + }); + }); + + describe('when setDataSourceTypeSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query')) + .thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' }); + }); + }); + + describe('when dataSourceMetaLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceMeta = mockPlugin(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta)) + .thenStateShouldEqual({ ...initialState, dataSourceMeta }); + }); + }); + + describe('when setDataSourceName is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceName('some name')) + .thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } } as DataSourcesState); + }); + }); + + describe('when setIsDefault is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setIsDefault(true)) + .thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } } as DataSourcesState); + }); + }); +}); + +describe('dataSourceSettingsReducer', () => { + describe('when initDataSourceSettingsSucceeded is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourceSettingsReducer, { ...initialDataSourceSettingsState }) + .whenActionIsDispatched(initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin)) + .thenStateShouldEqual({ + ...initialDataSourceSettingsState, + plugin: {} as GenericDataSourcePlugin, + loadError: null, + }); + }); + }); + + describe('when initDataSourceSettingsFailed is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourceSettingsReducer, { + ...initialDataSourceSettingsState, + plugin: {} as GenericDataSourcePlugin, + }) + .whenActionIsDispatched(initDataSourceSettingsFailed(new Error('Some error'))) + .thenStatePredicateShouldEqual((resultingState) => { + expect(resultingState.plugin).toEqual(null); + expect(resultingState.loadError).toEqual('Some error'); + return true; + }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts new file mode 100644 index 0000000..191f6f8 --- /dev/null +++ b/public/app/features/datasources/state/reducers.ts @@ -0,0 +1,170 @@ +import { AnyAction, createAction } from '@reduxjs/toolkit'; +import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; + +import { DataSourcesState, DataSourceSettingsState } from 'app/types'; +import { LayoutMode, LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; +import { DataSourceTypesLoadedPayload } from './actions'; +import { GenericDataSourcePlugin } from '../settings/PluginSettings'; + +export const initialState: DataSourcesState = { + dataSources: [], + plugins: [], + categories: [], + dataSource: {} as DataSourceSettings, + layoutMode: LayoutModes.List, + searchQuery: '', + dataSourcesCount: 0, + dataSourceTypeSearchQuery: '', + hasFetched: false, + isLoadingDataSources: false, + dataSourceMeta: {} as DataSourcePluginMeta, +}; + +export const dataSourceLoaded = createAction('dataSources/dataSourceLoaded'); +export const dataSourcesLoaded = createAction('dataSources/dataSourcesLoaded'); +export const dataSourceMetaLoaded = createAction('dataSources/dataSourceMetaLoaded'); +export const dataSourcePluginsLoad = createAction('dataSources/dataSourcePluginsLoad'); +export const dataSourcePluginsLoaded = createAction( + 'dataSources/dataSourcePluginsLoaded' +); +export const setDataSourcesSearchQuery = createAction('dataSources/setDataSourcesSearchQuery'); +export const setDataSourcesLayoutMode = createAction('dataSources/setDataSourcesLayoutMode'); +export const setDataSourceTypeSearchQuery = createAction('dataSources/setDataSourceTypeSearchQuery'); +export const setDataSourceName = createAction('dataSources/setDataSourceName'); +export const setIsDefault = createAction('dataSources/setIsDefault'); + +// Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. +// ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice +// because the state would become frozen and during run time we would get errors because Angular would try to mutate +// the frozen state. +// https://github.com/reduxjs/redux-toolkit/issues/242 +export const dataSourcesReducer = (state: DataSourcesState = initialState, action: AnyAction): DataSourcesState => { + if (dataSourcesLoaded.match(action)) { + return { + ...state, + hasFetched: true, + dataSources: action.payload, + dataSourcesCount: action.payload.length, + }; + } + + if (dataSourceLoaded.match(action)) { + return { ...state, dataSource: action.payload }; + } + + if (setDataSourcesSearchQuery.match(action)) { + return { ...state, searchQuery: action.payload }; + } + + if (setDataSourcesLayoutMode.match(action)) { + return { ...state, layoutMode: action.payload }; + } + + if (dataSourcePluginsLoad.match(action)) { + return { ...state, plugins: [], isLoadingDataSources: true }; + } + + if (dataSourcePluginsLoaded.match(action)) { + return { + ...state, + plugins: action.payload.plugins, + categories: action.payload.categories, + isLoadingDataSources: false, + }; + } + + if (setDataSourceTypeSearchQuery.match(action)) { + return { ...state, dataSourceTypeSearchQuery: action.payload }; + } + + if (dataSourceMetaLoaded.match(action)) { + return { ...state, dataSourceMeta: action.payload }; + } + + if (setDataSourceName.match(action)) { + return { ...state, dataSource: { ...state.dataSource, name: action.payload } }; + } + + if (setIsDefault.match(action)) { + return { + ...state, + dataSource: { ...state.dataSource, isDefault: action.payload }, + }; + } + + return state; +}; + +export const initialDataSourceSettingsState: DataSourceSettingsState = { + testingStatus: { + status: null, + message: null, + }, + loadError: null, + plugin: null, +}; + +export const initDataSourceSettingsSucceeded = createAction( + 'dataSourceSettings/initDataSourceSettingsSucceeded' +); + +export const initDataSourceSettingsFailed = createAction('dataSourceSettings/initDataSourceSettingsFailed'); + +export const testDataSourceStarting = createAction('dataSourceSettings/testDataSourceStarting'); + +export const testDataSourceSucceeded = createAction<{ + status: string; + message: string; +}>('dataSourceSettings/testDataSourceSucceeded'); + +export const testDataSourceFailed = createAction<{ message: string }>('dataSourceSettings/testDataSourceFailed'); + +export const dataSourceSettingsReducer = ( + state: DataSourceSettingsState = initialDataSourceSettingsState, + action: AnyAction +): DataSourceSettingsState => { + if (initDataSourceSettingsSucceeded.match(action)) { + return { ...state, plugin: action.payload, loadError: null }; + } + + if (initDataSourceSettingsFailed.match(action)) { + return { ...state, plugin: null, loadError: action.payload.message }; + } + + if (testDataSourceStarting.match(action)) { + return { + ...state, + testingStatus: { + message: 'Testing...', + status: 'info', + }, + }; + } + + if (testDataSourceSucceeded.match(action)) { + return { + ...state, + testingStatus: { + status: action.payload.status, + message: action.payload.message, + }, + }; + } + + if (testDataSourceFailed.match(action)) { + return { + ...state, + testingStatus: { + status: 'error', + message: action.payload.message, + }, + }; + } + + return state; +}; + +export default { + dataSources: dataSourcesReducer, + dataSourceSettings: dataSourceSettingsReducer, +}; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts new file mode 100644 index 0000000..d8f7165 --- /dev/null +++ b/public/app/features/datasources/state/selectors.ts @@ -0,0 +1,37 @@ +import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data'; +import { DataSourcesState } from '../../../types/datasources'; + +export const getDataSources = (state: DataSourcesState) => { + const regex = new RegExp(state.searchQuery, 'i'); + + return state.dataSources.filter((dataSource: DataSourceSettings) => { + return regex.test(dataSource.name) || regex.test(dataSource.database) || regex.test(dataSource.type); + }); +}; + +export const getDataSourcePlugins = (state: DataSourcesState) => { + const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i'); + + return state.plugins.filter((type: DataSourcePluginMeta) => { + return regex.test(type.name); + }); +}; + +export const getDataSource = (state: DataSourcesState, dataSourceId: UrlQueryValue): DataSourceSettings => { + if (state.dataSource.id === parseInt(dataSourceId as string, 10)) { + return state.dataSource; + } + return {} as DataSourceSettings; +}; + +export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSourcePluginMeta => { + if (state.dataSourceMeta.id === type) { + return state.dataSourceMeta; + } + + return {} as DataSourcePluginMeta; +}; + +export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery; +export const getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode; +export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount; diff --git a/public/app/features/datasources/utils/passwordHandlers.test.ts b/public/app/features/datasources/utils/passwordHandlers.test.ts new file mode 100644 index 0000000..bb55b95 --- /dev/null +++ b/public/app/features/datasources/utils/passwordHandlers.test.ts @@ -0,0 +1,32 @@ +import { createResetHandler, PasswordFieldEnum, Ctrl } from './passwordHandlers'; +describe('createResetHandler', () => { + Object.values(PasswordFieldEnum).forEach((field) => { + it(`should reset existing ${field} field`, () => { + const event: any = { + preventDefault: () => {}, + }; + const ctrl: Ctrl = { + current: { + [field]: 'set', + secureJsonData: { + [field]: 'set', + }, + secureJsonFields: {}, + }, + }; + + createResetHandler(ctrl, field)(event); + expect(ctrl).toEqual({ + current: { + [field]: undefined, + secureJsonData: { + [field]: '', + }, + secureJsonFields: { + [field]: false, + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/datasources/utils/passwordHandlers.ts b/public/app/features/datasources/utils/passwordHandlers.ts new file mode 100644 index 0000000..9d46f21 --- /dev/null +++ b/public/app/features/datasources/utils/passwordHandlers.ts @@ -0,0 +1,45 @@ +/** + * Set of handlers for secure password field in Angular components. They handle backward compatibility with + * passwords stored in plain text fields. + */ + +import { SyntheticEvent } from 'react'; + +export enum PasswordFieldEnum { + Password = 'password', + BasicAuthPassword = 'basicAuthPassword', +} + +/** + * Basic shape for settings controllers in at the moment mostly angular datasource plugins. + */ +export type Ctrl = { + current: { + secureJsonFields: { + [key: string]: boolean; + }; + secureJsonData?: { + [key: string]: string; + }; + password?: string; + basicAuthPassword?: string; + }; +}; + +export const createResetHandler = (ctrl: Ctrl, field: PasswordFieldEnum) => ( + event: SyntheticEvent +) => { + event.preventDefault(); + // Reset also normal plain text password to remove it and only save it in secureJsonData. + ctrl.current[field] = undefined; + ctrl.current.secureJsonFields[field] = false; + ctrl.current.secureJsonData = ctrl.current.secureJsonData || {}; + ctrl.current.secureJsonData[field] = ''; +}; + +export const createChangeHandler = (ctrl: any, field: PasswordFieldEnum) => ( + event: SyntheticEvent +) => { + ctrl.current.secureJsonData = ctrl.current.secureJsonData || {}; + ctrl.current.secureJsonData[field] = event.currentTarget.value; +}; diff --git a/public/app/features/explore/AdHocFilter.tsx b/public/app/features/explore/AdHocFilter.tsx new file mode 100644 index 0000000..79435bb --- /dev/null +++ b/public/app/features/explore/AdHocFilter.tsx @@ -0,0 +1,90 @@ +import React, { useContext } from 'react'; +import { LegacyForms, ThemeContext } from '@grafana/ui'; +const { Select } = LegacyForms; +import { css, cx } from 'emotion'; +import { GrafanaTheme, SelectableValue } from '@grafana/data'; + +const getStyles = (theme: GrafanaTheme) => ({ + keyValueContainer: css` + label: key-value-container; + display: flex; + flex-flow: row nowrap; + `, +}); + +enum ChangeType { + Key = 'key', + Value = 'value', + Operator = 'operator', +} + +export interface Props { + keys: string[]; + keysPlaceHolder?: string; + initialKey?: string; + initialOperator?: string; + initialValue?: string; + values?: string[]; + valuesPlaceHolder?: string; + onKeyChanged: (key: string) => void; + onValueChanged: (value: string) => void; + onOperatorChanged: (operator: string) => void; +} + +export const AdHocFilter: React.FunctionComponent = (props) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + const onChange = (changeType: ChangeType) => (item: SelectableValue) => { + const { onKeyChanged, onValueChanged, onOperatorChanged } = props; + + if (!item.value) { + return; + } + + switch (changeType) { + case ChangeType.Key: + onKeyChanged(item.value); + break; + case ChangeType.Operator: + onOperatorChanged(item.value); + break; + case ChangeType.Value: + onValueChanged(item.value); + break; + } + }; + + const stringToOption = (value: string) => ({ label: value, value: value }); + + const { keys, initialKey, keysPlaceHolder, initialOperator, values, initialValue, valuesPlaceHolder } = props; + const operators = ['=', '!=']; + const keysAsOptions = keys ? keys.map(stringToOption) : []; + const selectedKey = initialKey ? keysAsOptions.filter((option) => option.value === initialKey) : undefined; + const valuesAsOptions = values ? values.map(stringToOption) : []; + const selectedValue = initialValue ? valuesAsOptions.filter((option) => option.value === initialValue) : undefined; + const operatorsAsOptions = operators.map(stringToOption); + const selectedOperator = initialOperator + ? operatorsAsOptions.filter((option) => option.value === initialOperator) + : undefined; + + return ( +
    + + +
    +
    + + {{valError}} +
    +
    +
    +
    + +
    +
    Uploaded Key Details
    + +
    + Project + +
    +
    + Client Email + +
    +
    + Token URI + +
    +
    + Private Key + +
    + +
    + Reset Service Account Key + + + Reset to clear the uploaded key and upload a new file. + +
    +
    + +

    + Do not forget to save your changes after uploading a file. +

    + +
    +
    {{ctrl.gceError}}
    +
    + +

    + Verify GCE default service account by clicking Save & Test +

    diff --git a/public/app/plugins/datasource/cloud-monitoring/plugin.json b/public/app/plugins/datasource/cloud-monitoring/plugin.json new file mode 100644 index 0000000..cf43e37 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/plugin.json @@ -0,0 +1,122 @@ +{ + "name": "Google Cloud Monitoring", + "type": "datasource", + "id": "stackdriver", + "category": "cloud", + + "metrics": true, + "alerting": true, + "annotations": true, + "includes": [ + { + "type": "dashboard", + "name": "Data Processing Monitoring", + "path": "dashboards/dataprocessing-monitoring.json" + }, + { + "type": "dashboard", + "name": "Cloud Functions Monitoring", + "path": "dashboards/cloudfunctions-monitoring.json" + }, + { + "type": "dashboard", + "name": "GCE VM Instance Monitoring", + "path": "dashboards/gce-vm-instance-monitoring.json" + }, + { + "type": "dashboard", + "name": "GKE Prometheus Pod/Node Monitoring", + "path": "dashboards/gke-prometheus-pod-node-monitoring.json" + }, + { + "type": "dashboard", + "name": "Firewall Insights Monitoring", + "path": "dashboards/firewall-insight-monitoring.json" + }, + { + "type": "dashboard", + "name": "GCE Network Monitoring", + "path": "dashboards/gce-network-monitoring.json" + }, + { + "type": "dashboard", + "name": "HTTP/S LB Backend Services", + "path": "dashboards/https-lb-backend-services-monitoring.json" + }, + { + "type": "dashboard", + "name": "HTTP/S Load Balancer Monitoring", + "path": "dashboards/https-loadbalancer-monitoring.json" + }, + { + "type": "dashboard", + "name": "Network TCP Load Balancer Monitoring", + "path": "dashboards/network-tcp-loadbalancer-monitoring.json" + }, + { + "type": "dashboard", + "name": "MicroService Monitoring", + "path": "dashboards/micro-service-monitoring.json" + }, + { + "type": "dashboard", + "name": "Cloud Storage Monitoring", + "path": "dashboards/cloud-storage-monitoring.json" + }, + { + "type": "dashboard", + "name": "Cloud SQL Monitoring", + "path": "dashboards/cloudsql-monitoring.json" + }, + { + "type": "dashboard", + "name": "Cloud SQL(MySQL) Monitoring", + "path": "dashboards/cloudsql-mysql-monitoring.json" + } + ], + "queryOptions": { + "maxDataPoints": true, + "cacheTimeout": true + }, + + "info": { + "description": "Data source for Google's monitoring service (formerly named Stackdriver)", + "version": "1.0.0", + "logos": { + "small": "img/cloud_monitoring_logo.svg", + "large": "img/cloud_monitoring_logo.svg" + }, + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + } + }, + "routes": [ + { + "path": "cloudmonitoring", + "method": "GET", + "url": "https://monitoring.googleapis.com", + "jwtTokenAuth": { + "scopes": ["https://www.googleapis.com/auth/monitoring.read"], + "params": { + "token_uri": "{{.JsonData.tokenUri}}", + "client_email": "{{.JsonData.clientEmail}}", + "private_key": "{{.SecureJsonData.privateKey}}" + } + } + }, + { + "path": "cloudresourcemanager", + "method": "GET", + "url": "https://cloudresourcemanager.googleapis.com", + "jwtTokenAuth": { + "scopes": ["https://www.googleapis.com/auth/cloudplatformprojects.readonly"], + "params": { + "token_uri": "{{.JsonData.tokenUri}}", + "client_email": "{{.JsonData.clientEmail}}", + "private_key": "{{.SecureJsonData.privateKey}}" + } + } + } + ] +} diff --git a/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts b/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts new file mode 100644 index 0000000..12b6dae --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/specs/datasource.test.ts @@ -0,0 +1,230 @@ +import { of, throwError } from 'rxjs'; +import { DataSourceInstanceSettings, toUtc } from '@grafana/data'; + +import CloudMonitoringDataSource from '../datasource'; +import { metricDescriptors } from './testData'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { CloudMonitoringOptions } from '../types'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { CustomVariableModel } from '../../../../features/variables/types'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +type Args = { response?: any; throws?: boolean; templateSrv?: TemplateSrv }; + +function getTestcontext({ response = {}, throws = false, templateSrv = new TemplateSrv() }: Args = {}) { + jest.clearAllMocks(); + + const instanceSettings = ({ + jsonData: { + defaultProject: 'testproject', + }, + } as unknown) as DataSourceInstanceSettings; + + const timeSrv = {} as TimeSrv; + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + throws + ? fetchMock.mockImplementation(() => throwError(response)) + : fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + const ds = new CloudMonitoringDataSource(instanceSettings, templateSrv, timeSrv); + + return { ds }; +} + +describe('CloudMonitoringDataSource', () => { + describe('when performing testDataSource', () => { + describe('and call to cloud monitoring api succeeds', () => { + it('should return successfully', async () => { + const { ds } = getTestcontext(); + + const result = await ds.testDatasource(); + + expect(result.status).toBe('success'); + }); + }); + + describe('and a list of metricDescriptors are returned', () => { + it('should return status success', async () => { + const { ds } = getTestcontext({ response: metricDescriptors }); + + const result = await ds.testDatasource(); + + expect(result.status).toBe('success'); + }); + }); + + describe('and call to cloud monitoring api fails with 400 error', () => { + it('should return error status and a detailed error message', async () => { + const response = { + statusText: 'Bad Request', + data: { + error: { code: 400, message: 'Field interval.endTime had an invalid value' }, + }, + }; + const { ds } = getTestcontext({ response, throws: true }); + + const result = await ds.testDatasource(); + + expect(result.status).toEqual('error'); + expect(result.message).toBe( + 'Google Cloud Monitoring: Bad Request: 400. Field interval.endTime had an invalid value' + ); + }); + }); + }); + + describe('When performing query', () => { + describe('and no time series data is returned', () => { + it('should return a list of datapoints', async () => { + const options = { + range: { + from: toUtc('2017-08-22T20:00:00Z'), + to: toUtc('2017-08-22T23:59:00Z'), + }, + rangeRaw: { + from: 'now-4h', + to: 'now', + }, + targets: [ + { + refId: 'A', + }, + ], + }; + + const response: any = { + results: { + A: { + refId: 'A', + meta: { + rawQuery: 'arawquerystring', + }, + series: null, + tables: null, + }, + }, + }; + + const { ds } = getTestcontext({ response }); + + await expect(ds.query(options as any)).toEmitValuesWith((received) => { + const results = received[0]; + expect(results.data.length).toBe(0); + }); + }); + }); + }); + + describe('when performing getMetricTypes', () => { + describe('and call to cloud monitoring api succeeds', () => { + it('should return successfully', async () => { + const response = { + metricDescriptors: [ + { + displayName: 'test metric name 1', + type: 'compute.googleapis.com/instance/cpu/test-metric-type-1', + description: 'A description', + }, + { + type: 'logging.googleapis.com/user/logbased-metric-with-no-display-name', + }, + ], + }; + const { ds } = getTestcontext({ response }); + + const result = await ds.getMetricTypes('proj'); + + expect(result.length).toBe(2); + expect(result[0].service).toBe('compute.googleapis.com'); + expect(result[0].serviceShortName).toBe('compute'); + expect(result[0].type).toBe('compute.googleapis.com/instance/cpu/test-metric-type-1'); + expect(result[0].displayName).toBe('test metric name 1'); + expect(result[0].description).toBe('A description'); + expect(result[1].type).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name'); + expect(result[1].displayName).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name'); + }); + }); + }); + + describe('when interpolating a template variable for the filter', () => { + describe('and is single value variable', () => { + it('should replace the variable with the value', () => { + const templateSrv = initTemplateSrv('filtervalue1'); + const { ds } = getTestcontext({ templateSrv }); + const interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '${test}'], {}); + + expect(interpolated.length).toBe(3); + expect(interpolated[2]).toBe('filtervalue1'); + }); + }); + + describe('and is single value variable for the label part', () => { + it('should replace the variable with the value and not with regex formatting', () => { + const templateSrv = initTemplateSrv('resource.label.zone'); + const { ds } = getTestcontext({ templateSrv }); + const interpolated = ds.interpolateFilters(['${test}', '=~', 'europe-north-1a'], {}); + + expect(interpolated.length).toBe(3); + expect(interpolated[0]).toBe('resource.label.zone'); + }); + }); + + describe('and is multi value variable', () => { + it('should replace the variable with a regex expression', () => { + const templateSrv = initTemplateSrv(['filtervalue1', 'filtervalue2'], true); + const { ds } = getTestcontext({ templateSrv }); + const interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '[[test]]'], {}); + + expect(interpolated[2]).toBe('(filtervalue1|filtervalue2)'); + }); + }); + }); + + describe('when interpolating a template variable for group bys', () => { + describe('and is single value variable', () => { + it('should replace the variable with the value', () => { + const templateSrv = initTemplateSrv('groupby1'); + const { ds } = getTestcontext({ templateSrv }); + const interpolated = ds.interpolateGroupBys(['[[test]]'], {}); + + expect(interpolated.length).toBe(1); + expect(interpolated[0]).toBe('groupby1'); + }); + }); + + describe('and is multi value variable', () => { + it('should replace the variable with an array of group bys', () => { + const templateSrv = initTemplateSrv(['groupby1', 'groupby2'], true); + const { ds } = getTestcontext({ templateSrv }); + const interpolated = ds.interpolateGroupBys(['[[test]]'], {}); + + expect(interpolated.length).toBe(2); + expect(interpolated[0]).toBe('groupby1'); + expect(interpolated[1]).toBe('groupby2'); + }); + }); + }); +}); + +function initTemplateSrv(values: any, multi = false) { + const templateSrv = new TemplateSrv(); + const test: CustomVariableModel = { + ...initialCustomVariableModelState, + id: 'test', + name: 'test', + current: { value: values, text: Array.isArray(values) ? values.toString() : values, selected: true }, + options: [{ value: values, text: Array.isArray(values) ? values.toString() : values, selected: false }], + multi, + }; + templateSrv.init([test]); + return templateSrv; +} diff --git a/public/app/plugins/datasource/cloud-monitoring/specs/testData.ts b/public/app/plugins/datasource/cloud-monitoring/specs/testData.ts new file mode 100644 index 0000000..b97aad7 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/specs/testData.ts @@ -0,0 +1,42 @@ +export const metricDescriptors = [ + { + name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/api_request_count', + labels: [ + { + key: 'state', + description: 'Request state', + }, + ], + metricKind: 'CUMULATIVE', + valueType: 'INT64', + unit: '1', + description: 'API request count', + displayName: 'API Request Count', + type: 'agent.googleapis.com/agent/api_request_count', + metadata: { + launchStage: 'GA', + samplePeriod: '60s', + ingestDelay: '0s', + }, + }, + { + name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/log_entry_count', + labels: [ + { + key: 'response_code', + description: 'HTTP response code', + }, + ], + metricKind: 'CUMULATIVE', + valueType: 'INT64', + unit: '1', + description: 'Count of log entry writes', + displayName: 'Log Entry Count', + type: 'agent.googleapis.com/agent/log_entry_count', + metadata: { + launchStage: 'GA', + samplePeriod: '60s', + ingestDelay: '0s', + }, + }, +]; diff --git a/public/app/plugins/datasource/cloud-monitoring/types.ts b/public/app/plugins/datasource/cloud-monitoring/types.ts new file mode 100644 index 0000000..102e086 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/types.ts @@ -0,0 +1,157 @@ +import { DataQuery, DataSourceJsonData, SelectableValue } from '@grafana/data'; + +export enum AuthType { + JWT = 'jwt', + GCE = 'gce', +} + +export const authTypes = [ + { value: 'Google JWT File', key: AuthType.JWT }, + { value: 'GCE Default Service Account', key: AuthType.GCE }, +]; + +export enum MetricFindQueryTypes { + Projects = 'projects', + Services = 'services', + DefaultProject = 'defaultProject', + MetricTypes = 'metricTypes', + LabelKeys = 'labelKeys', + LabelValues = 'labelValues', + ResourceTypes = 'resourceTypes', + Aggregations = 'aggregations', + Aligners = 'aligners', + AlignmentPeriods = 'alignmentPeriods', + Selectors = 'selectors', + SLOServices = 'sloServices', + SLO = 'slo', +} + +export interface CloudMonitoringVariableQuery extends DataQuery { + selectedQueryType: string; + selectedService: string; + selectedMetricType: string; + selectedSLOService: string; + labelKey: string; + projects: SelectableValue[]; + sloServices: SelectableValue[]; + projectName: string; +} + +export interface VariableQueryData { + selectedQueryType: string; + metricDescriptors: MetricDescriptor[]; + selectedService: string; + selectedMetricType: string; + selectedSLOService: string; + labels: string[]; + labelKey: string; + metricTypes: Array<{ value: string; name: string }>; + services: SelectableValue[]; + projects: SelectableValue[]; + sloServices: SelectableValue[]; + projectName: string; + loading: boolean; +} + +export enum QueryType { + METRICS = 'metrics', + SLO = 'slo', +} + +export enum EditorMode { + Visual = 'visual', + MQL = 'mql', +} + +export const queryTypes = [ + { label: 'Metrics', value: QueryType.METRICS }, + { label: 'Service Level Objectives (SLO)', value: QueryType.SLO }, +]; + +export interface MetricQuery { + editorMode: EditorMode; + projectName: string; + unit?: string; + metricType: string; + crossSeriesReducer: string; + alignmentPeriod?: string; + perSeriesAligner?: string; + groupBys?: string[]; + filters?: string[]; + aliasBy?: string; + metricKind?: string; + valueType?: string; + view?: string; + query: string; +} + +export interface SLOQuery { + projectName: string; + alignmentPeriod?: string; + perSeriesAligner?: string; + aliasBy?: string; + selectorName: string; + serviceId: string; + serviceName: string; + sloId: string; + sloName: string; + goal?: number; +} + +export interface CloudMonitoringQuery extends DataQuery { + datasourceId?: number; // Should not be necessary anymore + queryType: QueryType; + metricQuery: MetricQuery; + sloQuery?: SLOQuery; + intervalMs: number; + type: string; +} + +export interface CloudMonitoringOptions extends DataSourceJsonData { + defaultProject?: string; + gceDefaultProject?: string; + authenticationType?: string; +} + +export interface AnnotationTarget { + projectName: string; + metricType: string; + refId: string; + filters: string[]; + metricKind: string; + valueType: string; + title: string; + text: string; +} + +export interface QueryMeta { + alignmentPeriod: string; + rawQuery: string; + rawQueryString: string; + metricLabels: { [key: string]: string[] }; + resourceLabels: { [key: string]: string[] }; + resourceTypes: string[]; +} + +export interface MetricDescriptor { + valueType: string; + metricKind: string; + type: string; + unit: string; + service: string; + serviceShortName: string; + displayName: string; + description: string; +} + +export interface Segment { + type: string; + value: string; +} + +export interface Filter { + key: string; + operator: string; + value: string; + condition?: string; +} diff --git a/public/app/plugins/datasource/cloud-monitoring/variables.ts b/public/app/plugins/datasource/cloud-monitoring/variables.ts new file mode 100644 index 0000000..181e9f0 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/variables.ts @@ -0,0 +1,31 @@ +import { from, Observable } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data'; + +import CloudMonitoringDatasource from './datasource'; +import { CloudMonitoringVariableQuery } from './types'; +import CloudMonitoringMetricFindQuery from './CloudMonitoringMetricFindQuery'; +import { CloudMonitoringVariableQueryEditor } from './components/VariableQueryEditor'; + +export class CloudMonitoringVariableSupport extends CustomVariableSupport< + CloudMonitoringDatasource, + CloudMonitoringVariableQuery +> { + private readonly metricFindQuery: CloudMonitoringMetricFindQuery; + + constructor(private readonly datasource: CloudMonitoringDatasource) { + super(); + this.metricFindQuery = new CloudMonitoringMetricFindQuery(datasource); + this.query = this.query.bind(this); + } + + editor = CloudMonitoringVariableQueryEditor; + + query(request: DataQueryRequest): Observable { + const executeObservable = from(this.metricFindQuery.execute(request.targets[0])); + return from(this.datasource.ensureGCEDefaultProject()).pipe( + mergeMap(() => executeObservable), + map((data) => ({ data })) + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/README.md b/public/app/plugins/datasource/cloudwatch/README.md new file mode 100644 index 0000000..184fb89 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/README.md @@ -0,0 +1,7 @@ +# CloudWatch Data Source - Native Plugin + +Grafana ships with **built in** support for CloudWatch. You just have to add it as a data source and you will be ready to build dashboards for your CloudWatch metrics. + +Read more about it here: + +[https://grafana.com/docs/grafana/latest/features/datasources/cloudwatch/](https://grafana.com/docs/grafana/latest/features/datasources/cloudwatch/) diff --git a/public/app/plugins/datasource/cloudwatch/annotations_query_ctrl.ts b/public/app/plugins/datasource/cloudwatch/annotations_query_ctrl.ts new file mode 100644 index 0000000..34fc5cf --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/annotations_query_ctrl.ts @@ -0,0 +1,31 @@ +import _ from 'lodash'; +import { AnnotationQuery } from './types'; + +export class CloudWatchAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + annotation: any; + + /** @ngInject */ + constructor() { + _.defaultsDeep(this.annotation, { + namespace: '', + metricName: '', + expression: '', + dimensions: {}, + region: 'default', + id: '', + alias: '', + statistics: ['Average'], + matchExact: true, + prefixMatching: false, + actionPrefix: '', + alarmNamePrefix: '', + }); + + this.onChange = this.onChange.bind(this); + } + + onChange(query: AnnotationQuery) { + Object.assign(this.annotation, query); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/aws_url.ts b/public/app/plugins/datasource/cloudwatch/aws_url.ts new file mode 100644 index 0000000..3982b3a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/aws_url.ts @@ -0,0 +1,18 @@ +const JSURL = require('jsurl'); + +export interface AwsUrl { + end: string; + start: string; + timeType?: 'ABSOLUTE' | 'RELATIVE'; + tz?: 'local' | 'UTC'; + unit?: string; + editorString: string; + isLiveTail: boolean; + source: string[]; +} + +export function encodeUrl(obj: AwsUrl, region: string): string { + return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logs-insights:queryDetail=${JSURL.stringify( + obj + )}`; +} diff --git a/public/app/plugins/datasource/cloudwatch/components/Alias.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Alias.test.tsx new file mode 100644 index 0000000..3b190c4 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Alias.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { Alias } from './Alias'; + +describe('Alias', () => { + it('should render component', () => { + const tree = renderer.create( {}} />).toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/Alias.tsx b/public/app/plugins/datasource/cloudwatch/components/Alias.tsx new file mode 100644 index 0000000..f665289 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Alias.tsx @@ -0,0 +1,22 @@ +import React, { FunctionComponent, useState } from 'react'; +import { debounce } from 'lodash'; +import { LegacyForms } from '@grafana/ui'; +const { Input } = LegacyForms; + +export interface Props { + onChange: (alias: any) => void; + value: string; +} + +export const Alias: FunctionComponent = ({ value = '', onChange }) => { + const [alias, setAlias] = useState(value); + + const propagateOnChange = debounce(onChange, 1500); + + onChange = (e: any) => { + setAlias(e.target.value); + propagateOnChange(e.target.value); + }; + + return ; +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor.tsx new file mode 100644 index 0000000..7befb4e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/AnnotationQueryEditor.tsx @@ -0,0 +1,62 @@ +import React, { ChangeEvent } from 'react'; +import { LegacyForms } from '@grafana/ui'; +const { Switch } = LegacyForms; +import { PanelData } from '@grafana/data'; +import { AnnotationQuery } from '../types'; +import { CloudWatchDatasource } from '../datasource'; +import { QueryField, PanelQueryEditor } from './'; + +export type Props = { + query: AnnotationQuery; + datasource: CloudWatchDatasource; + onChange: (value: AnnotationQuery) => void; + data?: PanelData; +}; + +export function AnnotationQueryEditor(props: React.PropsWithChildren) { + const { query, onChange } = props; + return ( + <> + onChange({ ...query, ...editorQuery })} + onRunQuery={() => {}} + history={[]} + > +
    + onChange({ ...query, prefixMatching: !query.prefixMatching })} + /> + +
    + + ) => + onChange({ ...query, actionPrefix: event.target.value }) + } + /> + + + ) => + onChange({ ...query, alarmNamePrefix: event.target.value }) + } + /> + +
    +
    +
    +
    +
    + + ); +} diff --git a/public/app/plugins/datasource/cloudwatch/components/CloudWatchLink.tsx b/public/app/plugins/datasource/cloudwatch/components/CloudWatchLink.tsx new file mode 100644 index 0000000..c360e60 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/CloudWatchLink.tsx @@ -0,0 +1,66 @@ +import _ from 'lodash'; +import React, { Component } from 'react'; + +import { CloudWatchLogsQuery } from '../types'; +import { PanelData } from '@grafana/data'; +import { Icon } from '@grafana/ui'; +import { encodeUrl, AwsUrl } from '../aws_url'; +import { CloudWatchDatasource } from '../datasource'; + +interface Props { + query: CloudWatchLogsQuery; + panelData?: PanelData; + datasource: CloudWatchDatasource; +} + +interface State { + href: string; +} + +export default class CloudWatchLink extends Component { + state: State = { href: '' }; + + async componentDidUpdate(prevProps: Props) { + const { panelData: panelDataNew } = this.props; + const { panelData: panelDataOld } = prevProps; + + if (panelDataOld !== panelDataNew && panelDataNew?.request) { + const href = this.getExternalLink(); + this.setState({ href }); + } + } + + getExternalLink(): string { + const { query, panelData, datasource } = this.props; + + const range = panelData?.request?.range; + + if (!range) { + return ''; + } + + const start = range.from.toISOString(); + const end = range.to.toISOString(); + + const urlProps: AwsUrl = { + end, + start, + timeType: 'ABSOLUTE', + tz: 'UTC', + editorString: query.expression ?? '', + isLiveTail: false, + source: query.logGroupNames ?? [], + }; + + return encodeUrl(urlProps, datasource.getActualRegion(query.region)); + } + + render() { + const { href } = this.state; + return ( + + CloudWatch Logs Insights + + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx new file mode 100644 index 0000000..8a2752b --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AwsAuthType } from '@grafana/aws-sdk'; +import { ConfigEditor, Props } from './ConfigEditor'; + +jest.mock('app/features/plugins/datasource_srv', () => ({ + getDatasourceSrv: () => ({ + loadDatasource: jest.fn().mockImplementation(() => + Promise.resolve({ + getRegions: jest.fn().mockReturnValue([ + { + label: 'ap-east-1', + value: 'ap-east-1', + }, + ]), + }) + ), + }), +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + options: { + id: 1, + orgId: 1, + typeLogoUrl: '', + name: 'CloudWatch', + access: 'proxy', + url: '', + database: '', + type: 'cloudwatch', + typeName: 'Cloudwatch', + user: '', + password: '', + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + isDefault: true, + readOnly: false, + withCredentials: false, + secureJsonFields: { + accessKey: false, + secretKey: false, + }, + jsonData: { + assumeRoleArn: '', + externalId: '', + database: '', + customMetricsNamespaces: '', + authType: AwsAuthType.Keys, + defaultRegion: 'us-east-2', + timeField: '@timestamp', + }, + secureJsonData: { + secretKey: '', + accessKey: '', + }, + }, + onOptionsChange: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should disable access key id field', () => { + const wrapper = setup({ + secureJsonFields: { + secretKey: true, + }, + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should show credentials profile name field', () => { + const wrapper = setup({ + jsonData: { + authType: 'credentials', + }, + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should show access key and secret access key fields', () => { + const wrapper = setup({ + jsonData: { + authType: 'keys', + }, + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should show arn role field', () => { + const wrapper = setup({ + jsonData: { + authType: 'arn', + }, + }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx new file mode 100644 index 0000000..8695896 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx @@ -0,0 +1,63 @@ +import React, { FC, useEffect, useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOption } from '@grafana/data'; +import { ConnectionConfig } from '@grafana/aws-sdk'; + +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { store } from 'app/store/store'; +import { notifyApp } from 'app/core/actions'; +import { createWarningNotification } from 'app/core/copy/appNotification'; + +import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types'; +import { CloudWatchDatasource } from '../datasource'; + +export type Props = DataSourcePluginOptionsEditorProps; + +export const ConfigEditor: FC = (props: Props) => { + const [datasource, setDatasource] = useState(); + + const addWarning = (message: string) => { + store.dispatch(notifyApp(createWarningNotification('CloudWatch Authentication', message))); + }; + + useEffect(() => { + getDatasourceSrv() + .loadDatasource(props.options.name) + .then((datasource: CloudWatchDatasource) => setDatasource(datasource)); + + if (props.options.jsonData.authType === 'arn') { + addWarning('Since grafana 7.3 authentication type "arn" is deprecated, falling back to default SDK provider'); + } else if ( + props.options.jsonData.authType === 'credentials' && + !props.options.jsonData.profile && + !props.options.jsonData.database + ) { + addWarning( + 'As of grafana 7.3 authentication type "credentials" should be used only for shared file credentials. \ + If you don\'t have a credentials file, switch to the default SDK provider for extracting credentials \ + from environment variables or IAM roles' + ); + } + }, []); + + return ( + <> + datasource!.getRegions().then((r) => r.filter((r) => r.value !== 'default').map((v) => v.value))) + } + > + + + + + + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/Dimensions.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Dimensions.test.tsx new file mode 100644 index 0000000..124cdd8 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Dimensions.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { Dimensions } from './'; +import { SelectableStrings } from '../types'; + +describe('Dimensions', () => { + it('renders', () => { + mount( + console.log(dimensions)} + loadKeys={() => Promise.resolve([])} + loadValues={() => Promise.resolve([])} + /> + ); + }); + + describe('and no dimension were passed to the component', () => { + it('initially displays just an add button', () => { + const wrapper = shallow( + {}} + loadKeys={() => Promise.resolve([])} + loadValues={() => Promise.resolve([])} + /> + ); + + expect(wrapper.html()).toEqual(expect.stringContaining(`gf-form`)); + }); + }); + + describe('and one dimension key along with a value were passed to the component', () => { + it('initially displays the dimension key, value and an add button', () => { + const wrapper = shallow( + {}} + loadKeys={() => Promise.resolve([])} + loadValues={() => Promise.resolve([])} + /> + ); + expect(wrapper.html()).toEqual(expect.stringContaining(`gf-form`)); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/Dimensions.tsx b/public/app/plugins/datasource/cloudwatch/components/Dimensions.tsx new file mode 100644 index 0000000..6380b81 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Dimensions.tsx @@ -0,0 +1,81 @@ +import React, { FunctionComponent, Fragment, useState, useEffect } from 'react'; +import isEqual from 'lodash/isEqual'; +import { SelectableValue } from '@grafana/data'; +import { SegmentAsync, Icon } from '@grafana/ui'; +import { SelectableStrings } from '../types'; + +export interface Props { + dimensions: { [key: string]: string | string[] }; + onChange: (dimensions: { [key: string]: string }) => void; + loadValues: (key: string) => Promise; + loadKeys: () => Promise; +} + +const removeText = '-- remove dimension --'; +const removeOption: SelectableValue = { label: removeText, value: removeText }; + +// The idea of this component is that is should only trigger the onChange event in the case +// there is a complete dimension object. E.g, when a new key is added is doesn't have a value. +// That should not trigger onChange. +export const Dimensions: FunctionComponent = ({ dimensions, loadValues, loadKeys, onChange }) => { + const [data, setData] = useState(dimensions); + + useEffect(() => { + const completeDimensions = Object.entries(data).reduce( + (res, [key, value]) => (value ? { ...res, [key]: value } : res), + {} + ); + if (!isEqual(completeDimensions, dimensions)) { + onChange(completeDimensions); + } + }, [data]); + + const excludeUsedKeys = (options: SelectableStrings) => { + return options.filter(({ value }) => !Object.keys(data).includes(value!)); + }; + + return ( + <> + {Object.entries(data).map(([key, value], index) => ( + + loadKeys().then((keys) => [removeOption, ...excludeUsedKeys(keys)])} + onChange={({ value: newKey }) => { + const { [key]: value, ...newDimensions } = data; + if (newKey === removeText) { + setData({ ...newDimensions }); + } else { + setData({ ...newDimensions, [newKey!]: '' }); + } + }} + /> + + loadValues(key)} + onChange={({ value: newValue }) => setData({ ...data, [key]: newValue! })} + /> + {Object.values(data).length > 1 && index + 1 !== Object.values(data).length && ( + + )} + + ))} + {Object.values(data).every((v) => v) && ( + + + + } + loadOptions={() => loadKeys().then(excludeUsedKeys)} + onChange={({ value: newKey }) => setData({ ...data, [newKey!]: '' })} + /> + )} + + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/Forms.tsx b/public/app/plugins/datasource/cloudwatch/components/Forms.tsx new file mode 100644 index 0000000..b7b4d36 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Forms.tsx @@ -0,0 +1,28 @@ +import React, { InputHTMLAttributes, FunctionComponent } from 'react'; +import { InlineFormLabel } from '@grafana/ui'; + +export interface Props extends InputHTMLAttributes { + label: string; + tooltip?: string; + children?: React.ReactNode; +} + +export const QueryField: FunctionComponent> = ({ label, tooltip, children }) => ( + <> + + {label} + + {children} + +); + +export const QueryInlineField: FunctionComponent = ({ ...props }) => { + return ( +
    + +
    +
    +
    +
    + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx new file mode 100644 index 0000000..e33eaeb --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx @@ -0,0 +1,273 @@ +import React, { PureComponent } from 'react'; +import { stripIndent, stripIndents } from 'common-tags'; +import { QueryEditorHelpProps } from '@grafana/data'; +import Prism from 'prismjs'; +import tokenizer from '../syntax'; +import { flattenTokens } from '@grafana/ui/src/slate-plugins/slate-prism'; +import { css, cx } from 'emotion'; +import { CloudWatchLogsQuery } from '../types'; + +interface QueryExample { + category: string; + examples: Array<{ + title: string; + expr: string; + }>; +} + +const CLIQ_EXAMPLES: QueryExample[] = [ + { + category: 'Lambda', + examples: [ + { + title: 'View latency statistics for 5-minute intervals', + expr: stripIndents`filter @type = "REPORT" | + stats avg(@duration), max(@duration), min(@duration) by bin(5m)`, + }, + { + title: 'Determine the amount of overprovisioned memory', + expr: stripIndent` + filter @type = "REPORT" | + stats max(@memorySize / 1024 / 1024) as provisonedMemoryMB, + min(@maxMemoryUsed / 1024 / 1024) as smallestMemoryRequestMB, + avg(@maxMemoryUsed / 1024 / 1024) as avgMemoryUsedMB, + max(@maxMemoryUsed / 1024 / 1024) as maxMemoryUsedMB, + provisonedMemoryMB - maxMemoryUsedMB as overProvisionedMB`, + }, + { + title: 'Find the most expensive requests', + expr: stripIndents`filter @type = "REPORT" | + fields @requestId, @billedDuration | + sort by @billedDuration desc`, + }, + ], + }, + + { + category: 'VPC Flow Logs', + examples: [ + { + title: 'Average, min, and max byte transfers by source and destination IP addresses', + expr: `stats avg(bytes), min(bytes), max(bytes) by srcAddr, dstAddr`, + }, + { + title: 'IP addresses using UDP transfer protocol', + expr: 'filter protocol=17 | stats count(*) by srcAddr', + }, + { + title: 'Top 10 byte transfers by source and destination IP addresses', + expr: stripIndents`stats sum(bytes) as bytesTransferred by srcAddr, dstAddr | + sort bytesTransferred desc | + limit 10`, + }, + { + title: 'Top 20 source IP addresses with highest number of rejected requests', + expr: stripIndents`filter action="REJECT" | + stats count(*) as numRejections by srcAddr | + sort numRejections desc | + limit 20`, + }, + ], + }, + { + category: 'CloudTrail', + examples: [ + { + title: 'Number of log entries by service, event type, and region', + expr: 'stats count(*) by eventSource, eventName, awsRegion', + }, + + { + title: 'Number of log entries by region and EC2 event type', + expr: stripIndents`filter eventSource="ec2.amazonaws.com" | + stats count(*) as eventCount by eventName, awsRegion | + sort eventCount desc`, + }, + + { + title: 'Regions, usernames, and ARNs of newly created IAM users', + expr: stripIndents`filter eventName="CreateUser" | + fields awsRegion, requestParameters.userName, responseElements.user.arn`, + }, + ], + }, + { + category: 'Common Queries', + examples: [ + { + title: '25 most recently added log events', + expr: stripIndents`fields @timestamp, @message | + sort @timestamp desc | + limit 25`, + }, + { + title: 'Number of exceptions logged every 5 minutes', + expr: stripIndents`filter @message like /Exception/ | + stats count(*) as exceptionCount by bin(5m) | + sort exceptionCount desc`, + }, + { + title: 'List of log events that are not exceptions', + expr: 'fields @message | filter @message not like /Exception/', + }, + ], + }, + { + category: 'Route 53', + examples: [ + { + title: 'Number of requests received every 10 minutes by edge location', + expr: 'stats count(*) by queryType, bin(10m)', + }, + { + title: 'Number of unsuccessful requests by domain', + expr: 'filter responseCode="SERVFAIL" | stats count(*) by queryName', + }, + { + title: 'Number of requests received every 10 minutes by edge location', + expr: 'stats count(*) as numRequests by resolverIp | sort numRequests desc | limit 10', + }, + ], + }, + { + category: 'AWS AppSync', + examples: [ + { + title: 'Number of unique HTTP status codes', + expr: stripIndents`fields ispresent(graphQLAPIId) as isApi | + filter isApi | + filter logType = "RequestSummary" | + stats count() as statusCount by statusCode | + sort statusCount desc`, + }, + { + title: 'Top 10 resolvers with maximum latency', + expr: stripIndents`fields resolverArn, duration | + filter logType = "Tracing" | + sort duration desc | + limit 10`, + }, + { + title: 'Most frequently invoked resolvers', + expr: stripIndents`fields ispresent(resolverArn) as isRes | + stats count() as invocationCount by resolverArn | + filter isRes | + filter logType = "Tracing" | + sort invocationCount desc | + limit 10`, + }, + { + title: 'Resolvers with most errors in mapping templates', + expr: stripIndents`fields ispresent(resolverArn) as isRes | + stats count() as errorCount by resolverArn, logType | + filter isRes and (logType = "RequestMapping" or logType = "ResponseMapping") and fieldInError | + sort errorCount desc | + limit 10`, + }, + { + title: 'Field latency statistics', + expr: stripIndents`fields requestId, latency | + filter logType = "RequestSummary" | + sort latency desc | + limit 10`, + }, + { + title: 'Resolver latency statistics', + expr: stripIndents`fields ispresent(resolverArn) as isRes | + filter isRes | + filter logType = "Tracing" | + stats min(duration), max(duration), avg(duration) as avgDur by resolverArn | + sort avgDur desc | + limit 10`, + }, + { + title: 'Top 10 requests with maximum latency', + expr: stripIndents`fields requestId, latency | + filter logType = "RequestSummary" | + sort latency desc | + limit 10`, + }, + ], + }, +]; + +function renderHighlightedMarkup(code: string, keyPrefix: string) { + const grammar = tokenizer; + const tokens = flattenTokens(Prism.tokenize(code, grammar)); + const spans = tokens + .filter((token) => typeof token !== 'string') + .map((token, i) => { + return ( + + {token.content} + + ); + }); + + return
    {spans}
    ; +} + +const exampleCategory = css` + margin-top: 5px; +`; + +export default class LogsCheatSheet extends PureComponent { + onClickExample(query: CloudWatchLogsQuery) { + this.props.onClickExample(query); + } + + renderExpression(expr: string, keyPrefix: string) { + return ( +
    + this.onClickExample({ refId: 'A', expression: expr, queryMode: 'Logs', region: 'default', id: 'A' }) + } + > +
    {renderHighlightedMarkup(expr, keyPrefix)}
    +
    + ); + } + + renderLogsCheatSheet() { + return ( +
    +

    CloudWatch Logs Cheat Sheet

    + {CLIQ_EXAMPLES.map((cat, i) => ( +
    +
    {cat.category}
    + {cat.examples.map((item, j) => ( +
    +

    {item.title}

    + {this.renderExpression(item.expr, `item-${j}`)} +
    + ))} +
    + ))} +
    + ); + } + + render() { + return ( +
    +

    CloudWatch Logs Cheat Sheet

    + {CLIQ_EXAMPLES.map((cat, i) => ( +
    +
    {cat.category}
    + {cat.examples.map((item, j) => ( +
    +

    {item.title}

    + {this.renderExpression(item.expr, `item-${j}`)} +
    + ))} +
    + ))} +
    + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx new file mode 100644 index 0000000..91c37df --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx @@ -0,0 +1,60 @@ +// Libraries +import React, { memo } from 'react'; + +// Types +import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data'; +import { InlineFormLabel } from '@grafana/ui'; +import { CloudWatchDatasource } from '../datasource'; +import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types'; +import { CloudWatchLogsQueryField } from './LogsQueryField'; +import CloudWatchLink from './CloudWatchLink'; +import { css } from 'emotion'; + +type Props = QueryEditorProps & { + allowCustomValue?: boolean; +}; + +const labelClass = css` + margin-left: 3px; + flex-grow: 0; +`; + +export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) { + const { query, data, datasource, onRunQuery, onChange, exploreId, allowCustomValue = false } = props; + + let absolute: AbsoluteTimeRange; + if (data?.request?.range?.from) { + const { range } = data.request; + absolute = { + from: range.from.valueOf(), + to: range.to.valueOf(), + }; + } else { + absolute = { + from: Date.now() - 10000, + to: Date.now(), + }; + } + + return ( + {}} + onChange={(val: CloudWatchLogsQuery) => onChange({ ...val, queryMode: 'Logs' })} + onRunQuery={onRunQuery} + history={[]} + data={data} + absoluteRange={absolute} + allowCustomValue={allowCustomValue} + ExtraFieldElement={ + + + + } + /> + ); +}); + +export default CloudWatchLogsQueryEditor; diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx new file mode 100644 index 0000000..f728ef6 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { CloudWatchLogsQueryField } from './LogsQueryField'; +import { ExploreId } from '../../../../types'; +import { DescribeLogGroupsRequest } from '../types'; +import { SelectableValue } from '@grafana/data'; + +jest.mock('lodash/debounce', () => { + const fakeDebounce = (func: () => any, period: number) => func; + return fakeDebounce; +}); + +describe('CloudWatchLogsQueryField', () => { + it('updates upstream query log groups on region change', async () => { + const onChange = jest.fn(); + const wrapper = shallow( + {}} + onChange={onChange} + /> + ); + const getRegionSelect = () => wrapper.find({ label: 'Region' }).props().inputEl; + const getLogGroupSelect = () => wrapper.find({ label: 'Log Groups' }).props().inputEl; + + getLogGroupSelect().props.onChange([{ value: 'log_group_1' }]); + expect(getLogGroupSelect().props.value.length).toBe(1); + expect(getLogGroupSelect().props.value[0].value).toBe('log_group_1'); + + // We select new region where the selected log group does not exist + await getRegionSelect().props.onChange({ value: 'region2' }); + + // We clear the select + expect(getLogGroupSelect().props.value.length).toBe(0); + // Make sure we correctly updated the upstream state + expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toEqual({ region: 'region2', logGroupNames: [] }); + }); + + it('should merge results of remote log groups search with existing results', async () => { + const allLogGroups = [ + 'AmazingGroup', + 'AmazingGroup2', + 'AmazingGroup3', + 'BeautifulGroup', + 'BeautifulGroup2', + 'BeautifulGroup3', + 'CrazyGroup', + 'CrazyGroup2', + 'CrazyGroup3', + 'DeliciousGroup', + 'DeliciousGroup2', + 'DeliciousGroup3', + 'EnjoyableGroup', + 'EnjoyableGroup2', + 'EnjoyableGroup3', + 'FavouriteGroup', + 'FavouriteGroup2', + 'FavouriteGroup3', + 'GorgeousGroup', + 'GorgeousGroup2', + 'GorgeousGroup3', + 'HappyGroup', + 'HappyGroup2', + 'HappyGroup3', + 'IncredibleGroup', + 'IncredibleGroup2', + 'IncredibleGroup3', + 'JollyGroup', + 'JollyGroup2', + 'JollyGroup3', + 'KoolGroup', + 'KoolGroup2', + 'KoolGroup3', + 'LovelyGroup', + 'LovelyGroup2', + 'LovelyGroup3', + 'MagnificentGroup', + 'MagnificentGroup2', + 'MagnificentGroup3', + 'NiceGroup', + 'NiceGroup2', + 'NiceGroup3', + 'OddGroup', + 'OddGroup2', + 'OddGroup3', + 'PerfectGroup', + 'PerfectGroup2', + 'PerfectGroup3', + 'QuietGroup', + 'QuietGroup2', + 'QuietGroup3', + 'RestlessGroup', + 'RestlessGroup2', + 'RestlessGroup3', + 'SurpriseGroup', + 'SurpriseGroup2', + 'SurpriseGroup3', + 'TestingGroup', + 'TestingGroup2', + 'TestingGroup3', + 'UmbrellaGroup', + 'UmbrellaGroup2', + 'UmbrellaGroup3', + 'VelvetGroup', + 'VelvetGroup2', + 'VelvetGroup3', + 'WaterGroup', + 'WaterGroup2', + 'WaterGroup3', + 'XylophoneGroup', + 'XylophoneGroup2', + 'XylophoneGroup3', + 'YellowGroup', + 'YellowGroup2', + 'YellowGroup3', + 'ZebraGroup', + 'ZebraGroup2', + 'ZebraGroup3', + ]; + + const onChange = jest.fn(); + const wrapper = shallow( + logGroupName.startsWith(params.logGroupNamePrefix ?? '')) + .slice(0, Math.max(params.limit ?? 50, 50)); + return Promise.resolve(theLogGroups); + }, + } as any + } + query={{} as any} + onRunQuery={() => {}} + onChange={onChange} + /> + ); + + const initialAvailableGroups = allLogGroups + .slice(0, 50) + .map((logGroupName) => ({ value: logGroupName, label: logGroupName })); + wrapper.setState({ + availableLogGroups: initialAvailableGroups, + }); + + await wrapper.instance().onLogGroupSearch('Water', 'default', { action: 'input-change' }); + + let nextAvailableGroups = (wrapper.state('availableLogGroups') as Array>).map( + (logGroup) => logGroup.value + ); + expect(nextAvailableGroups).toEqual( + initialAvailableGroups.map((logGroup) => logGroup.value).concat(['WaterGroup', 'WaterGroup2', 'WaterGroup3']) + ); + + await wrapper.instance().onLogGroupSearch('Velv', 'default', { action: 'input-change' }); + nextAvailableGroups = (wrapper.state('availableLogGroups') as Array>).map( + (logGroup) => logGroup.value + ); + expect(nextAvailableGroups).toEqual( + initialAvailableGroups + .map((logGroup) => logGroup.value) + .concat(['WaterGroup', 'WaterGroup2', 'WaterGroup3', 'VelvetGroup', 'VelvetGroup2', 'VelvetGroup3']) + ); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx new file mode 100644 index 0000000..4badb8f --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx @@ -0,0 +1,398 @@ +// Libraries +import React, { ReactNode } from 'react'; +import intersectionBy from 'lodash/intersectionBy'; +import debounce from 'lodash/debounce'; +import unionBy from 'lodash/unionBy'; + +import { + BracesPlugin, + LegacyForms, + MultiSelect, + QueryField, + Select, + SlatePrism, + TypeaheadInput, + TypeaheadOutput, +} from '@grafana/ui'; + +// Utils & Services +// dom also includes Element polyfills +import { Editor, Node, Plugin } from 'slate'; +import syntax from '../syntax'; + +// Types +import { AbsoluteTimeRange, AppEvents, ExploreQueryFieldProps, SelectableValue } from '@grafana/data'; +import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types'; +import { CloudWatchDatasource } from '../datasource'; +import { LanguageMap, languages as prismLanguages } from 'prismjs'; +import { CloudWatchLanguageProvider } from '../language_provider'; +import { css } from 'emotion'; +import { ExploreId } from 'app/types'; +import { appEvents } from 'app/core/core'; +import { InputActionMeta } from '@grafana/ui/src/components/Select/types'; +import { getStatsGroups } from '../utils/query/getStatsGroups'; + +export interface CloudWatchLogsQueryFieldProps + extends ExploreQueryFieldProps { + absoluteRange: AbsoluteTimeRange; + onLabelsRefresh?: () => void; + ExtraFieldElement?: ReactNode; + exploreId: ExploreId; + allowCustomValue?: boolean; +} + +const containerClass = css` + flex-grow: 1; + min-height: 35px; +`; + +const rowGap = css` + gap: 3px; +`; + +interface State { + selectedLogGroups: Array>; + availableLogGroups: Array>; + loadingLogGroups: boolean; + regions: Array>; + selectedRegion: SelectableValue; + invalidLogGroups: boolean; + hint: + | { + message: string; + fix: { + label: string; + action: () => void; + }; + } + | undefined; +} + +export class CloudWatchLogsQueryField extends React.PureComponent { + state: State = { + selectedLogGroups: + (this.props.query as CloudWatchLogsQuery).logGroupNames?.map((logGroup) => ({ + value: logGroup, + label: logGroup, + })) ?? [], + availableLogGroups: [], + regions: [], + invalidLogGroups: false, + selectedRegion: (this.props.query as CloudWatchLogsQuery).region + ? { + label: (this.props.query as CloudWatchLogsQuery).region, + value: (this.props.query as CloudWatchLogsQuery).region, + text: (this.props.query as CloudWatchLogsQuery).region, + } + : { label: 'default', value: 'default', text: 'default' }, + loadingLogGroups: false, + hint: undefined, + }; + + plugins: Plugin[]; + + constructor(props: CloudWatchLogsQueryFieldProps, context: React.Context) { + super(props, context); + + this.plugins = [ + BracesPlugin(), + SlatePrism( + { + onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', + getSyntax: (node: Node) => 'cloudwatch', + }, + { ...(prismLanguages as LanguageMap), cloudwatch: syntax } + ), + ]; + } + + fetchLogGroupOptions = async (region: string, logGroupNamePrefix?: string) => { + try { + const logGroups: string[] = await this.props.datasource.describeLogGroups({ + refId: this.props.query.refId, + region, + logGroupNamePrefix, + }); + + return logGroups.map((logGroup) => ({ + value: logGroup, + label: logGroup, + })); + } catch (err) { + appEvents.emit(AppEvents.alertError, [err]); + return []; + } + }; + + onLogGroupSearch = (searchTerm: string, region: string, actionMeta: InputActionMeta) => { + if (actionMeta.action !== 'input-change') { + return Promise.resolve(); + } + + // No need to fetch matching log groups if the search term isn't valid + // This is also useful for preventing searches when a user is typing out a log group with template vars + // See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_LogGroup.html for the source of the pattern below + const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/; + if (!logGroupNamePattern.test(searchTerm)) { + return Promise.resolve(); + } + + this.setState({ + loadingLogGroups: true, + }); + + return this.fetchLogGroupOptions(region, searchTerm) + .then((matchingLogGroups) => { + this.setState((state) => ({ + availableLogGroups: unionBy(state.availableLogGroups, matchingLogGroups, 'value'), + })); + }) + .finally(() => { + this.setState({ + loadingLogGroups: false, + }); + }); + }; + + onLogGroupSearchDebounced = debounce(this.onLogGroupSearch, 300); + + componentDidMount = () => { + const { datasource, query, onChange } = this.props; + + this.setState({ + loadingLogGroups: true, + }); + + this.fetchLogGroupOptions(query.region).then((logGroups) => { + this.setState((state) => { + const selectedLogGroups = state.selectedLogGroups; + if (onChange) { + const nextQuery = { + ...query, + logGroupNames: selectedLogGroups.map((group) => group.value!), + }; + + onChange(nextQuery); + } + + return { + loadingLogGroups: false, + availableLogGroups: logGroups, + selectedLogGroups, + }; + }); + }); + + datasource.getRegions().then((regions) => { + this.setState({ + regions, + }); + }); + }; + + onChangeQuery = (value: string) => { + // Send text change to parent + const { query, onChange } = this.props; + const { selectedLogGroups, selectedRegion } = this.state; + + if (onChange) { + const nextQuery = { + ...query, + expression: value, + logGroupNames: selectedLogGroups?.map((logGroupName) => logGroupName.value!) ?? [], + region: selectedRegion.value ?? 'default', + statsGroups: getStatsGroups(value), + }; + onChange(nextQuery); + } + }; + + setSelectedLogGroups = (selectedLogGroups: Array>) => { + this.setState({ + selectedLogGroups, + }); + + const { onChange, query } = this.props; + onChange?.({ + ...(query as CloudWatchLogsQuery), + logGroupNames: selectedLogGroups.map((logGroupName) => logGroupName.value!) ?? [], + }); + }; + + setCustomLogGroups = (v: string) => { + const customLogGroup: SelectableValue = { value: v, label: v }; + const selectedLogGroups = [...this.state.selectedLogGroups, customLogGroup]; + this.setSelectedLogGroups(selectedLogGroups); + }; + + setSelectedRegion = async (v: SelectableValue) => { + this.setState({ + selectedRegion: v, + loadingLogGroups: true, + }); + + const logGroups = await this.fetchLogGroupOptions(v.value!); + + this.setState((state) => { + const selectedLogGroups = intersectionBy(state.selectedLogGroups, logGroups, 'value'); + + const { onChange, query } = this.props; + if (onChange) { + const nextQuery = { + ...query, + region: v.value ?? 'default', + logGroupNames: selectedLogGroups.map((group) => group.value!), + }; + + onChange(nextQuery); + } + return { + availableLogGroups: logGroups, + selectedLogGroups: selectedLogGroups, + loadingLogGroups: false, + }; + }); + }; + + onTypeahead = async (typeahead: TypeaheadInput): Promise => { + const { datasource } = this.props; + const { selectedLogGroups } = this.state; + + if (!datasource.languageProvider) { + return { suggestions: [] }; + } + + const cloudwatchLanguageProvider = datasource.languageProvider as CloudWatchLanguageProvider; + const { history, absoluteRange } = this.props; + const { prefix, text, value, wrapperClasses, labelKey, editor } = typeahead; + + return await cloudwatchLanguageProvider.provideCompletionItems( + { text, value, prefix, wrapperClasses, labelKey, editor }, + { history, absoluteRange, logGroupNames: selectedLogGroups.map((logGroup) => logGroup.value!) } + ); + }; + + onQueryFieldClick = (_event: Event, _editor: Editor, next: () => any) => { + const { selectedLogGroups, loadingLogGroups } = this.state; + + const queryFieldDisabled = loadingLogGroups || selectedLogGroups.length === 0; + + if (queryFieldDisabled) { + this.setState({ + invalidLogGroups: true, + }); + } + + next(); + }; + + onOpenLogGroupMenu = () => { + this.setState({ + invalidLogGroups: false, + }); + }; + + render() { + const { ExtraFieldElement, data, query, datasource, allowCustomValue } = this.props; + const { + selectedLogGroups, + availableLogGroups, + regions, + selectedRegion, + loadingLogGroups, + hint, + invalidLogGroups, + } = this.state; + + const showError = data && data.error && data.error.refId === query.refId; + const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined; + + const MAX_LOG_GROUPS = 20; + + return ( + <> +
    + this.setSelectedRegion(v)} + width={18} + placeholder="Choose Region" + maxMenuHeight={500} + /> + } + /> + + { + this.setSelectedLogGroups(v); + }} + onCreateOption={(v) => { + this.setCustomLogGroups(v); + }} + className={containerClass} + closeMenuOnSelect={false} + isClearable={true} + invalid={invalidLogGroups} + isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS} + placeholder="Choose Log Groups" + maxVisibleValues={4} + noOptionsMessage="No log groups available" + isLoading={loadingLogGroups} + onOpenMenu={this.onOpenLogGroupMenu} + onInputChange={(value, actionMeta) => { + this.onLogGroupSearchDebounced(value, selectedRegion.value ?? 'default', actionMeta); + }} + /> + } + /> +
    +
    +
    + +
    + {ExtraFieldElement} +
    + {hint && ( +
    +
    + {hint.message} + + {hint.fix.label} + +
    +
    + )} + {showError ? ( +
    +
    {data?.error?.message}
    +
    + ) : null} + + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx new file mode 100644 index 0000000..7cf91a3 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor'; +import { CloudWatchDatasource } from '../datasource'; +import { CustomVariableModel, initialVariableModelState } from '../../../../features/variables/types'; +import { CloudWatchJsonData } from '../types'; + +const setup = () => { + const instanceSettings = { + jsonData: { defaultRegion: 'us-east-1' }, + } as DataSourceInstanceSettings; + + const templateSrv = new TemplateSrv(); + const variable: CustomVariableModel = { + ...initialVariableModelState, + id: 'var3', + index: 0, + name: 'var3', + options: [ + { selected: true, value: 'var3-foo', text: 'var3-foo' }, + { selected: false, value: 'var3-bar', text: 'var3-bar' }, + { selected: true, value: 'var3-baz', text: 'var3-baz' }, + ], + current: { selected: true, value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz' }, + multi: true, + includeAll: false, + query: '', + type: 'custom', + }; + templateSrv.init([variable]); + + const datasource = new CloudWatchDatasource(instanceSettings, templateSrv as any, {} as any); + datasource.metricFindQuery = async () => [{ value: 'test', label: 'test', text: 'test' }]; + + const props: Props = { + query: { + queryMode: 'Metrics', + refId: '', + id: '', + region: 'us-east-1', + namespace: 'ec2', + metricName: 'CPUUtilization', + dimensions: { somekey: 'somevalue' }, + statistics: [], + period: '', + expression: '', + alias: '', + matchExact: true, + }, + datasource, + history: [], + onChange: jest.fn(), + onRunQuery: jest.fn(), + }; + + return props; +}; + +describe('QueryEditor', () => { + it('should render component', async () => { + const { act } = renderer; + await act(async () => { + const props = setup(); + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + }); + + it('normalizes query on mount', async () => { + const { act } = renderer; + const props = setup(); + // This does not actually even conform to the prop type but this happens on initialisation somehow + props.query = { + queryMode: 'Metrics', + apiMode: 'Metrics', + refId: '', + expression: '', + matchExact: true, + } as any; + await act(async () => { + renderer.create(); + }); + expect((props.onChange as jest.Mock).mock.calls[0][0]).toEqual({ + namespace: '', + metricName: '', + expression: '', + dimensions: {}, + region: 'default', + id: '', + alias: '', + statistics: ['Average'], + period: '', + queryMode: 'Metrics', + apiMode: 'Metrics', + refId: '', + matchExact: true, + }); + }); + + describe('should use correct default values', () => { + it('when region is null is display default in the label', async () => { + // @ts-ignore strict null error TS2345: Argument of type '() => Promise' is not assignable to parameter of type '() => void | undefined'. + await act(async () => { + const props = setup(); + props.query.region = (null as unknown) as string; + const wrapper = mount(); + expect( + wrapper.find('.gf-form-inline').first().find('Segment').find('InlineLabel').find('label').text() + ).toEqual('default'); + }); + }); + + it('should normalize query with default values', () => { + expect(normalizeQuery({ refId: '42' } as any)).toEqual({ + namespace: '', + metricName: '', + expression: '', + dimensions: {}, + region: 'default', + id: '', + alias: '', + statistics: ['Average'], + matchExact: true, + period: '', + refId: '42', + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx new file mode 100644 index 0000000..857e14c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx @@ -0,0 +1,195 @@ +import React, { PureComponent, ChangeEvent } from 'react'; +import isEmpty from 'lodash/isEmpty'; + +import { ExploreQueryFieldProps } from '@grafana/data'; +import { LegacyForms, ValidationEvents, EventsWithValidation, Icon } from '@grafana/ui'; +const { Input, Switch } = LegacyForms; +import { CloudWatchQuery, CloudWatchMetricsQuery, CloudWatchJsonData } from '../types'; +import { CloudWatchDatasource } from '../datasource'; +import { QueryField, Alias, MetricsQueryFieldsEditor } from './'; + +export type Props = ExploreQueryFieldProps; + +interface State { + showMeta: boolean; +} + +const idValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: (value) => new RegExp(/^$|^[a-z][a-zA-Z0-9_]*$/).test(value), + errorMessage: 'Invalid format. Only alphanumeric characters and underscores are allowed', + }, + ], +}; + +export const normalizeQuery = ({ + namespace, + metricName, + expression, + dimensions, + region, + id, + alias, + statistics, + period, + ...rest +}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => { + const normalizedQuery = { + namespace: namespace || '', + metricName: metricName || '', + expression: expression || '', + dimensions: dimensions || {}, + region: region || 'default', + id: id || '', + alias: alias || '', + statistics: isEmpty(statistics) ? ['Average'] : statistics, + period: period || '', + ...rest, + }; + return !rest.hasOwnProperty('matchExact') ? { ...normalizedQuery, matchExact: true } : normalizedQuery; +}; + +export class MetricsQueryEditor extends PureComponent { + state: State = { showMeta: false }; + + componentDidMount(): void { + const metricsQuery = this.props.query as CloudWatchMetricsQuery; + const query = normalizeQuery(metricsQuery); + this.props.onChange(query); + } + + onChange(query: CloudWatchMetricsQuery) { + const { onChange, onRunQuery } = this.props; + onChange(query); + onRunQuery(); + } + + render() { + const { data, onRunQuery } = this.props; + const metricsQuery = this.props.query as CloudWatchMetricsQuery; + const { showMeta } = this.state; + const query = normalizeQuery(metricsQuery); + const executedQueries = + data && data.series.length && data.series[0].meta && data.state === 'Done' + ? data.series[0].meta.executedQueryString + : null; + + return ( + <> + + {query.statistics.length <= 1 && ( +
    +
    + + ) => + this.onChange({ ...metricsQuery, id: event.target.value }) + } + validationEvents={idValidationEvents} + value={query.id} + /> + +
    +
    + + ) => + this.onChange({ ...metricsQuery, expression: event.target.value }) + } + /> + +
    +
    + )} +
    +
    + + ) => + this.onChange({ ...metricsQuery, period: event.target.value }) + } + /> + +
    +
    + + this.onChange({ ...metricsQuery, alias: value })} + /> + + + this.onChange({ + ...metricsQuery, + matchExact: !metricsQuery.matchExact, + }) + } + /> + +
    +
    +
    +
    + {showMeta && executedQueries && ( + + + + + + + + + + {JSON.parse(executedQueries).map(({ ID, Expression, Period }: any) => ( + + + + + + ))} + +
    Metric Data Query IDMetric Data Query ExpressionPeriod +
    {ID}{Expression}{Period}
    + )} +
    + + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryFieldsEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryFieldsEditor.tsx new file mode 100644 index 0000000..15d701d --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryFieldsEditor.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { Segment, SegmentAsync } from '@grafana/ui'; +import { CloudWatchMetricsQuery, SelectableStrings } from '../types'; +import { CloudWatchDatasource } from '../datasource'; +import { Dimensions, QueryInlineField, Stats } from '.'; + +export type Props = { + query: CloudWatchMetricsQuery; + datasource: CloudWatchDatasource; + onRunQuery?: () => void; + onChange: (value: CloudWatchMetricsQuery) => void; +}; + +interface State { + regions: SelectableStrings; + namespaces: SelectableStrings; + metricNames: SelectableStrings; + variableOptionGroup: SelectableValue; + showMeta: boolean; +} + +export function MetricsQueryFieldsEditor({ + query, + datasource, + onChange, + onRunQuery = () => {}, +}: React.PropsWithChildren) { + const metricsQuery = query as CloudWatchMetricsQuery; + + const [state, setState] = useState({ + regions: [], + namespaces: [], + metricNames: [], + variableOptionGroup: {}, + showMeta: false, + }); + + useEffect(() => { + const variableOptionGroup = { + label: 'Template Variables', + options: datasource.getVariables().map(toOption), + }; + + Promise.all([datasource.metricFindQuery('regions()'), datasource.metricFindQuery('namespaces()')]).then( + ([regions, namespaces]) => { + setState({ + ...state, + regions: [...regions, variableOptionGroup], + namespaces: [...namespaces, variableOptionGroup], + variableOptionGroup, + }); + } + ); + }, []); + + const loadMetricNames = async () => { + const { namespace, region } = query; + return datasource.metricFindQuery(`metrics(${namespace},${region})`).then(appendTemplateVariables); + }; + + const appendTemplateVariables = (values: SelectableValue[]) => [ + ...values, + { label: 'Template Variables', options: datasource.getVariables().map(toOption) }, + ]; + + const toOption = (value: any) => ({ label: value, value }); + + const onQueryChange = (query: CloudWatchMetricsQuery) => { + onChange(query); + onRunQuery(); + }; + + // Load dimension values based on current selected dimensions. + // Remove the new dimension key and all dimensions that has a wildcard as selected value + const loadDimensionValues = (newKey: string) => { + const { [newKey]: value, ...dim } = metricsQuery.dimensions; + const newDimensions = Object.entries(dim).reduce( + (result, [key, value]) => (value === '*' ? result : { ...result, [key]: value }), + {} + ); + return datasource + .getDimensionValues(query.region, query.namespace, metricsQuery.metricName, newKey, newDimensions) + .then((values) => (values.length ? [{ value: '*', text: '*', label: '*' }, ...values] : values)) + .then(appendTemplateVariables); + }; + + const { regions, namespaces, variableOptionGroup } = state; + return ( + <> + + onQueryChange({ ...query, region: region! })} + /> + + + {query.expression?.length === 0 && ( + <> + + onQueryChange({ ...query, namespace: namespace! })} + /> + + + + onQueryChange({ ...metricsQuery, metricName })} + /> + + + + onQueryChange({ ...metricsQuery, statistics })} + variableOptionGroup={variableOptionGroup} + /> + + + + onQueryChange({ ...metricsQuery, dimensions })} + loadKeys={() => datasource.getDimensionKeys(query.namespace, query.region).then(appendTemplateVariables)} + loadValues={loadDimensionValues} + /> + + + )} + + ); +} diff --git a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx new file mode 100644 index 0000000..295b4c3 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import pick from 'lodash/pick'; +import { ExploreQueryFieldProps, ExploreMode } from '@grafana/data'; +import { Segment } from '@grafana/ui'; +import { CloudWatchJsonData, CloudWatchQuery } from '../types'; +import { CloudWatchDatasource } from '../datasource'; +import { QueryInlineField } from './'; +import { MetricsQueryEditor } from './MetricsQueryEditor'; +import LogsQueryEditor from './LogsQueryEditor'; + +export type Props = ExploreQueryFieldProps; + +const apiModes = { + Metrics: { label: 'CloudWatch Metrics', value: 'Metrics' }, + Logs: { label: 'CloudWatch Logs', value: 'Logs' }, +}; + +export class PanelQueryEditor extends PureComponent { + render() { + const { query } = this.props; + const apiMode = query.queryMode ?? 'Metrics'; + + return ( + <> + + { + const newMode = (value as 'Metrics' | 'Logs') ?? 'Metrics'; + if (newMode !== apiModes[apiMode].value) { + const commonProps = pick( + query, + 'id', + 'region', + 'namespace', + 'refId', + 'hide', + 'key', + 'queryType', + 'datasource' + ); + + this.props.onChange({ + ...commonProps, + queryMode: newMode, + } as CloudWatchQuery); + } + }} + /> + + {apiMode === ExploreMode.Logs ? ( + + ) : ( + + )} + + ); + } +} diff --git a/public/app/plugins/datasource/cloudwatch/components/Stats.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Stats.test.tsx new file mode 100644 index 0000000..c698c45 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Stats.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Stats } from './Stats'; + +const toOption = (value: any) => ({ label: value, value }); + +describe('Stats', () => { + it('should render component', () => { + render( + {}} + stats={['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'].map(toOption)} + /> + ); + expect(screen.getByText('Average')).toBeInTheDocument(); + expect(screen.getByText('Minimum')).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/Stats.tsx b/public/app/plugins/datasource/cloudwatch/components/Stats.tsx new file mode 100644 index 0000000..748b19f --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Stats.tsx @@ -0,0 +1,45 @@ +import React, { FunctionComponent } from 'react'; +import { SelectableStrings } from '../types'; +import { SelectableValue } from '@grafana/data'; +import { Segment, Icon } from '@grafana/ui'; + +export interface Props { + values: string[]; + onChange: (values: string[]) => void; + variableOptionGroup: SelectableValue; + stats: SelectableStrings; +} + +const removeText = '-- remove stat --'; +const removeOption: SelectableValue = { label: removeText, value: removeText }; + +export const Stats: FunctionComponent = ({ stats, values, onChange, variableOptionGroup }) => ( + <> + {values && + values.map((value, index) => ( + + onChange( + value === removeText + ? values.filter((_, i) => i !== index) + : values.map((v, i) => (i === index ? value! : v)) + ) + } + /> + ))} + + + + } + allowCustomValue + onChange={({ value }) => onChange([...values, value!])} + options={[...stats.filter(({ value }) => !values.includes(value!)), variableOptionGroup]} + /> + +); diff --git a/public/app/plugins/datasource/cloudwatch/components/ThrottlingErrorMessage.tsx b/public/app/plugins/datasource/cloudwatch/components/ThrottlingErrorMessage.tsx new file mode 100644 index 0000000..0e65aeb --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/ThrottlingErrorMessage.tsx @@ -0,0 +1,29 @@ +import React, { FunctionComponent } from 'react'; + +export interface Props { + region: string; +} + +export const ThrottlingErrorMessage: FunctionComponent = ({ region }) => ( +

    + Please visit the  + + AWS Service Quotas console + +  to request a quota increase or see our  + + documentation + +  to learn more. +

    +); diff --git a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/Alias.test.tsx.snap b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/Alias.test.tsx.snap new file mode 100644 index 0000000..0b5ae81 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/Alias.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alias should render component 1`] = ` +
    + +
    +`; diff --git a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap new file mode 100644 index 0000000..201c006 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap @@ -0,0 +1,316 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should disable access key id field 1`] = ` + + + + + + + +`; + +exports[`Render should render component 1`] = ` + + + + + + + +`; + +exports[`Render should show access key and secret access key fields 1`] = ` + + + + + + + +`; + +exports[`Render should show arn role field 1`] = ` + + + + + + + +`; + +exports[`Render should show credentials profile name field 1`] = ` + + + + + + + +`; diff --git a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/MetricsQueryEditor.test.tsx.snap b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/MetricsQueryEditor.test.tsx.snap new file mode 100644 index 0000000..d977a61 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/MetricsQueryEditor.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QueryEditor should render component 1`] = `null`; diff --git a/public/app/plugins/datasource/cloudwatch/components/index.ts b/public/app/plugins/datasource/cloudwatch/components/index.ts new file mode 100644 index 0000000..035b82d --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/index.ts @@ -0,0 +1,7 @@ +export { Stats } from './Stats'; +export { Dimensions } from './Dimensions'; +export { QueryInlineField, QueryField } from './Forms'; +export { Alias } from './Alias'; +export { MetricsQueryFieldsEditor } from './MetricsQueryFieldsEditor'; +export { PanelQueryEditor } from './PanelQueryEditor'; +export { CloudWatchLogsQueryEditor } from './LogsQueryEditor'; diff --git a/public/app/plugins/datasource/cloudwatch/dashboards/EBS.json b/public/app/plugins/datasource/cloudwatch/dashboards/EBS.json new file mode 100644 index 0000000..67fdda9 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dashboards/EBS.json @@ -0,0 +1,1370 @@ +{ + "__inputs": [ + { + "name": "DS_CLOUDWATCH", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + } + ], + "__requires": [ + { + "type": "datasource", + "id": "cloudwatch", + "name": "CloudWatch", + "version": "1.0.0" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.6.0-pre" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualize Amazon EBS metrics", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1574322143223, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Write", + "transform": "negative-Y" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "Read", + "dimensions": { + "VolumeId": "*" + }, + "expression": "SUM(REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeReadBytes\"', 'Sum', $period)))/$period", + "id": "", + "matchExact": true, + "metricName": "VolumeReadBytes", + "namespace": "AWS/EBS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + }, + { + "alias": "Write", + "dimensions": { + "VolumeId": "*" + }, + "expression": "SUM(REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeWriteBytes\"', 'Sum', $period)))/$period", + "id": "", + "matchExact": true, + "metricName": "VolumeReadBytes", + "namespace": "AWS/EBS", + "period": "$period", + "refId": "B", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total volumes read (+) and write (-) [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "VolumeId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeReadBytes\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "VolumeReadBytes", + "namespace": "AWS/EBS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume READ per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 9 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "VolumeId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeWriteBytes\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "VolumeReadBytes", + "namespace": "AWS/EBS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume WRITE per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 18 + }, + "hiddenSeries": false, + "id": 13, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeReadOps\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume READ per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 18 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeWriteOps\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume WRITE per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 27 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeTotalReadTime\"', 'Sum', $period))", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume READ per instance [total sec in this period]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 27 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeTotalWriteTime\"', 'Sum', $period))", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume WRITE per instance [total sec in this period]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 36 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeIdleTime\"', 'Sum', $period))/$period*100", + "hide": false, + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume idle time per instance [%]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "percent", + "label": null, + "logBase": 1, + "max": "100", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 36 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeQueueLength\"', 'Sum', $period))", + "hide": false, + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume queue length [number of requests]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 45 + }, + "hiddenSeries": false, + "id": 21, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeThroughputPercentage\"', 'Average', $period))", + "hide": false, + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume throughput per instance [% IOPS delivered/provisioned]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 45 + }, + "hiddenSeries": false, + "id": 22, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"VolumeConsumedReadWriteOps\"', 'Sum', $period))", + "hide": false, + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Volume consumed read write ops per instance [count of 256K capacity units]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 54 + }, + "hiddenSeries": false, + "id": 23, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": {}, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EBS,VolumeId} MetricName=\"BurstBalance\"', 'Sum', $period))", + "hide": false, + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Burst balance per instance [%]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "For more information, see [Amazon CloudWatch Metrics for Amazon EBS](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using_cloudwatch_ebs.html).\n\n\n\n", + "datasource": "$datasource", + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 63 + }, + "id": 20, + "mode": "markdown", + "options": {}, + "timeFrom": null, + "timeShift": null, + "title": "Info", + "type": "text" + } + ], + "refresh": false, + "schemaVersion": 21, + "style": "dark", + "tags": ["Amazon", "AWS", "CloudWatch", "EBS"], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "hide": 0, + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "query": "cloudwatch", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": null, + "current": { + "text": "default", + "value": "default" + }, + "datasource": "$datasource", + "definition": "regions()", + "hide": 0, + "includeAll": false, + "label": "Region", + "multi": false, + "name": "region", + "options": [], + "query": "regions()", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "300", + "value": "300" + }, + "hide": 0, + "includeAll": false, + "label": "Period [sec]", + "multi": false, + "name": "period", + "options": [ + { + "selected": false, + "text": "60", + "value": "60" + }, + { + "selected": true, + "text": "300", + "value": "300" + }, + { + "selected": false, + "text": "3600", + "value": "3600" + } + ], + "query": "60,300,3600", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Amazon EBS", + "uid": "MP7gaHbZz", + "version": 1 +} diff --git a/public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json b/public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json new file mode 100644 index 0000000..9b76536 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dashboards/Lambda.json @@ -0,0 +1,976 @@ +{ + "__inputs": [ + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + }, + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + } + ], + "__requires": [ + { + "type": "datasource", + "id": "cloudwatch", + "name": "CloudWatch", + "version": "1.0.0" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.6.0-pre" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualize AWS Lambda metrics", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1574322239729, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "FunctionName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Lambda} MetricName=\"Invocations\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total invocations [count/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 7 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "hide": true, + "id": "errors", + "matchExact": true, + "metricName": "Errors", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + }, + { + "alias": "", + "dimensions": {}, + "expression": "", + "hide": true, + "id": "invocations", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "B", + "region": "$region", + "statistics": ["Sum"] + }, + { + "alias": "", + "dimensions": {}, + "expression": "errors / invocations", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "C", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total error rate [errors/invocations]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 14 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "hide": true, + "id": "throttles", + "matchExact": true, + "metricName": "Throttles", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + }, + { + "alias": "", + "dimensions": {}, + "expression": "", + "hide": true, + "id": "invocations", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "B", + "region": "$region", + "statistics": ["Sum"] + }, + { + "alias": "", + "dimensions": {}, + "expression": "throttles / invocations", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "C", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total throttle rate [throttles/invocations]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 9, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "max", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "FunctionName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Lambda,FunctionName} MetricName=\"Duration\"', 'Average', $period))", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Duration per function [average time] Log scale", + "tooltip": { + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 10, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 10 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "max", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "FunctionName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Lambda,FunctionName} MetricName=\"Invocations\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Invocations per function [count/sec]", + "tooltip": { + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 19 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "max", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "FunctionName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Lambda,FunctionName} MetricName=\"Errors\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Errors per function [count/sec]", + "tooltip": { + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 28 + }, + "hiddenSeries": false, + "id": 13, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "max", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "FunctionName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Lambda,FunctionName} MetricName=\"Throttles\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "Invocations", + "namespace": "AWS/Lambda", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Throttles per function [count/sec]", + "tooltip": { + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Per function details", + "type": "row" + }, + { + "collapsed": false, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 20, + "panels": [], + "title": "Information", + "type": "row" + }, + { + "content": "For more information about the available metrics for AWS Lambda, visit the [AWS Lambda documentation](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-functions-metrics.html).", + "datasource": "$datasource", + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 18, + "mode": "markdown", + "options": {}, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "refId": "A", + "region": "default", + "statistics": ["Average"] + } + ], + "timeFrom": null, + "timeShift": null, + "title": " ", + "type": "text" + } + ], + "refresh": false, + "schemaVersion": 21, + "style": "dark", + "tags": ["Amazon", "AWS", "CloudWatch", "Lambda"], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "hide": 0, + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "query": "cloudwatch", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": null, + "current": { + "text": "default", + "value": "default" + }, + "datasource": "$datasource", + "definition": "regions()", + "hide": 0, + "includeAll": false, + "label": "Region", + "multi": false, + "name": "region", + "options": [], + "query": "regions()", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "300", + "value": "300" + }, + "hide": 0, + "includeAll": false, + "label": "Period [sec]", + "multi": false, + "name": "period", + "options": [ + { + "selected": false, + "text": "60", + "value": "60" + }, + { + "selected": true, + "text": "300", + "value": "300" + }, + { + "selected": false, + "text": "3600", + "value": "3600" + } + ], + "query": "60,300,3600", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "AWS Lambda", + "uid": "iUna6IxZz", + "version": 1 +} diff --git a/public/app/plugins/datasource/cloudwatch/dashboards/Logs.json b/public/app/plugins/datasource/cloudwatch/dashboards/Logs.json new file mode 100644 index 0000000..90d211f --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dashboards/Logs.json @@ -0,0 +1,784 @@ +{ + "__inputs": [ + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + } + ], + "__requires": [ + { + "type": "datasource", + "id": "cloudwatch", + "name": "CloudWatch", + "version": "1.0.0" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.6.0-pre" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualize Amazon CloudWatch Log based metrics", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1574321829202, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "LogGroupName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Logs,LogGroupName} MetricName=\"IncomingLogEvents\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "IncomingLogEvents", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Incoming log events [count/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "hiddenSeries": false, + "id": 3, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "LogGroupName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Logs,LogGroupName} MetricName=\"IncomingBytes\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "IncomingLogEvents", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Incoming bytes [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 16 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "LogGroupName": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "DeliveryErrors", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Delivery errors [count]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 16 + }, + "hiddenSeries": false, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "LogGroupName": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "DeliveryThrottling", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Delivery throttling [count]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 25 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "LogGroupName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Logs,LogGroupName} MetricName=\"ForwardedLogEvents\"', 'Sum', $period))/$period", + "matchExact": true, + "metricName": "ForwardedLogEvents", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Forwarded log events [count/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 25 + }, + "hiddenSeries": false, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "LogGroupName": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/Logs,LogGroupName} MetricName=\"ForwardedBytes\"', 'Sum', $period))/$period", + "matchExact": true, + "metricName": "ForwardedBytes", + "namespace": "AWS/Logs", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Forwarded bytes [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "For more information about the available metrics for Amazon CloudWatch Logs, please visit the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html).", + "datasource": "$datasource", + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 9, + "mode": "markdown", + "options": {}, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "refId": "A", + "region": "default", + "statistics": ["Average"] + } + ], + "timeFrom": null, + "timeShift": null, + "title": " ", + "type": "text" + } + ], + "refresh": false, + "schemaVersion": 21, + "style": "dark", + "tags": ["Amazon", "AWS", "CloudWatch", "Logs"], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "hide": 0, + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "query": "cloudwatch", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": null, + "current": { + "text": "default", + "value": "default" + }, + "datasource": "$datasource", + "definition": "regions()", + "hide": 0, + "includeAll": false, + "label": "Region", + "multi": false, + "name": "region", + "options": [], + "query": "regions()", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "300", + "value": "300" + }, + "hide": 0, + "includeAll": false, + "label": "Period [sec]", + "multi": false, + "name": "period", + "options": [ + { + "selected": false, + "text": "60", + "value": "60" + }, + { + "selected": true, + "text": "300", + "value": "300" + }, + { + "selected": false, + "text": "3600", + "value": "3600" + } + ], + "query": "60,300,3600", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Amazon CloudWatch Logs", + "uid": "5lKoAHxZz", + "version": 1 +} diff --git a/public/app/plugins/datasource/cloudwatch/dashboards/RDS.json b/public/app/plugins/datasource/cloudwatch/dashboards/RDS.json new file mode 100644 index 0000000..b088472 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dashboards/RDS.json @@ -0,0 +1,1525 @@ +{ + "__inputs": [ + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + }, + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + } + ], + "__requires": [ + { + "type": "datasource", + "id": "cloudwatch", + "name": "CloudWatch", + "version": "1.0.0" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.6.0-pre" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualize Amazon RDS metrics", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1574322211137, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 7, + "panels": [], + "title": "Cluster metrics", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 5, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBClusterIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "CPUUtilization", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Maximum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU utilization per cluster [%]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBClusterIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "DatabaseConnections", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Database connections [count sum]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "hiddenSeries": false, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBClusterIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "FreeableMemory", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Available RAM [bytes]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 2, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 5 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "CPUUtilization", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Maximum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU utilization per instance [%]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 13 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "DatabaseConnections", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Database connections [count sum]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 21 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "FreeStorageSpace", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Available storage space [bytes]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 21 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "FreeableMemory", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Available RAM [bytes]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 29 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "ReadThroughput", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Read throughput [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 29 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "WriteThroughput", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Write throughput [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 37 + }, + "hiddenSeries": false, + "id": 13, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "ReadLatency", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Maximum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Read latency [sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 37 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "WriteLatency", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Maximum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Write latency [sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 45 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "ReadIOPS", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Read operations [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 45 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "dimensions": { + "DBInstanceIdentifier": "*" + }, + "expression": "", + "matchExact": true, + "metricName": "WriteIOPS", + "namespace": "AWS/RDS", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Write operations [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Instance metrics", + "type": "row" + }, + { + "collapsed": false, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 22, + "panels": [], + "title": "Information", + "type": "row" + }, + { + "content": "For more information about the available Amazon RDS metrics, please visit the [Amazon RDS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/MonitoringOverview.html#rds-metrics).", + "datasource": "$datasource", + "gridPos": { + "h": 2, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 20, + "mode": "markdown", + "options": {}, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "refId": "A", + "region": "default", + "statistics": ["Average"] + } + ], + "timeFrom": null, + "timeShift": null, + "title": " ", + "type": "text" + } + ], + "schemaVersion": 21, + "style": "dark", + "tags": ["Amazon", "AWS", "CloudWatch", "RDS"], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "hide": 0, + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "query": "cloudwatch", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": null, + "current": { + "text": "default", + "value": "default" + }, + "datasource": "$datasource", + "definition": "regions()", + "hide": 0, + "includeAll": false, + "label": "Region", + "multi": false, + "name": "region", + "options": [], + "query": "regions()", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "300", + "value": "300" + }, + "hide": 0, + "includeAll": false, + "label": "Period [sec]", + "multi": false, + "name": "period", + "options": [ + { + "selected": false, + "text": "60", + "value": "60" + }, + { + "selected": true, + "text": "300", + "value": "300" + }, + { + "selected": false, + "text": "3600", + "value": "3600" + } + ], + "query": "60,300,3600", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Amazon RDS", + "uid": "HdeaRHxWz", + "version": 1 +} diff --git a/public/app/plugins/datasource/cloudwatch/dashboards/ec2.json b/public/app/plugins/datasource/cloudwatch/dashboards/ec2.json new file mode 100644 index 0000000..483cc28 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dashboards/ec2.json @@ -0,0 +1,1594 @@ +{ + "__inputs": [ + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + }, + { + "name": "DS_CLOUDWATCH", + "label": "CloudWatch", + "description": "", + "type": "datasource", + "pluginId": "cloudwatch", + "pluginName": "CloudWatch" + } + ], + "__requires": [ + { + "type": "datasource", + "id": "cloudwatch", + "name": "CloudWatch", + "version": "1.0.0" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.6.0-pre" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualize Amazon EC2 metrics", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "iteration": 1574322183934, + "links": [], + "panels": [ + { + "collapsed": false, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 14, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "CPUUtilization", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "B", + "region": "$region", + "statistics": ["Maximum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU utilization per instance [max %]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "percent", + "label": "", + "logBase": 1, + "max": "100", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 7, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Inbound", + "transform": "negative-Y" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "Inbound", + "dimensions": { + "InstanceId": "*" + }, + "expression": "SUM(REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkIn\"', 'Sum', $period)))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkOut", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "B", + "region": "$region", + "statistics": ["Average"] + }, + { + "alias": "Outbound", + "dimensions": { + "InstanceId": "*" + }, + "expression": "SUM(REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkOut\"', 'Sum', $period)))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkOut", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "C", + "region": "$region", + "statistics": ["Average"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Total network traffic outbound (+) and inbound (-) [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 16, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 19 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkIn\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkIn", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Inbound network traffic per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 19 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkOut\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkIn", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Outbound network traffic per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 28 + }, + "hiddenSeries": false, + "id": 9, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkPacketsIn\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkPacketsIn", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Inbound network packets per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 28 + }, + "hiddenSeries": false, + "id": 19, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkPacketsOut\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "NetworkPacketsIn", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Outbound network packets per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Network details", + "type": "row" + }, + { + "collapsed": true, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 21, + "panels": [ + { + "content": "", + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 30, + "mode": "markdown", + "options": {}, + "timeFrom": null, + "timeShift": null, + "title": "The following metrics are reported for EC2 Instance Store Volumes. For Amazon EBS volumes, see the EBS dashboard.", + "type": "text" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 39 + }, + "hiddenSeries": false, + "id": 3, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"DiskReadBytes\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "DiskReadBytes", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Disk READ per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 39 + }, + "hiddenSeries": false, + "id": 22, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"DiskWriteBytes\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "DiskReadBytes", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Disk WRITE per instance [bytes/sec]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 48 + }, + "hiddenSeries": false, + "id": 24, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"DiskReadOps\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "DiskReadBytes", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Disk READ per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 48 + }, + "hiddenSeries": false, + "id": 23, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "{{label}}", + "dimensions": { + "InstanceId": "*" + }, + "expression": "REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"DiskWriteOps\"', 'Sum', $period))/$period", + "id": "", + "matchExact": true, + "metricName": "DiskReadBytes", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Disk WRITE per instance [IOPS]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Disk details", + "type": "row" + }, + { + "collapsed": true, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 20 + }, + "id": 26, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 58 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "StatusCheckFailed", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Status check failed [Sum]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 64 + }, + "hiddenSeries": false, + "id": 27, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "StatusCheckFailed", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Instance status check failed [Sum]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "description": "", + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 70 + }, + "hiddenSeries": false, + "id": 28, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "connected", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "alias": "", + "dimensions": { + "InstanceId": "*" + }, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "StatusCheckFailed", + "namespace": "AWS/EC2", + "period": "$period", + "refId": "A", + "region": "$region", + "statistics": ["Sum"] + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "System status check failed [Sum]", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "Status Checks", + "type": "row" + }, + { + "collapsed": false, + "datasource": "$datasource", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 32, + "panels": [], + "title": "Information", + "type": "row" + }, + { + "content": "For more information about avaialble metrics from Amazon EC2, visit the [List of Available CloudWatch Metrics for Your EC2 Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/viewing_metrics_with_cloudwatch.html) in the Amazon documentation.\n\nFor additional metrics and 1-minute granularity, enable [Detailed Monitoring](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html) for your Amazon EC2 instances.\n\nThis dashboard has been optimized for 1-minute metrics.", + "datasource": "$datasource", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 34, + "mode": "markdown", + "options": {}, + "targets": [ + { + "alias": "", + "dimensions": {}, + "expression": "", + "id": "", + "matchExact": true, + "metricName": "", + "namespace": "", + "refId": "A", + "region": "default", + "statistics": ["Average"] + } + ], + "timeFrom": null, + "timeShift": null, + "title": " ", + "type": "text" + } + ], + "refresh": false, + "schemaVersion": 21, + "style": "dark", + "tags": ["Amazon", "AWS", "CloudWatch", "EC2"], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "hide": 0, + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "query": "cloudwatch", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": null, + "current": {}, + "datasource": "$datasource", + "definition": "regions()", + "hide": 0, + "includeAll": false, + "label": "Region", + "multi": false, + "name": "region", + "options": [], + "query": "regions()", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": { + "selected": true, + "tags": [], + "text": "300", + "value": "300" + }, + "hide": 0, + "includeAll": false, + "label": "Period [sec]", + "multi": false, + "name": "period", + "options": [ + { + "selected": false, + "text": "60", + "value": "60" + }, + { + "selected": false, + "text": "300", + "value": "300" + }, + { + "selected": true, + "text": "3600", + "value": "3600" + } + ], + "query": "60,300,3600", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Amazon EC2", + "uid": "tmsOtSxZk", + "version": 1 +} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.d.ts b/public/app/plugins/datasource/cloudwatch/datasource.d.ts new file mode 100644 index 0000000..23503de --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/datasource.d.ts @@ -0,0 +1,2 @@ +declare var CloudWatchDatasource: any; +export default CloudWatchDatasource; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts new file mode 100644 index 0000000..67b675a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -0,0 +1,94 @@ +import { of } from 'rxjs'; +import { setBackendSrv } from '@grafana/runtime'; +import { dateTime, getDefaultTimeRange } from '@grafana/data'; + +import { TemplateSrv } from '../../../features/templating/template_srv'; +import { CloudWatchDatasource } from './datasource'; + +describe('datasource', () => { + describe('query', () => { + it('should return error if log query and log groups is not specified', async () => { + const { datasource } = setup(); + const observable = datasource.query({ + targets: [ + { + queryMode: 'Logs' as 'Logs', + }, + ], + } as any); + + await expect(observable).toEmitValuesWith((received) => { + const response = received[0]; + expect(response.error?.message).toBe('Log group is required'); + }); + }); + + it('should return empty response if queries are hidden', async () => { + const { datasource } = setup(); + const observable = datasource.query({ + targets: [ + { + queryMode: 'Logs' as 'Logs', + hide: true, + }, + ], + } as any); + + await expect(observable).toEmitValuesWith((received) => { + const response = received[0]; + expect(response.data).toEqual([]); + }); + }); + }); + + describe('performTimeSeriesQuery', () => { + it('should return the same length of data as result', async () => { + const { datasource } = setup({ + data: { + results: { + a: { refId: 'a', series: [{ name: 'cpu', points: [1, 1] }], meta: {} }, + b: { refId: 'b', series: [{ name: 'memory', points: [2, 2] }], meta: {} }, + }, + }, + }); + + const observable = datasource.performTimeSeriesQuery( + { + queries: [ + { datasourceId: 1, refId: 'a' }, + { datasourceId: 1, refId: 'b' }, + ], + } as any, + { from: dateTime(), to: dateTime() } as any + ); + + await expect(observable).toEmitValuesWith((received) => { + const response = received[0]; + expect(response.data.length).toEqual(2); + }); + }); + }); + + describe('describeLogGroup', () => { + it('replaces region correctly in the query', async () => { + const { datasource, fetchMock } = setup(); + await datasource.describeLogGroups({ region: 'default' }); + expect(fetchMock.mock.calls[0][0].data.queries[0].region).toBe('us-west-1'); + + await datasource.describeLogGroups({ region: 'eu-east' }); + expect(fetchMock.mock.calls[1][0].data.queries[0].region).toBe('eu-east'); + }); + }); +}); + +function setup({ data = [] }: { data?: any } = {}) { + const datasource = new CloudWatchDatasource({ jsonData: { defaultRegion: 'us-west-1' } } as any, new TemplateSrv(), { + timeRange() { + return getDefaultTimeRange(); + }, + } as any); + const fetchMock = jest.fn().mockReturnValue(of({ data })); + setBackendSrv({ fetch: fetchMock } as any); + + return { datasource, fetchMock }; +} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts new file mode 100644 index 0000000..35124ab --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -0,0 +1,1054 @@ +import React from 'react'; +import angular from 'angular'; +import _ from 'lodash'; +import { merge, Observable, of, throwError, zip } from 'rxjs'; +import { + catchError, + concatMap, + filter, + finalize, + map, + mergeMap, + repeat, + scan, + share, + takeWhile, + tap, +} from 'rxjs/operators'; +import { getBackendSrv, getGrafanaLiveSrv, toDataQueryResponse } from '@grafana/runtime'; +import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; +import { + DataFrame, + DataQueryErrorType, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + dateMath, + LiveChannelEvent, + LiveChannelMessageEvent, + LiveChannelScope, + LoadingState, + LogRowModel, + rangeUtil, + ScopedVars, + TimeRange, +} from '@grafana/data'; + +import { notifyApp } from 'app/core/actions'; +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { AppNotificationTimeout } from 'app/types'; +import { store } from 'app/store/store'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage'; +import memoizedDebounce from './memoizedDebounce'; +import { + CloudWatchJsonData, + CloudWatchLogsQuery, + CloudWatchLogsQueryStatus, + CloudWatchMetricsQuery, + CloudWatchQuery, + DescribeLogGroupsRequest, + GetLogEventsRequest, + GetLogGroupFieldsRequest, + GetLogGroupFieldsResponse, + isCloudWatchLogsQuery, + LogAction, + MetricQuery, + MetricRequest, + TSDBResponse, +} from './types'; +import { CloudWatchLanguageProvider } from './language_provider'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { AwsUrl, encodeUrl } from './aws_url'; +import { increasingInterval } from './utils/rxjs/increasingInterval'; +import config from 'app/core/config'; + +const TSDB_QUERY_ENDPOINT = '/api/tsdb/query'; + +// Constants also defined in tsdb/cloudwatch/cloudwatch.go +const LOG_IDENTIFIER_INTERNAL = '__log__grafana_internal__'; +const LOGSTREAM_IDENTIFIER_INTERNAL = '__logstream__grafana_internal__'; + +const displayAlert = (datasourceName: string, region: string) => + store.dispatch( + notifyApp( + createErrorNotification( + `CloudWatch request limit reached in ${region} for data source ${datasourceName}`, + '', + React.createElement(ThrottlingErrorMessage, { region }, null) + ) + ) + ); + +const displayCustomError = (title: string, message: string) => + store.dispatch(notifyApp(createErrorNotification(title, message))); + +export const MAX_ATTEMPTS = 5; + +export class CloudWatchDatasource extends DataSourceApi { + proxyUrl: any; + defaultRegion: any; + datasourceName: string; + languageProvider: CloudWatchLanguageProvider; + + type = 'cloudwatch'; + standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount']; + debouncedAlert: (datasourceName: string, region: string) => void = memoizedDebounce( + displayAlert, + AppNotificationTimeout.Error + ); + debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce( + displayCustomError, + AppNotificationTimeout.Error + ); + logQueries: Record = {}; + + constructor( + instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(instanceSettings); + this.proxyUrl = instanceSettings.url; + this.defaultRegion = instanceSettings.jsonData.defaultRegion; + this.datasourceName = instanceSettings.name; + + this.languageProvider = new CloudWatchLanguageProvider(this); + } + + query(options: DataQueryRequest): Observable { + options = angular.copy(options); + + let queries = options.targets.filter((item) => item.id !== '' || item.hide !== true); + const { logQueries, metricsQueries } = this.getTargetsByQueryMode(queries); + + const dataQueryResponses: Array> = []; + if (logQueries.length > 0) { + if (config.featureToggles.live) { + dataQueryResponses.push(this.handleLiveLogQueries(logQueries, options)); + } else { + dataQueryResponses.push(this.handleLogQueries(logQueries, options)); + } + } + + if (metricsQueries.length > 0) { + dataQueryResponses.push(this.handleMetricQueries(metricsQueries, options)); + } + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(dataQueryResponses)) { + return of({ + data: [], + state: LoadingState.Done, + }); + } + + return merge(...dataQueryResponses); + } + + handleLiveLogQueries = ( + logQueries: CloudWatchLogsQuery[], + options: DataQueryRequest + ): Observable => { + const validLogQueries = logQueries.filter((item) => item.logGroupNames?.length); + if (logQueries.length > validLogQueries.length) { + return of({ data: [], error: { message: 'Log group is required' } }); + } + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(validLogQueries)) { + return of({ data: [], state: LoadingState.Done }); + } + + const queryParams = validLogQueries.map((target: CloudWatchLogsQuery) => ({ + intervalMs: 1, // dummy + maxDataPoints: 1, // dummy + datasourceId: this.id, + queryString: this.replace(target.expression, options.scopedVars, true), + refId: target.refId, + logGroupNames: target.logGroupNames?.map((logGroup) => + this.replace(logGroup, options.scopedVars, true, 'log groups') + ), + statsGroups: target.statsGroups, + region: this.getActualRegion(this.replace(target.region, options.scopedVars, true, 'region')), + type: 'liveLogAction', + })); + + const range = this.timeSrv.timeRange(); + + const requestParams = { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: queryParams, + }; + + return this.awsRequest(TSDB_QUERY_ENDPOINT, requestParams).pipe( + mergeMap((response: TSDBResponse) => { + const channelName: string = response.results['A'].meta.channelName; + const channel = getGrafanaLiveSrv().getChannel({ + scope: LiveChannelScope.Plugin, + namespace: 'cloudwatch', + path: channelName, + }); + return channel.getStream(); + }), + filter((e: LiveChannelEvent) => e.type === 'message'), + map(({ message }: LiveChannelMessageEvent) => { + const dataQueryResponse = toDataQueryResponse({ + data: message, + }); + dataQueryResponse.state = dataQueryResponse.data.every((dataFrame) => + statusIsTerminated(dataFrame.meta?.custom?.['Status']) + ) + ? LoadingState.Done + : LoadingState.Loading; + dataQueryResponse.key = message.results[Object.keys(message.results)[0]].refId; + return this.addDataLinksToLogsResponse(dataQueryResponse, options); + }), + catchError((err) => { + if (err.data?.error) { + throw err.data.error; + } + + throw err; + }) + ); + }; + + handleLogQueries = ( + logQueries: CloudWatchLogsQuery[], + options: DataQueryRequest + ): Observable => { + const validLogQueries = logQueries.filter((item) => item.logGroupNames?.length); + if (logQueries.length > validLogQueries.length) { + return of({ data: [], error: { message: 'Log group is required' } }); + } + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(validLogQueries)) { + return of({ data: [], state: LoadingState.Done }); + } + + const queryParams = validLogQueries.map((target: CloudWatchLogsQuery) => ({ + queryString: target.expression, + refId: target.refId, + logGroupNames: target.logGroupNames, + region: this.replace(this.getActualRegion(target.region), options.scopedVars, true, 'region'), + })); + + return this.makeLogActionRequest('StartQuery', queryParams, options.scopedVars).pipe( + mergeMap((dataFrames) => + this.logsQuery( + dataFrames.map((dataFrame) => ({ + queryId: dataFrame.fields[0].values.get(0), + region: dataFrame.meta?.custom?.['Region'] ?? 'default', + refId: dataFrame.refId!, + statsGroups: (logQueries.find((target) => target.refId === dataFrame.refId)! as CloudWatchLogsQuery) + .statsGroups, + })) + ) + ), + map((response) => this.addDataLinksToLogsResponse(response, options)) + ); + }; + + handleMetricQueries = ( + metricQueries: CloudWatchMetricsQuery[], + options: DataQueryRequest + ): Observable => { + const validMetricsQueries = metricQueries + .filter( + (item) => + (!!item.region && !!item.namespace && !!item.metricName && !_.isEmpty(item.statistics)) || + item.expression?.length > 0 + ) + .map( + (item: CloudWatchMetricsQuery): MetricQuery => { + item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region'); + item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace'); + item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name'); + item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); + item.statistics = item.statistics.map((stat) => this.replace(stat, options.scopedVars, true, 'statistics')); + item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting + item.id = this.templateSrv.replace(item.id, options.scopedVars); + item.expression = this.templateSrv.replace(item.expression, options.scopedVars); + + // valid ExtendedStatistics is like p90.00, check the pattern + const hasInvalidStatistics = item.statistics.some((s) => { + if (s.indexOf('p') === 0) { + const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s); + return !matches || matches[0] !== s; + } + + return false; + }); + + if (hasInvalidStatistics) { + throw { message: 'Invalid extended statistics' }; + } + + return { + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + type: 'timeSeriesQuery', + ...item, + }; + } + ); + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(validMetricsQueries)) { + return of({ data: [] }); + } + + const request = { + from: options?.range?.from.valueOf().toString(), + to: options?.range?.to.valueOf().toString(), + queries: validMetricsQueries, + }; + + return this.performTimeSeriesQuery(request, options.range); + }; + + logsQuery( + queryParams: Array<{ + queryId: string; + refId: string; + limit?: number; + region: string; + statsGroups?: string[]; + }> + ): Observable { + this.logQueries = {}; + queryParams.forEach((param) => { + this.logQueries[param.refId] = { + id: param.queryId, + region: param.region, + statsQuery: (param.statsGroups?.length ?? 0) > 0 ?? false, + }; + }); + + const dataFrames = increasingInterval({ startPeriod: 100, endPeriod: 1000, step: 300 }).pipe( + concatMap((_) => this.makeLogActionRequest('GetQueryResults', queryParams)), + repeat(), + share() + ); + + const consecutiveFailedAttempts = dataFrames.pipe( + scan( + ({ failures, prevRecordsMatched }, frames) => { + failures++; + for (const frame of frames) { + const recordsMatched = frame.meta?.stats?.find((stat) => stat.displayName === 'Records scanned')?.value!; + if (recordsMatched > (prevRecordsMatched[frame.refId!] ?? 0)) { + failures = 0; + } + prevRecordsMatched[frame.refId!] = recordsMatched; + } + + return { failures, prevRecordsMatched }; + }, + { failures: 0, prevRecordsMatched: {} as Record } + ), + map(({ failures }) => failures), + share() + ); + + const queryResponse: Observable = zip(dataFrames, consecutiveFailedAttempts).pipe( + tap(([dataFrames]) => { + for (const frame of dataFrames) { + if ( + [ + CloudWatchLogsQueryStatus.Complete, + CloudWatchLogsQueryStatus.Cancelled, + CloudWatchLogsQueryStatus.Failed, + ].includes(frame.meta?.custom?.['Status']) && + this.logQueries.hasOwnProperty(frame.refId!) + ) { + delete this.logQueries[frame.refId!]; + } + } + }), + map(([dataFrames, failedAttempts]) => { + if (failedAttempts >= MAX_ATTEMPTS) { + for (const frame of dataFrames) { + _.set(frame, 'meta.custom.Status', CloudWatchLogsQueryStatus.Cancelled); + } + } + + return { + data: dataFrames, + key: 'test-key', + state: dataFrames.every((dataFrame) => + [ + CloudWatchLogsQueryStatus.Complete, + CloudWatchLogsQueryStatus.Cancelled, + CloudWatchLogsQueryStatus.Failed, + ].includes(dataFrame.meta?.custom?.['Status']) + ) + ? LoadingState.Done + : LoadingState.Loading, + error: + failedAttempts >= MAX_ATTEMPTS + ? { + message: `error: query timed out after ${MAX_ATTEMPTS} attempts`, + type: DataQueryErrorType.Timeout, + } + : undefined, + }; + }), + takeWhile(({ state }) => state !== LoadingState.Error && state !== LoadingState.Done, true) + ); + + return withTeardown(queryResponse, () => this.stopQueries()); + } + + private addDataLinksToLogsResponse(response: DataQueryResponse, options: DataQueryRequest) { + for (const dataFrame of response.data as DataFrame[]) { + const range = this.timeSrv.timeRange(); + const start = range.from.toISOString(); + const end = range.to.toISOString(); + + const curTarget = options.targets.find((target) => target.refId === dataFrame.refId) as CloudWatchLogsQuery; + const interpolatedGroups = + curTarget.logGroupNames?.map((logGroup: string) => + this.replace(logGroup, options.scopedVars, true, 'log groups') + ) ?? []; + const urlProps: AwsUrl = { + end, + start, + timeType: 'ABSOLUTE', + tz: 'UTC', + editorString: curTarget.expression ? this.replace(curTarget.expression, options.scopedVars, true) : '', + isLiveTail: false, + source: interpolatedGroups, + }; + + const encodedUrl = encodeUrl( + urlProps, + this.getActualRegion(this.replace(curTarget.region, options.scopedVars, true, 'region')) + ); + + for (const field of dataFrame.fields) { + field.config.links = [ + { + url: encodedUrl, + title: 'View in CloudWatch console', + targetBlank: true, + }, + ]; + } + } + + return response; + } + + stopQueries() { + if (Object.keys(this.logQueries).length > 0) { + this.makeLogActionRequest( + 'StopQuery', + Object.values(this.logQueries).map((logQuery) => ({ queryId: logQuery.id, region: logQuery.region })), + undefined, + false + ).pipe( + finalize(() => { + this.logQueries = {}; + }) + ); + } + } + + async describeLogGroups(params: DescribeLogGroupsRequest): Promise { + const dataFrames = await this.makeLogActionRequest('DescribeLogGroups', [params]).toPromise(); + + const logGroupNames = dataFrames[0]?.fields[0]?.values.toArray() ?? []; + return logGroupNames; + } + + async getLogGroupFields(params: GetLogGroupFieldsRequest): Promise { + const dataFrames = await this.makeLogActionRequest('GetLogGroupFields', [params]).toPromise(); + + const fieldNames = dataFrames[0].fields[0].values.toArray(); + const fieldPercentages = dataFrames[0].fields[1].values.toArray(); + const getLogGroupFieldsResponse = { + logGroupFields: fieldNames.map((val, i) => ({ name: val, percent: fieldPercentages[i] })) ?? [], + }; + + return getLogGroupFieldsResponse; + } + + getLogRowContext = async ( + row: LogRowModel, + { limit = 10, direction = 'BACKWARD' }: RowContextOptions = {} + ): Promise<{ data: DataFrame[] }> => { + let logStreamField = null; + let logField = null; + + for (const field of row.dataFrame.fields) { + if (field.name === LOGSTREAM_IDENTIFIER_INTERNAL) { + logStreamField = field; + if (logField !== null) { + break; + } + } else if (field.name === LOG_IDENTIFIER_INTERNAL) { + logField = field; + if (logStreamField !== null) { + break; + } + } + } + + const requestParams: GetLogEventsRequest = { + limit, + startFromHead: direction !== 'BACKWARD', + logGroupName: parseLogGroupName(logField!.values.get(row.rowIndex)), + logStreamName: logStreamField!.values.get(row.rowIndex), + }; + + if (direction === 'BACKWARD') { + requestParams.endTime = row.timeEpochMs; + } else { + requestParams.startTime = row.timeEpochMs; + } + + const dataFrames = await this.makeLogActionRequest('GetLogEvents', [requestParams]).toPromise(); + + return { + data: dataFrames, + }; + }; + + getVariables() { + return this.templateSrv.getVariables().map((v) => `$${v.name}`); + } + + getPeriod(target: CloudWatchMetricsQuery, options: any) { + let period = this.templateSrv.replace(target.period, options.scopedVars) as any; + if (period && period.toLowerCase() !== 'auto') { + if (/^\d+$/.test(period)) { + period = parseInt(period, 10); + } else { + period = rangeUtil.intervalToSeconds(period); + } + + if (period < 1) { + period = 1; + } + } + + return period || ''; + } + + performTimeSeriesQuery(request: MetricRequest, { from, to }: TimeRange): Observable { + return this.awsRequest(TSDB_QUERY_ENDPOINT, request).pipe( + map((res) => { + const dataframes: DataFrame[] = toDataQueryResponse({ data: res }).data; + if (!dataframes || dataframes.length <= 0) { + return { data: [] }; + } + + return { + data: dataframes, + error: Object.values(res.results).reduce((acc, curr) => (curr.error ? { message: curr.error } : acc), null), + }; + }), + catchError((err) => { + if (/^Throttling:.*/.test(err.data.message)) { + const failedRedIds = Object.keys(err.data.results); + const regionsAffected = Object.values(request.queries).reduce( + (res: string[], { refId, region }) => + (refId && !failedRedIds.includes(refId)) || res.includes(region) ? res : [...res, region], + [] + ) as string[]; + + regionsAffected.forEach((region) => this.debouncedAlert(this.datasourceName, this.getActualRegion(region))); + } + + if (err.data && err.data.message === 'Metric request error' && err.data.error) { + err.data.message = err.data.error; + } + + return throwError(err); + }) + ); + } + + transformSuggestDataFromTable(suggestData: TSDBResponse): Array<{ text: any; label: any; value: any }> { + return suggestData.results['metricFindQuery'].tables[0].rows.map(([text, value]) => ({ + text, + value, + label: value, + })); + } + + doMetricQueryRequest(subtype: string, parameters: any): Promise> { + const range = this.timeSrv.timeRange(); + return this.awsRequest(TSDB_QUERY_ENDPOINT, { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: [ + { + refId: 'metricFindQuery', + intervalMs: 1, // dummy + maxDataPoints: 1, // dummy + datasourceId: this.id, + type: 'metricFindQuery', + subtype: subtype, + ...parameters, + }, + ], + }) + .pipe( + map((r) => { + return this.transformSuggestDataFromTable(r); + }) + ) + .toPromise(); + } + + makeLogActionRequest( + subtype: LogAction, + queryParams: any[], + scopedVars?: ScopedVars, + makeReplacements = true + ): Observable { + const range = this.timeSrv.timeRange(); + + const requestParams = { + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + queries: queryParams.map((param: any) => ({ + refId: 'A', + intervalMs: 1, // dummy + maxDataPoints: 1, // dummy + datasourceId: this.id, + type: 'logAction', + subtype: subtype, + ...param, + })), + }; + + if (makeReplacements) { + requestParams.queries.forEach((query) => { + if (query.hasOwnProperty('queryString')) { + query.queryString = this.replace(query.queryString, scopedVars, true); + } + query.region = this.replace(query.region, scopedVars, true, 'region'); + query.region = this.getActualRegion(query.region); + + // interpolate log groups + if (query.logGroupNames) { + query.logGroupNames = query.logGroupNames.map((logGroup: string) => + this.replace(logGroup, scopedVars, true, 'log groups') + ); + } + }); + } + + const resultsToDataFrames = (val: any): DataFrame[] => toDataQueryResponse(val).data || []; + + return this.awsRequest(TSDB_QUERY_ENDPOINT, requestParams).pipe( + map((response) => resultsToDataFrames({ data: response })), + catchError((err) => { + if (err.data?.error) { + throw err.data.error; + } + + throw err; + }) + ); + } + + getRegions(): Promise> { + return this.doMetricQueryRequest('regions', null).then((regions: any) => [ + { label: 'default', value: 'default', text: 'default' }, + ...regions, + ]); + } + + getNamespaces() { + return this.doMetricQueryRequest('namespaces', null); + } + + async getMetrics(namespace: string, region?: string) { + if (!namespace) { + return []; + } + + return this.doMetricQueryRequest('metrics', { + region: this.templateSrv.replace(this.getActualRegion(region)), + namespace: this.templateSrv.replace(namespace), + }); + } + + async getDimensionKeys(namespace: string, region: string) { + if (!namespace) { + return []; + } + + return this.doMetricQueryRequest('dimension_keys', { + region: this.templateSrv.replace(this.getActualRegion(region)), + namespace: this.templateSrv.replace(namespace), + }); + } + + async getDimensionValues( + region: string, + namespace: string, + metricName: string, + dimensionKey: string, + filterDimensions: {} + ) { + if (!namespace || !metricName) { + return []; + } + + const values = await this.doMetricQueryRequest('dimension_values', { + region: this.templateSrv.replace(this.getActualRegion(region)), + namespace: this.templateSrv.replace(namespace), + metricName: this.templateSrv.replace(metricName.trim()), + dimensionKey: this.templateSrv.replace(dimensionKey), + dimensions: this.convertDimensionFormat(filterDimensions, {}), + }); + + return values; + } + + getEbsVolumeIds(region: string, instanceId: string) { + return this.doMetricQueryRequest('ebs_volume_ids', { + region: this.templateSrv.replace(this.getActualRegion(region)), + instanceId: this.templateSrv.replace(instanceId), + }); + } + + getEc2InstanceAttribute(region: string, attributeName: string, filters: any) { + return this.doMetricQueryRequest('ec2_instance_attribute', { + region: this.templateSrv.replace(this.getActualRegion(region)), + attributeName: this.templateSrv.replace(attributeName), + filters: filters, + }); + } + + getResourceARNs(region: string, resourceType: string, tags: any) { + return this.doMetricQueryRequest('resource_arns', { + region: this.templateSrv.replace(this.getActualRegion(region)), + resourceType: this.templateSrv.replace(resourceType), + tags: tags, + }); + } + + async metricFindQuery(query: string) { + let region; + let namespace; + let metricName; + let filterJson; + + const regionQuery = query.match(/^regions\(\)/); + if (regionQuery) { + return this.getRegions(); + } + + const namespaceQuery = query.match(/^namespaces\(\)/); + if (namespaceQuery) { + return this.getNamespaces(); + } + + const metricNameQuery = query.match(/^metrics\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (metricNameQuery) { + namespace = metricNameQuery[1]; + region = metricNameQuery[3]; + return this.getMetrics(namespace, region); + } + + const dimensionKeysQuery = query.match(/^dimension_keys\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (dimensionKeysQuery) { + namespace = dimensionKeysQuery[1]; + region = dimensionKeysQuery[3]; + return this.getDimensionKeys(namespace, region); + } + + const dimensionValuesQuery = query.match( + /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/ + ); + if (dimensionValuesQuery) { + region = dimensionValuesQuery[1]; + namespace = dimensionValuesQuery[2]; + metricName = dimensionValuesQuery[3]; + const dimensionKey = dimensionValuesQuery[4]; + filterJson = {}; + if (dimensionValuesQuery[6]) { + filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6])); + } + + return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson); + } + + const ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/); + if (ebsVolumeIdsQuery) { + region = ebsVolumeIdsQuery[1]; + const instanceId = ebsVolumeIdsQuery[2]; + return this.getEbsVolumeIds(region, instanceId); + } + + const ec2InstanceAttributeQuery = query.match(/^ec2_instance_attribute\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (ec2InstanceAttributeQuery) { + region = ec2InstanceAttributeQuery[1]; + const targetAttributeName = ec2InstanceAttributeQuery[2]; + filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3])); + return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson); + } + + const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (resourceARNsQuery) { + region = resourceARNsQuery[1]; + const resourceType = resourceARNsQuery[2]; + const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3])); + return this.getResourceARNs(region, resourceType, tagsJSON); + } + + const statsQuery = query.match(/^statistics\(\)/); + if (statsQuery) { + return this.standardStatistics.map((s: string) => ({ value: s, label: s, text: s })); + } + + return Promise.resolve([]); + } + + annotationQuery(options: any) { + const annotation = options.annotation; + const statistics = annotation.statistics.map((s: any) => this.templateSrv.replace(s)); + const defaultPeriod = annotation.prefixMatching ? '' : '300'; + let period = annotation.period || defaultPeriod; + period = parseInt(period, 10); + const parameters = { + prefixMatching: annotation.prefixMatching, + region: this.templateSrv.replace(this.getActualRegion(annotation.region)), + namespace: this.templateSrv.replace(annotation.namespace), + metricName: this.templateSrv.replace(annotation.metricName), + dimensions: this.convertDimensionFormat(annotation.dimensions, {}), + statistics: statistics, + period: period, + actionPrefix: annotation.actionPrefix || '', + alarmNamePrefix: annotation.alarmNamePrefix || '', + }; + + return this.awsRequest(TSDB_QUERY_ENDPOINT, { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [ + { + refId: 'annotationQuery', + datasourceId: this.id, + type: 'annotationQuery', + ...parameters, + }, + ], + }) + .pipe( + map((r) => { + return r.results['annotationQuery'].tables[0].rows.map((v) => ({ + annotation: annotation, + time: Date.parse(v[0]), + title: v[1], + tags: [v[2]], + text: v[3], + })); + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + return ( + this.templateSrv.variableExists(target.region) || + this.templateSrv.variableExists(target.namespace) || + this.templateSrv.variableExists(target.metricName) || + this.templateSrv.variableExists(target.expression!) || + target.logGroupNames?.some((logGroup: string) => this.templateSrv.variableExists(logGroup)) || + _.find(target.dimensions, (v, k) => this.templateSrv.variableExists(k) || this.templateSrv.variableExists(v)) + ); + } + + testDatasource() { + // use billing metrics for test + const region = this.defaultRegion; + const namespace = 'AWS/Billing'; + const metricName = 'EstimatedCharges'; + const dimensions = {}; + + return this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions).then(() => ({ + status: 'success', + message: 'Data source is working', + })); + } + + awsRequest(url: string, data: MetricRequest): Observable { + const options = { + method: 'POST', + url, + data, + }; + + return getBackendSrv() + .fetch(options) + .pipe(map((result) => result.data)); + } + + getDefaultRegion() { + return this.defaultRegion; + } + + getActualRegion(region?: string) { + if (region === 'default' || region === undefined || region === '') { + return this.getDefaultRegion(); + } + return region; + } + + showContextToggle() { + return true; + } + + convertToCloudWatchTime(date: any, roundUp: any) { + if (_.isString(date)) { + date = dateMath.parse(date, roundUp); + } + return Math.round(date.valueOf() / 1000); + } + + convertDimensionFormat(dimensions: { [key: string]: string | string[] }, scopedVars: ScopedVars) { + return Object.entries(dimensions).reduce((result, [key, value]) => { + key = this.replace(key, scopedVars, true, 'dimension keys'); + + if (Array.isArray(value)) { + return { ...result, [key]: value }; + } + + const valueVar = this.templateSrv + .getVariables() + .find(({ name }) => name === this.templateSrv.getVariableName(value)); + if (valueVar) { + if (((valueVar as unknown) as VariableWithMultiSupport).multi) { + const values = this.templateSrv.replace(value, scopedVars, 'pipe').split('|'); + return { ...result, [key]: values }; + } + return { ...result, [key]: [this.templateSrv.replace(value, scopedVars)] }; + } + + return { ...result, [key]: [value] }; + }, {}); + } + + replace( + target?: string, + scopedVars?: ScopedVars, + displayErrorIfIsMultiTemplateVariable?: boolean, + fieldName?: string + ) { + if (displayErrorIfIsMultiTemplateVariable && !!target) { + const variable = this.templateSrv + .getVariables() + .find(({ name }) => name === this.templateSrv.getVariableName(target)); + if (variable && ((variable as unknown) as VariableWithMultiSupport).multi) { + this.debouncedCustomAlert( + 'CloudWatch templating error', + `Multi template variables are not supported for ${fieldName || target}` + ); + } + } + + return this.templateSrv.replace(target, scopedVars); + } + + getQueryDisplayText(query: CloudWatchQuery) { + if (query.queryMode === 'Logs') { + return query.expression ?? ''; + } else { + return JSON.stringify(query); + } + } + + getTargetsByQueryMode = (targets: CloudWatchQuery[]) => { + const logQueries: CloudWatchLogsQuery[] = []; + const metricsQueries: CloudWatchMetricsQuery[] = []; + + targets.forEach((query) => { + const mode = query.queryMode ?? 'Metrics'; + if (mode === 'Logs') { + logQueries.push(query as CloudWatchLogsQuery); + } else { + metricsQueries.push(query as CloudWatchMetricsQuery); + } + }); + + return { + logQueries, + metricsQueries, + }; + }; + + interpolateVariablesInQueries(queries: CloudWatchQuery[], scopedVars: ScopedVars): CloudWatchQuery[] { + if (!queries.length) { + return queries; + } + + return queries.map((query) => ({ + ...query, + region: this.getActualRegion(this.replace(query.region, scopedVars)), + expression: this.replace(query.expression, scopedVars), + + ...(!isCloudWatchLogsQuery(query) && this.interpolateMetricsQueryVariables(query, scopedVars)), + })); + } + + interpolateMetricsQueryVariables( + query: CloudWatchMetricsQuery, + scopedVars: ScopedVars + ): Pick { + return { + alias: this.replace(query.alias, scopedVars), + metricName: this.replace(query.metricName, scopedVars), + namespace: this.replace(query.namespace, scopedVars), + period: this.replace(query.period, scopedVars), + dimensions: Object.entries(query.dimensions).reduce((prev, [key, value]) => { + if (Array.isArray(value)) { + return { ...prev, [key]: value }; + } + + return { ...prev, [this.replace(key, scopedVars)]: this.replace(value, scopedVars) }; + }, {}), + }; + } +} + +function withTeardown(observable: Observable, onUnsubscribe: () => void): Observable { + return new Observable((subscriber) => { + const innerSub = observable.subscribe({ + next: (val) => subscriber.next(val), + error: (err) => subscriber.next(err), + complete: () => subscriber.complete(), + }); + + return () => { + innerSub.unsubscribe(); + onUnsubscribe(); + }; + }); +} + +function parseLogGroupName(logIdentifier: string): string { + const colonIndex = logIdentifier.lastIndexOf(':'); + return logIdentifier.substr(colonIndex + 1); +} + +function statusIsTerminated(status: string | CloudWatchLogsQueryStatus) { + return [ + CloudWatchLogsQueryStatus.Complete, + CloudWatchLogsQueryStatus.Cancelled, + CloudWatchLogsQueryStatus.Failed, + CloudWatchLogsQueryStatus.Timeout, + ].includes(status as CloudWatchLogsQueryStatus); +} diff --git a/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png b/public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png new file mode 100644 index 0000000000000000000000000000000000000000..835843ace1b472390af48a69ab892b4e48f58630 GIT binary patch literal 7837 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 { + it('should suggest ', async () => { + await runSuggestionTest('stats count(\\)', [fields]); + // Make sure having a field prefix does not brake anything + await runSuggestionTest('stats count(@mess\\)', [fields]); + }); + + it('should suggest query commands on start of query', async () => { + await runSuggestionTest('\\', [QUERY_COMMANDS.map((v) => v.label)]); + }); + + it('should suggest query commands after pipe', async () => { + await runSuggestionTest('fields f | \\', [QUERY_COMMANDS.map((v) => v.label)]); + }); + + it('should suggest fields and functions after field command', async () => { + await runSuggestionTest('fields \\', [fields, FIELD_AND_FILTER_FUNCTIONS.map((v) => v.label)]); + }); + + it('should suggest fields and functions after comma', async () => { + await runSuggestionTest('fields field1, \\', [fields, FIELD_AND_FILTER_FUNCTIONS.map((v) => v.label)]); + }); + + it('should suggest fields and functions after comma with prefix', async () => { + await runSuggestionTest('fields field1, @mess\\', [fields, FIELD_AND_FILTER_FUNCTIONS.map((v) => v.label)]); + }); + + it('should suggest fields and functions after display command', async () => { + await runSuggestionTest('display \\', [fields, FIELD_AND_FILTER_FUNCTIONS.map((v) => v.label)]); + }); + + it('should suggest functions after stats command', async () => { + await runSuggestionTest('stats \\', [AGGREGATION_FUNCTIONS_STATS.map((v) => v.label)]); + }); + + it('should suggest fields and some functions after `by` command', async () => { + await runSuggestionTest('stats count(something) by \\', [ + fields, + STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS).map((v) => v.label), + ]); + }); + + it('should suggest fields and some functions after comparison operator', async () => { + await runSuggestionTest('filter field1 >= \\', [ + fields, + [...NUMERIC_OPERATORS.map((v) => v.label), ...BOOLEAN_FUNCTIONS.map((v) => v.label)], + ]); + }); + + it('should suggest fields directly after sort', async () => { + await runSuggestionTest('sort \\', [fields]); + }); + + it('should suggest fields directly after sort after a pipe', async () => { + await runSuggestionTest('fields field1 | sort \\', [fields]); + }); + + it('should suggest sort order after sort command and field', async () => { + await runSuggestionTest('sort field1 \\', [['asc', 'desc']]); + }); + + it('should suggest fields directly after parse', async () => { + await runSuggestionTest('parse \\', [fields]); + }); + + it('should suggest fields and bool functions after filter', async () => { + await runSuggestionTest('filter \\', [fields, BOOLEAN_FUNCTIONS.map((v) => v.label)]); + }); + + it('should suggest fields and functions after filter bin() function', async () => { + await runSuggestionTest('stats count(@message) by bin(30m), \\', [ + fields, + STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS).map((v) => v.label), + ]); + }); + + it('should not suggest anything if not after comma in by expression', async () => { + await runSuggestionTest('stats count(@message) by bin(30m) \\', []); + }); +}); + +async function runSuggestionTest(query: string, expectedItems: string[][]) { + const result = await getProvideCompletionItems(query); + expectedItems.forEach((items, index) => { + expect(result.suggestions[index].items.map((item) => item.label)).toEqual(items); + }); +} + +function makeDatasource(): CloudWatchDatasource { + return { + getLogGroupFields(): Promise { + return Promise.resolve({ logGroupFields: [{ name: 'field1' }, { name: '@message' }] }); + }, + } as any; +} + +/** + * Get suggestion items based on query. Use `\\` to mark position of the cursor. + */ +function getProvideCompletionItems(query: string): Promise { + const provider = new CloudWatchLanguageProvider(makeDatasource()); + const cursorOffset = query.indexOf('\\'); + const queryWithoutCursor = query.replace('\\', ''); + let tokens: Token[] = Prism.tokenize(queryWithoutCursor, provider.getSyntax()) as any; + tokens = addTokenMetadata(tokens); + const value = new ValueMock(tokens, cursorOffset); + return provider.provideCompletionItems( + { + value, + } as any, + { logGroupNames: ['logGroup1'] } + ); +} + +class ValueMock { + selection: Value['selection']; + data: Value['data']; + + constructor(tokens: Array, cursorOffset: number) { + this.selection = { + start: { + offset: cursorOffset, + }, + } as any; + + this.data = { + get() { + return tokens; + }, + } as any; + } +} + +/** + * Adds some Slate specific metadata + * @param tokens + */ +function addTokenMetadata(tokens: Array): Token[] { + let prev = undefined as any; + let offset = 0; + return tokens.reduce((acc, token) => { + let newToken: any; + if (typeof token === 'string') { + newToken = { + content: token, + // Not sure what else could it be here, probably if we do not match something + types: ['whitespace'], + }; + } else { + newToken = { ...token }; + newToken.types = [token.type]; + } + newToken.prev = prev; + if (newToken.prev) { + newToken.prev.next = newToken; + } + const end = offset + token.length; + newToken.offsets = { + start: offset, + end, + }; + prev = newToken; + offset = end; + return [...acc, newToken]; + }, [] as Token[]); +} diff --git a/public/app/plugins/datasource/cloudwatch/language_provider.ts b/public/app/plugins/datasource/cloudwatch/language_provider.ts new file mode 100644 index 0000000..004ee9e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/language_provider.ts @@ -0,0 +1,463 @@ +// Libraries +import _ from 'lodash'; + +// Services & Utils +import syntax, { + AGGREGATION_FUNCTIONS_STATS, + BOOLEAN_FUNCTIONS, + DATETIME_FUNCTIONS, + FIELD_AND_FILTER_FUNCTIONS, + IP_FUNCTIONS, + NUMERIC_OPERATORS, + QUERY_COMMANDS, + STRING_FUNCTIONS, +} from './syntax'; + +// Types +import { CloudWatchQuery, TSDBResponse } from './types'; +import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data'; + +import { CloudWatchDatasource } from './datasource'; +import { Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; +import Prism, { Grammar } from 'prismjs'; + +export type CloudWatchHistoryItem = HistoryItem; + +type TypeaheadContext = { + history?: CloudWatchHistoryItem[]; + absoluteRange?: AbsoluteTimeRange; + logGroupNames?: string[]; +}; + +export class CloudWatchLanguageProvider extends LanguageProvider { + started: boolean; + initialRange: AbsoluteTimeRange; + datasource: CloudWatchDatasource; + + constructor(datasource: CloudWatchDatasource, initialValues?: any) { + super(); + + this.datasource = datasource; + + Object.assign(this, initialValues); + } + + // Strip syntax chars + cleanText = (s: string) => s.replace(/[()]/g, '').trim(); + + getSyntax(): Grammar { + return syntax; + } + + request = (url: string, params?: any): Promise => { + return this.datasource.awsRequest(url, params).toPromise(); + }; + + start = () => { + if (!this.startTask) { + this.startTask = Promise.resolve().then(() => { + this.started = true; + return []; + }); + } + + return this.startTask; + }; + + isStatsQuery(query: string): boolean { + const grammar = this.getSyntax(); + const tokens = Prism.tokenize(query, grammar) ?? []; + + return !!tokens.find( + (token) => + typeof token !== 'string' && + token.content.toString().toLowerCase() === 'stats' && + token.type === 'query-command' + ); + } + + /** + * Return suggestions based on input that can be then plugged into a typeahead dropdown. + * Keep this DOM-free for testing + * @param input + * @param context Is optional in types but is required in case we are doing getLabelCompletionItems + * @param context.absoluteRange Required in case we are doing getLabelCompletionItems + * @param context.history Optional used only in getEmptyCompletionItems + */ + async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise { + const { value } = input; + + // Get tokens + const tokens = value?.data.get('tokens'); + + if (!tokens || !tokens.length) { + return { suggestions: [] }; + } + + const curToken: Token = tokens.filter( + (token: any) => + token.offsets.start <= value!.selection?.start?.offset && token.offsets.end >= value!.selection?.start?.offset + )[0]; + + const isFirstToken = !curToken.prev; + const prevToken = prevNonWhitespaceToken(curToken); + + const isCommandStart = isFirstToken || (!isFirstToken && prevToken?.types.includes('command-separator')); + if (isCommandStart) { + return this.getCommandCompletionItems(); + } + + if (isInsideFunctionParenthesis(curToken)) { + return await this.getFieldCompletionItems(context?.logGroupNames ?? []); + } + + if (isAfterKeyword('by', curToken)) { + return this.handleKeyword(context); + } + + if (prevToken?.types.includes('comparison-operator')) { + return this.handleComparison(context); + } + + const commandToken = previousCommandToken(curToken); + if (commandToken) { + return await this.handleCommand(commandToken, curToken, context); + } + + return { + suggestions: [], + }; + } + + private fetchedFieldsCache: + | { + time: number; + logGroups: string[]; + fields: string[]; + } + | undefined; + + private fetchFields = async (logGroups: string[]): Promise => { + if ( + this.fetchedFieldsCache && + Date.now() - this.fetchedFieldsCache.time < 30 * 1000 && + _.sortedUniq(this.fetchedFieldsCache.logGroups).join('|') === _.sortedUniq(logGroups).join('|') + ) { + return this.fetchedFieldsCache.fields; + } + + const results = await Promise.all( + logGroups.map((logGroup) => this.datasource.getLogGroupFields({ logGroupName: logGroup })) + ); + + const fields = [ + ...new Set( + results.reduce((acc: string[], cur) => acc.concat(cur.logGroupFields?.map((f) => f.name) as string[]), []) + ).values(), + ]; + + this.fetchedFieldsCache = { + time: Date.now(), + logGroups, + fields, + }; + + return fields; + }; + + private handleKeyword = async (context?: TypeaheadContext): Promise => { + const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []); + const functionSuggestions = [ + { prefixMatch: true, label: 'Functions', items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS) }, + ]; + suggs.suggestions.push(...functionSuggestions); + + return suggs; + }; + + private handleCommand = async ( + commandToken: Token, + curToken: Token, + context?: TypeaheadContext + ): Promise => { + const queryCommand = commandToken.content.toLowerCase(); + const prevToken = prevNonWhitespaceToken(curToken); + const currentTokenIsFirstArg = prevToken === commandToken; + + if (queryCommand === 'sort') { + return this.handleSortCommand(currentTokenIsFirstArg, curToken, context); + } + + if (queryCommand === 'parse') { + if (currentTokenIsFirstArg) { + return await this.getFieldCompletionItems(context?.logGroupNames ?? []); + } + } + + const currentTokenIsAfterCommandAndEmpty = isTokenType(commandToken.next, 'whitespace') && !commandToken.next?.next; + const currentTokenIsAfterCommand = + currentTokenIsAfterCommandAndEmpty || nextNonWhitespaceToken(commandToken) === curToken; + + const currentTokenIsComma = isTokenType(curToken, 'punctuation', ','); + const currentTokenIsCommaOrAfterComma = currentTokenIsComma || isTokenType(prevToken, 'punctuation', ','); + + // We only show suggestions if we are after a command or after a comma which is a field separator + if (!(currentTokenIsAfterCommand || currentTokenIsCommaOrAfterComma)) { + return { suggestions: [] }; + } + + if (['display', 'fields'].includes(queryCommand)) { + const typeaheadOutput = await this.getFieldCompletionItems(context?.logGroupNames ?? []); + typeaheadOutput.suggestions.push(...this.getFieldAndFilterFunctionCompletionItems().suggestions); + + return typeaheadOutput; + } + + if (queryCommand === 'stats') { + const typeaheadOutput = this.getStatsAggCompletionItems(); + if (currentTokenIsComma || currentTokenIsAfterCommandAndEmpty) { + typeaheadOutput?.suggestions.forEach((group) => { + group.skipFilter = true; + }); + } + return typeaheadOutput; + } + + if (queryCommand === 'filter' && currentTokenIsFirstArg) { + const sugg = await this.getFieldCompletionItems(context?.logGroupNames ?? []); + const boolFuncs = this.getBoolFuncCompletionItems(); + sugg.suggestions.push(...boolFuncs.suggestions); + return sugg; + } + return { suggestions: [] }; + }; + + private async handleSortCommand( + isFirstArgument: boolean, + curToken: Token, + context?: TypeaheadContext + ): Promise { + if (isFirstArgument) { + return await this.getFieldCompletionItems(context?.logGroupNames ?? []); + } else if (isTokenType(prevNonWhitespaceToken(curToken), 'field-name')) { + // suggest sort options + return { + suggestions: [ + { + prefixMatch: true, + label: 'Sort Order', + items: [ + { + label: 'asc', + }, + { label: 'desc' }, + ], + }, + ], + }; + } + + return { suggestions: [] }; + } + + private handleComparison = async (context?: TypeaheadContext) => { + const fieldsSuggestions = await this.getFieldCompletionItems(context?.logGroupNames ?? []); + const comparisonSuggestions = this.getComparisonCompletionItems(); + fieldsSuggestions.suggestions.push(...comparisonSuggestions.suggestions); + return fieldsSuggestions; + }; + + private getCommandCompletionItems = (): TypeaheadOutput => { + return { suggestions: [{ prefixMatch: true, label: 'Commands', items: QUERY_COMMANDS }] }; + }; + + private getFieldAndFilterFunctionCompletionItems = (): TypeaheadOutput => { + return { suggestions: [{ prefixMatch: true, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS }] }; + }; + + private getStatsAggCompletionItems = (): TypeaheadOutput => { + return { suggestions: [{ prefixMatch: true, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS }] }; + }; + + private getBoolFuncCompletionItems = (): TypeaheadOutput => { + return { + suggestions: [ + { + prefixMatch: true, + label: 'Functions', + items: BOOLEAN_FUNCTIONS, + }, + ], + }; + }; + + private getComparisonCompletionItems = (): TypeaheadOutput => { + return { + suggestions: [ + { + prefixMatch: true, + label: 'Functions', + items: NUMERIC_OPERATORS.concat(BOOLEAN_FUNCTIONS), + }, + ], + }; + }; + + private getFieldCompletionItems = async (logGroups: string[]): Promise => { + const fields = await this.fetchFields(logGroups); + + return { + suggestions: [ + { + label: 'Fields', + items: fields.map((field) => ({ + label: field, + insertText: field.match(/@?[_a-zA-Z]+[_.0-9a-zA-Z]*/) ? undefined : `\`${field}\``, + })), + }, + ], + }; + }; +} + +function nextNonWhitespaceToken(token: Token): Token | null { + let curToken = token; + + while (curToken.next) { + if (curToken.next.types.includes('whitespace')) { + curToken = curToken.next; + } else { + return curToken.next; + } + } + + return null; +} + +function prevNonWhitespaceToken(token: Token): Token | null { + let curToken = token; + + while (curToken.prev) { + if (isTokenType(curToken.prev, 'whitespace')) { + curToken = curToken.prev; + } else { + return curToken.prev; + } + } + + return null; +} + +function previousCommandToken(startToken: Token): Token | null { + let thisToken = startToken; + while (!!thisToken.prev) { + thisToken = thisToken.prev; + if ( + thisToken.types.includes('query-command') && + (!thisToken.prev || isTokenType(prevNonWhitespaceToken(thisToken), 'command-separator')) + ) { + return thisToken; + } + } + return null; +} + +const funcsWithFieldArgs = [ + 'avg', + 'count', + 'count_distinct', + 'earliest', + 'latest', + 'sortsFirst', + 'sortsLast', + 'max', + 'min', + 'pct', + 'stddev', + 'ispresent', + 'fromMillis', + 'toMillis', + 'isempty', + 'isblank', + 'isValidIp', + 'isValidIpV4', + 'isValidIpV6', + 'isIpInSubnet', + 'isIpv4InSubnet', + 'isIpv6InSubnet', +].map((funcName) => funcName.toLowerCase()); + +/** + * Returns true if cursor is currently inside a function parenthesis for example `count(|)` or `count(@mess|)` should + * return true. + */ +function isInsideFunctionParenthesis(curToken: Token): boolean { + const prevToken = prevNonWhitespaceToken(curToken); + + if (!prevToken) { + return false; + } + + const parenthesisToken = curToken.content === '(' ? curToken : prevToken.content === '(' ? prevToken : undefined; + if (parenthesisToken) { + const maybeFunctionToken = prevNonWhitespaceToken(parenthesisToken); + if (maybeFunctionToken) { + return ( + funcsWithFieldArgs.includes(maybeFunctionToken.content.toLowerCase()) && + maybeFunctionToken.types.includes('function') + ); + } + } + return false; +} + +function isAfterKeyword(keyword: string, token: Token): boolean { + const maybeKeyword = getPreviousTokenExcluding(token, [ + 'whitespace', + 'function', + 'punctuation', + 'field-name', + 'number', + ]); + if (isTokenType(maybeKeyword, 'keyword', 'by')) { + const prev = getPreviousTokenExcluding(token, ['whitespace']); + if (prev === maybeKeyword || isTokenType(prev, 'punctuation', ',')) { + return true; + } + } + return false; +} + +function isTokenType(token: Token | undefined | null, type: string, content?: string): boolean { + if (!token?.types.includes(type)) { + return false; + } + if (content) { + if (token?.content.toLowerCase() !== content) { + return false; + } + } + return true; +} + +type TokenDef = string | { type: string; value: string }; +function getPreviousTokenExcluding(token: Token, exclude: TokenDef[]): Token | undefined | null { + let curToken = token.prev; + main: while (curToken) { + for (const item of exclude) { + if (typeof item === 'string') { + if (curToken.types.includes(item)) { + curToken = curToken.prev; + continue main; + } + } else { + if (curToken.types.includes(item.type) && curToken.content.toLowerCase() === item.value) { + curToken = curToken.prev; + continue main; + } + } + } + break; + } + return curToken; +} diff --git a/public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts b/public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts new file mode 100644 index 0000000..2a66190 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/memoizedDebounce.ts @@ -0,0 +1,13 @@ +import { debounce, memoize } from 'lodash'; + +export default (func: (...args: any[]) => void, wait = 7000) => { + const mem = memoize( + (...args) => + debounce(func, wait, { + leading: true, + }), + (...args) => JSON.stringify(args) + ); + + return (...args: any[]) => mem(...args)(...args); +}; diff --git a/public/app/plugins/datasource/cloudwatch/module.tsx b/public/app/plugins/datasource/cloudwatch/module.tsx new file mode 100644 index 0000000..9c82f4e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/module.tsx @@ -0,0 +1,25 @@ +import './query_parameter_ctrl'; +import { DataSourcePlugin } from '@grafana/data'; +import { ConfigEditor } from './components/ConfigEditor'; +import { CloudWatchDatasource } from './datasource'; +import { CloudWatchAnnotationsQueryCtrl } from './annotations_query_ctrl'; +import { CloudWatchJsonData, CloudWatchQuery } from './types'; +import { CloudWatchLogsQueryEditor } from './components/LogsQueryEditor'; +import { PanelQueryEditor } from './components/PanelQueryEditor'; +import LogsCheatSheet from './components/LogsCheatSheet'; + +export const plugin = new DataSourcePlugin( + CloudWatchDatasource +) + .setQueryEditorHelp(LogsCheatSheet) + .setConfigEditor(ConfigEditor) + .setQueryEditor(PanelQueryEditor) + .setExploreMetricsQueryField(PanelQueryEditor) + .setExploreLogsQueryField(CloudWatchLogsQueryEditor) + .setAnnotationQueryCtrl(CloudWatchAnnotationsQueryCtrl) + .setChannelSupport({ + getChannelConfig: (path: string) => ({ + path, + }), + getSupportedPaths: () => [], + }); diff --git a/public/app/plugins/datasource/cloudwatch/partials/annotations.editor.html b/public/app/plugins/datasource/cloudwatch/partials/annotations.editor.html new file mode 100644 index 0000000..926d939 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/partials/annotations.editor.html @@ -0,0 +1,5 @@ + diff --git a/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html b/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html new file mode 100644 index 0000000..21e7074 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html @@ -0,0 +1,94 @@ +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    + + + + + +
    + +
    + +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + + + Alias replacement variables: +
      +
    • {{ metric }}
    • +
    • {{ stat }}
    • +
    • {{ namespace }}
    • +
    • {{ region }}
    • +
    • {{ period }}
    • +
    • {{ label }}
    • +
    • {{ YOUR_DIMENSION_NAME }}
    • +
    +
    +
    + +
    +
    +
    +
    diff --git a/public/app/plugins/datasource/cloudwatch/plugin.json b/public/app/plugins/datasource/cloudwatch/plugin.json new file mode 100644 index 0000000..daa8e53 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/plugin.json @@ -0,0 +1,33 @@ +{ + "type": "datasource", + "name": "CloudWatch", + "id": "cloudwatch", + "category": "cloud", + "metrics": true, + "logs": true, + "alerting": true, + "annotations": true, + "includes": [ + { "type": "dashboard", "name": "EC2", "path": "dashboards/ec2.json" }, + { "type": "dashboard", "name": "EBS", "path": "dashboards/EBS.json" }, + { "type": "dashboard", "name": "Lambda", "path": "dashboards/Lambda.json" }, + { "type": "dashboard", "name": "Logs", "path": "dashboards/Logs.json" }, + { "type": "dashboard", "name": "RDS", "path": "dashboards/RDS.json" } + ], + + "queryOptions": { + "minInterval": true + }, + + "info": { + "description": "Data source for Amazon AWS monitoring service", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/amazon-web-services.png", + "large": "img/amazon-web-services.png" + } + } +} diff --git a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts new file mode 100644 index 0000000..23ca76c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts @@ -0,0 +1,241 @@ +import angular from 'angular'; +import coreModule from 'app/core/core_module'; +import _ from 'lodash'; +import { TemplateSrv } from '@grafana/runtime'; + +export class CloudWatchQueryParameterCtrl { + /** @ngInject */ + constructor($scope: any, templateSrv: TemplateSrv, uiSegmentSrv: any) { + $scope.init = () => { + const target = $scope.target; + target.namespace = target.namespace || ''; + target.metricName = target.metricName || ''; + target.statistics = target.statistics || ['Average']; + target.dimensions = target.dimensions || {}; + target.period = target.period || ''; + target.region = target.region || 'default'; + target.id = target.id || ''; + target.expression = target.expression || ''; + + $scope.regionSegment = uiSegmentSrv.getSegmentForValue($scope.target.region, 'select region'); + $scope.namespaceSegment = uiSegmentSrv.getSegmentForValue($scope.target.namespace, 'select namespace'); + $scope.metricSegment = uiSegmentSrv.getSegmentForValue($scope.target.metricName, 'select metric'); + + $scope.dimSegments = _.reduce( + $scope.target.dimensions, + (memo, value, key) => { + memo.push(uiSegmentSrv.newKey(key)); + memo.push(uiSegmentSrv.newOperator('=')); + memo.push(uiSegmentSrv.newKeyValue(value)); + return memo; + }, + [] as any + ); + + $scope.statSegments = _.map($scope.target.statistics, (stat) => { + return uiSegmentSrv.getSegmentForValue(stat); + }); + + $scope.ensurePlusButton($scope.statSegments); + $scope.ensurePlusButton($scope.dimSegments); + $scope.removeDimSegment = uiSegmentSrv.newSegment({ + fake: true, + value: '-- remove dimension --', + }); + $scope.removeStatSegment = uiSegmentSrv.newSegment({ + fake: true, + value: '-- remove stat --', + }); + + if (_.isEmpty($scope.target.region)) { + $scope.target.region = 'default'; + } + + if (!$scope.onChange) { + $scope.onChange = () => {}; + } + }; + + $scope.getStatSegments = () => { + return Promise.resolve( + _.flatten([ + angular.copy($scope.removeStatSegment), + _.map($scope.datasource.standardStatistics, (s) => { + return uiSegmentSrv.getSegmentForValue(s); + }), + uiSegmentSrv.getSegmentForValue('pNN.NN'), + ]) + ); + }; + + $scope.statSegmentChanged = (segment: any, index: number) => { + if (segment.value === $scope.removeStatSegment.value) { + $scope.statSegments.splice(index, 1); + } else { + segment.type = 'value'; + } + + $scope.target.statistics = _.reduce( + $scope.statSegments, + (memo, seg) => { + if (!seg.fake) { + memo.push(seg.value); + } + return memo; + }, + [] as any + ); + + $scope.ensurePlusButton($scope.statSegments); + $scope.onChange(); + }; + + $scope.ensurePlusButton = (segments: any) => { + const count = segments.length; + const lastSegment = segments[Math.max(count - 1, 0)]; + + if (!lastSegment || lastSegment.type !== 'plus-button') { + segments.push(uiSegmentSrv.newPlusButton()); + } + }; + + $scope.getDimSegments = (segment: any, $index: number) => { + if (segment.type === 'operator') { + return Promise.resolve([]); + } + + const target = $scope.target; + let query = Promise.resolve([] as any[]); + + if (segment.type === 'key' || segment.type === 'plus-button') { + query = $scope.datasource.getDimensionKeys($scope.target.namespace, $scope.target.region); + } else if (segment.type === 'value') { + const dimensionKey = $scope.dimSegments[$index - 2].value; + delete target.dimensions[dimensionKey]; + query = $scope.datasource.getDimensionValues( + target.region, + target.namespace, + target.metricName, + dimensionKey, + target.dimensions + ); + } + + return query.then($scope.transformToSegments(true)).then((results) => { + if (segment.type === 'key') { + results.splice(0, 0, angular.copy($scope.removeDimSegment)); + } + return results; + }); + }; + + $scope.dimSegmentChanged = (segment: any, index: number) => { + $scope.dimSegments[index] = segment; + + if (segment.value === $scope.removeDimSegment.value) { + $scope.dimSegments.splice(index, 3); + } else if (segment.type === 'plus-button') { + $scope.dimSegments.push(uiSegmentSrv.newOperator('=')); + $scope.dimSegments.push(uiSegmentSrv.newFake('select dimension value', 'value', 'query-segment-value')); + segment.type = 'key'; + segment.cssClass = 'query-segment-key'; + } + + $scope.syncDimSegmentsWithModel(); + $scope.ensurePlusButton($scope.dimSegments); + $scope.onChange(); + }; + + $scope.syncDimSegmentsWithModel = () => { + const dims: any = {}; + const length = $scope.dimSegments.length; + + for (let i = 0; i < length - 2; i += 3) { + const keySegment = $scope.dimSegments[i]; + const valueSegment = $scope.dimSegments[i + 2]; + if (!valueSegment.fake) { + dims[keySegment.value] = valueSegment.value; + } + } + + $scope.target.dimensions = dims; + }; + + $scope.getRegions = () => { + return $scope.datasource + .metricFindQuery('regions()') + .then((results: any) => { + results.unshift({ text: 'default' }); + return results; + }) + .then($scope.transformToSegments(true)); + }; + + $scope.getNamespaces = () => { + return $scope.datasource.metricFindQuery('namespaces()').then($scope.transformToSegments(true)); + }; + + $scope.getMetrics = () => { + return $scope.datasource + .metricFindQuery('metrics(' + $scope.target.namespace + ',' + $scope.target.region + ')') + .then($scope.transformToSegments(true)); + }; + + $scope.regionChanged = () => { + $scope.target.region = $scope.regionSegment.value; + $scope.onChange(); + }; + + $scope.namespaceChanged = () => { + $scope.target.namespace = $scope.namespaceSegment.value; + $scope.onChange(); + }; + + $scope.metricChanged = () => { + $scope.target.metricName = $scope.metricSegment.value; + $scope.onChange(); + }; + + $scope.transformToSegments = (addTemplateVars: any) => { + return (results: any) => { + const segments = _.map(results, (segment) => { + return uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (addTemplateVars) { + _.each(templateSrv.getVariables(), (variable) => { + segments.unshift( + uiSegmentSrv.newSegment({ + type: 'template', + value: '$' + variable.name, + expandable: true, + }) + ); + }); + } + + return segments; + }; + }; + + $scope.init(); + } +} + +export function cloudWatchQueryParameter() { + return { + templateUrl: 'public/app/plugins/datasource/cloudwatch/partials/query.parameter.html', + controller: CloudWatchQueryParameterCtrl, + restrict: 'E', + scope: { + target: '=', + datasource: '=', + onChange: '&', + }, + }; +} + +coreModule.directive('cloudwatchQueryParameter', cloudWatchQueryParameter); diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts new file mode 100644 index 0000000..afc8ba3 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -0,0 +1,1101 @@ +import { interval, of, throwError } from 'rxjs'; +import { + DataFrame, + DataQueryErrorType, + DataQueryResponse, + DataSourceInstanceSettings, + dateMath, + getFrameDisplayName, +} from '@grafana/data'; + +import * as redux from 'app/store/store'; +import { CloudWatchDatasource, MAX_ATTEMPTS } from '../datasource'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { + CloudWatchJsonData, + CloudWatchLogsQuery, + CloudWatchLogsQueryStatus, + CloudWatchMetricsQuery, + LogAction, +} from '../types'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { convertToStoreState } from '../../../../../test/helpers/convertToStoreState'; +import { getTemplateSrvDependencies } from 'test/helpers/getTemplateSrvDependencies'; +import { CustomVariableModel, initialVariableModelState, VariableHide } from '../../../../features/variables/types'; + +import * as rxjsUtils from '../utils/rxjs/increasingInterval'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; + +jest.mock('rxjs/operators', () => { + const operators = jest.requireActual('rxjs/operators'); + operators.delay = jest.fn(() => (s: any) => s); + return operators; +}); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +type Args = { response?: any; throws?: boolean; templateSrv?: TemplateSrv }; + +function getTestContext({ response = {}, throws = false, templateSrv = new TemplateSrv() }: Args = {}) { + jest.clearAllMocks(); + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + throws + ? fetchMock.mockImplementation(() => throwError(response)) + : fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + const instanceSettings = { + jsonData: { defaultRegion: 'us-east-1' }, + name: 'TestDatasource', + } as DataSourceInstanceSettings; + + const timeSrv = { + time: { from: '2016-12-31 15:00:00Z', to: '2016-12-31 16:00:00Z' }, + timeRange: () => { + return { + from: dateMath.parse(timeSrv.time.from, false), + to: dateMath.parse(timeSrv.time.to, true), + }; + }, + } as TimeSrv; + + const ds = new CloudWatchDatasource(instanceSettings, templateSrv, timeSrv); + + return { ds, fetchMock, instanceSettings }; +} + +describe('CloudWatchDatasource', () => { + const start = 1483196400 * 1000; + const defaultTimeRange = { from: new Date(start), to: new Date(start + 3600 * 1000) }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('When getting log groups', () => { + it('should return log groups as an array of strings', async () => { + const response = { + results: { + A: { + dataframes: [ + 'QVJST1cxAAD/////GAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAFgAAAACAAAAKAAAAAQAAAB8////CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAJz///8IAAAAFAAAAAkAAABsb2dHcm91cHMAAAAEAAAAbmFtZQAAAAABAAAAGAAAAAAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAABMAAAAUAAAAAAABQFMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAGAAAAAwAAABsb2dHcm91cE5hbWUAAAAABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAADAAAAGxvZ0dyb3VwTmFtZQAAAAD/////mAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAAGAGAAAAAAAAFAAAAAAAAAMDAAoAGAAMAAgABAAKAAAAFAAAAEgAAAAhAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiAAAAAAAAACIAAAAAAAAANgFAAAAAAAAAAAAAAEAAAAhAAAAAAAAAAAAAAAAAAAAAAAAADIAAABiAAAAkQAAALwAAADuAAAAHwEAAFQBAACHAQAAtQEAAOoBAAAbAgAASgIAAHQCAAClAgAA1QIAABADAABEAwAAdgMAAKMDAADXAwAACQQAAEAEAAB3BAAAlwQAAK0EAAC8BAAA+wQAAEIFAABhBQAAeAUAAJIFAAC0BQAA1gUAAC9hd3MvY29udGFpbmVyaW5zaWdodHMvZGV2MzAzLXdvcmtzaG9wL2FwcGxpY2F0aW9uL2F3cy9jb250YWluZXJpbnNpZ2h0cy9kZXYzMDMtd29ya3Nob3AvZGF0YXBsYW5lL2F3cy9jb250YWluZXJpbnNpZ2h0cy9kZXYzMDMtd29ya3Nob3AvZmxvd2xvZ3MvYXdzL2NvbnRhaW5lcmluc2lnaHRzL2RldjMwMy13b3Jrc2hvcC9ob3N0L2F3cy9jb250YWluZXJpbnNpZ2h0cy9kZXYzMDMtd29ya3Nob3AvcGVyZm9ybWFuY2UvYXdzL2NvbnRhaW5lcmluc2lnaHRzL2RldjMwMy13b3Jrc2hvcC9wcm9tZXRoZXVzL2F3cy9jb250YWluZXJpbnNpZ2h0cy9lY29tbWVyY2Utc29ja3Nob3AvYXBwbGljYXRpb24vYXdzL2NvbnRhaW5lcmluc2lnaHRzL2Vjb21tZXJjZS1zb2Nrc2hvcC9kYXRhcGxhbmUvYXdzL2NvbnRhaW5lcmluc2lnaHRzL2Vjb21tZXJjZS1zb2Nrc2hvcC9ob3N0L2F3cy9jb250YWluZXJpbnNpZ2h0cy9lY29tbWVyY2Utc29ja3Nob3AvcGVyZm9ybWFuY2UvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1wZXJmL2FwcGxpY2F0aW9uL2F3cy9jb250YWluZXJpbnNpZ2h0cy93YXRjaGRlbW8tcGVyZi9kYXRhcGxhbmUvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1wZXJmL2hvc3QvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1wZXJmL3BlcmZvcm1hbmNlL2F3cy9jb250YWluZXJpbnNpZ2h0cy93YXRjaGRlbW8tcGVyZi9wcm9tZXRoZXVzL2F3cy9jb250YWluZXJpbnNpZ2h0cy93YXRjaGRlbW8tcHJvZC11cy1lYXN0LTEvcGVyZm9ybWFuY2UvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1zdGFnaW5nL2FwcGxpY2F0aW9uL2F3cy9jb250YWluZXJpbnNpZ2h0cy93YXRjaGRlbW8tc3RhZ2luZy9kYXRhcGxhbmUvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1zdGFnaW5nL2hvc3QvYXdzL2NvbnRhaW5lcmluc2lnaHRzL3dhdGNoZGVtby1zdGFnaW5nL3BlcmZvcm1hbmNlL2F3cy9lY3MvY29udGFpbmVyaW5zaWdodHMvYnVnYmFzaC1lYzIvcGVyZm9ybWFuY2UvYXdzL2Vjcy9jb250YWluZXJpbnNpZ2h0cy9lY3MtZGVtb3dvcmtzaG9wL3BlcmZvcm1hbmNlL2F3cy9lY3MvY29udGFpbmVyaW5zaWdodHMvZWNzLXdvcmtzaG9wLWRldi9wZXJmb3JtYW5jZS9hd3MvZWtzL2RldjMwMy13b3Jrc2hvcC9jbHVzdGVyL2F3cy9ldmVudHMvY2xvdWR0cmFpbC9hd3MvZXZlbnRzL2Vjcy9hd3MvbGFtYmRhL2N3c3luLW15Y2FuYXJ5LWZhYzk3ZGVkLWYxMzQtNDk5YS05ZDcxLTRjM2JlMWY2MzE4Mi9hd3MvbGFtYmRhL2N3c3luLXdhdGNoLWxpbmtjaGVja3MtZWY3ZWYyNzMtNWRhMi00NjYzLWFmNTQtZDJmNTJkNTViMDYwL2Vjcy9lY3MtY3dhZ2VudC1kYWVtb24tc2VydmljZS9lY3MvZWNzLWRlbW8tbGltaXRUYXNrQ2xvdWRUcmFpbC9EZWZhdWx0TG9nR3JvdXBjb250YWluZXItaW5zaWdodHMtcHJvbWV0aGV1cy1iZXRhY29udGFpbmVyLWluc2lnaHRzLXByb21ldGhldXMtZGVtbwAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAMAAQAAACgBAAAAAAAAoAAAAAAAAABgBgAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAWAAAAAIAAAAoAAAABAAAAHz///8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAnP///wgAAAAUAAAACQAAAGxvZ0dyb3VwcwAAAAQAAABuYW1lAAAAAAEAAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAEwAAABQAAAAAAAFAUwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAYAAAADAAAAGxvZ0dyb3VwTmFtZQAAAAAEAAAAbmFtZQAAAAAAAAAABAAEAAQAAAAMAAAAbG9nR3JvdXBOYW1lAAAAAEgBAABBUlJPVzE=', + ], + refId: 'A', + }, + }, + }; + const { ds } = getTestContext({ response }); + const expectedLogGroups = [ + '/aws/containerinsights/dev303-workshop/application', + '/aws/containerinsights/dev303-workshop/dataplane', + '/aws/containerinsights/dev303-workshop/flowlogs', + '/aws/containerinsights/dev303-workshop/host', + '/aws/containerinsights/dev303-workshop/performance', + '/aws/containerinsights/dev303-workshop/prometheus', + '/aws/containerinsights/ecommerce-sockshop/application', + '/aws/containerinsights/ecommerce-sockshop/dataplane', + '/aws/containerinsights/ecommerce-sockshop/host', + '/aws/containerinsights/ecommerce-sockshop/performance', + '/aws/containerinsights/watchdemo-perf/application', + '/aws/containerinsights/watchdemo-perf/dataplane', + '/aws/containerinsights/watchdemo-perf/host', + '/aws/containerinsights/watchdemo-perf/performance', + '/aws/containerinsights/watchdemo-perf/prometheus', + '/aws/containerinsights/watchdemo-prod-us-east-1/performance', + '/aws/containerinsights/watchdemo-staging/application', + '/aws/containerinsights/watchdemo-staging/dataplane', + '/aws/containerinsights/watchdemo-staging/host', + '/aws/containerinsights/watchdemo-staging/performance', + '/aws/ecs/containerinsights/bugbash-ec2/performance', + '/aws/ecs/containerinsights/ecs-demoworkshop/performance', + '/aws/ecs/containerinsights/ecs-workshop-dev/performance', + '/aws/eks/dev303-workshop/cluster', + '/aws/events/cloudtrail', + '/aws/events/ecs', + '/aws/lambda/cwsyn-mycanary-fac97ded-f134-499a-9d71-4c3be1f63182', + '/aws/lambda/cwsyn-watch-linkchecks-ef7ef273-5da2-4663-af54-d2f52d55b060', + '/ecs/ecs-cwagent-daemon-service', + '/ecs/ecs-demo-limitTask', + 'CloudTrail/DefaultLogGroup', + 'container-insights-prometheus-beta', + 'container-insights-prometheus-demo', + ]; + + const logGroups = await ds.describeLogGroups({}); + + expect(logGroups).toEqual(expectedLogGroups); + }); + }); + + describe('When performing CloudWatch logs query', () => { + beforeEach(() => { + jest.spyOn(rxjsUtils, 'increasingInterval').mockImplementation(() => interval(100)); + }); + + it('should add data links to response', () => { + const { ds } = getTestContext(); + const mockResponse: DataQueryResponse = { + data: [ + { + fields: [ + { + config: { + links: [], + }, + }, + ], + refId: 'A', + }, + ], + }; + + const mockOptions: any = { + targets: [ + { + refId: 'A', + expression: 'stats count(@message) by bin(1h)', + logGroupNames: ['fake-log-group-one', 'fake-log-group-two'], + region: 'default', + }, + ], + }; + + const saturatedResponse = ds['addDataLinksToLogsResponse'](mockResponse, mockOptions); + expect(saturatedResponse).toMatchObject({ + data: [ + { + fields: [ + { + config: { + links: [ + { + url: + "https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#logs-insights:queryDetail=~(end~'2016-12-31T16*3a00*3a00.000Z~start~'2016-12-31T15*3a00*3a00.000Z~timeType~'ABSOLUTE~tz~'UTC~editorString~'stats*20count*28*40message*29*20by*20bin*281h*29~isLiveTail~false~source~(~'fake-log-group-one~'fake-log-group-two))", + title: 'View in CloudWatch console', + targetBlank: true, + }, + ], + }, + }, + ], + refId: 'A', + }, + ], + }); + }); + + it('should stop querying when no more data received a number of times in a row', async () => { + const { ds } = getTestContext(); + const fakeFrames = genMockFrames(20); + const initialRecordsMatched = fakeFrames[0].meta!.stats!.find((stat) => stat.displayName === 'Records scanned')! + .value!; + for (let i = 1; i < 4; i++) { + fakeFrames[i].meta!.stats = [ + { + displayName: 'Records scanned', + value: initialRecordsMatched, + }, + ]; + } + + const finalRecordsMatched = fakeFrames[9].meta!.stats!.find((stat) => stat.displayName === 'Records scanned')! + .value!; + for (let i = 10; i < fakeFrames.length; i++) { + fakeFrames[i].meta!.stats = [ + { + displayName: 'Records scanned', + value: finalRecordsMatched, + }, + ]; + } + + let i = 0; + jest.spyOn(ds, 'makeLogActionRequest').mockImplementation((subtype: LogAction) => { + if (subtype === 'GetQueryResults') { + const mockObservable = of([fakeFrames[i]]); + i++; + return mockObservable; + } else { + return of([]); + } + }); + + const myResponse = await ds.logsQuery([{ queryId: 'fake-query-id', region: 'default', refId: 'A' }]).toPromise(); + + const expectedData = [ + { + ...fakeFrames[14], + meta: { + custom: { + Status: 'Cancelled', + }, + stats: fakeFrames[14].meta!.stats, + }, + }, + ]; + + expect(myResponse).toEqual({ + data: expectedData, + key: 'test-key', + state: 'Done', + error: { + type: DataQueryErrorType.Timeout, + message: `error: query timed out after ${MAX_ATTEMPTS} attempts`, + }, + }); + expect(i).toBe(15); + }); + + it('should continue querying as long as new data is being received', async () => { + const { ds } = getTestContext(); + const fakeFrames = genMockFrames(15); + + let i = 0; + jest.spyOn(ds, 'makeLogActionRequest').mockImplementation((subtype: LogAction) => { + if (subtype === 'GetQueryResults') { + const mockObservable = of([fakeFrames[i]]); + i++; + return mockObservable; + } else { + return of([]); + } + }); + + const myResponse = await ds.logsQuery([{ queryId: 'fake-query-id', region: 'default', refId: 'A' }]).toPromise(); + expect(myResponse).toEqual({ + data: [fakeFrames[fakeFrames.length - 1]], + key: 'test-key', + state: 'Done', + }); + expect(i).toBe(15); + }); + + it('should stop querying when results come back with status "Complete"', async () => { + const { ds } = getTestContext(); + const fakeFrames = genMockFrames(3); + let i = 0; + jest.spyOn(ds, 'makeLogActionRequest').mockImplementation((subtype: LogAction) => { + if (subtype === 'GetQueryResults') { + const mockObservable = of([fakeFrames[i]]); + i++; + return mockObservable; + } else { + return of([]); + } + }); + + const myResponse = await ds.logsQuery([{ queryId: 'fake-query-id', region: 'default', refId: 'A' }]).toPromise(); + + expect(myResponse).toEqual({ + data: [fakeFrames[2]], + key: 'test-key', + state: 'Done', + }); + expect(i).toBe(3); + }); + + it('should call the replace method on provided log groups', () => { + const { ds } = getTestContext(); + const replaceSpy = jest.spyOn(ds, 'replace').mockImplementation((target: string) => target); + ds.makeLogActionRequest('StartQuery', [ + { + queryString: 'test query string', + region: 'default', + logGroupNames: ['log-group', '${my_var}Variable', 'Cool${other_var}'], + }, + ]); + + expect(replaceSpy).toBeCalledWith('log-group', undefined, true, 'log groups'); + expect(replaceSpy).toBeCalledWith('${my_var}Variable', undefined, true, 'log groups'); + expect(replaceSpy).toBeCalledWith('Cool${other_var}', undefined, true, 'log groups'); + }); + }); + + describe('When performing CloudWatch metrics query', () => { + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + expression: '', + refId: 'A', + region: 'us-east-1', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistics: ['Average'], + period: '300', + }, + ], + }; + + const response: any = { + timings: [null], + results: { + A: { + type: 'Metrics', + error: '', + refId: 'A', + meta: {}, + series: [ + { + name: 'CPUUtilization_Average', + points: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + InstanceId: 'i-12345678', + }, + }, + ], + }, + }, + }; + + it('should generate the correct query', async () => { + const { ds, fetchMock } = getTestContext({ response }); + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + namespace: query.targets[0].namespace, + metricName: query.targets[0].metricName, + dimensions: { InstanceId: ['i-12345678'] }, + statistics: query.targets[0].statistics, + period: query.targets[0].period, + }), + ]) + ); + }); + }); + + it('should generate the correct query with interval variable', async () => { + const period: CustomVariableModel = { + ...initialVariableModelState, + id: 'period', + name: 'period', + index: 0, + current: { value: '10m', text: '10m', selected: true }, + options: [{ value: '10m', text: '10m', selected: true }], + multi: false, + includeAll: false, + query: '', + hide: VariableHide.dontHide, + type: 'custom', + }; + const templateSrv = new TemplateSrv(); + templateSrv.init([period]); + + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistics: ['Average'], + period: '[[period]]', + }, + ], + }; + + const { ds, fetchMock } = getTestContext({ response, templateSrv }); + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries[0].period).toEqual('600'); + }); + }); + + it.each(['pNN.NN', 'p9', 'p99.', 'p99.999'])('should cancel query for invalid extended statistics (%s)', (stat) => { + const { ds } = getTestContext({ response }); + const query = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistics: [stat], + period: '60s', + }, + ], + }; + + expect(ds.query.bind(ds, query)).toThrow(/Invalid extended statistics/); + }); + + it('should return series list', async () => { + const { ds } = getTestContext({ response }); + + await expect(ds.query(query)).toEmitValuesWith((received) => { + const result = received[0]; + expect(getFrameDisplayName(result.data[0])).toBe(response.results.A.series[0].name); + expect(result.data[0].fields[1].values.buffer[0]).toBe(response.results.A.series[0].points[0][0]); + }); + }); + + describe('and throttling exception is thrown', () => { + const partialQuery = { + type: 'Metrics', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistics: ['Average'], + period: '300', + expression: '', + }; + + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { ...partialQuery, refId: 'A', region: 'us-east-1' }, + { ...partialQuery, refId: 'B', region: 'us-east-2' }, + { ...partialQuery, refId: 'C', region: 'us-east-1' }, + { ...partialQuery, refId: 'D', region: 'us-east-2' }, + { ...partialQuery, refId: 'E', region: 'eu-north-1' }, + ], + }; + + const backendErrorResponse = { + data: { + message: 'Throttling: exception', + results: { + A: { + error: 'Throttling: exception', + refId: 'A', + meta: {}, + }, + B: { + error: 'Throttling: exception', + refId: 'B', + meta: {}, + }, + C: { + error: 'Throttling: exception', + refId: 'C', + meta: {}, + }, + D: { + error: 'Throttling: exception', + refId: 'D', + meta: {}, + }, + E: { + error: 'Throttling: exception', + refId: 'E', + meta: {}, + }, + }, + }, + }; + + beforeEach(() => { + redux.setStore({ + dispatch: jest.fn(), + } as any); + }); + + it('should display one alert error message per region+datasource combination', async () => { + const { ds } = getTestContext({ response: backendErrorResponse, throws: true }); + const memoizedDebounceSpy = jest.spyOn(ds, 'debouncedAlert'); + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'us-east-1'); + expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'us-east-2'); + expect(memoizedDebounceSpy).toHaveBeenCalledWith('TestDatasource', 'eu-north-1'); + expect(memoizedDebounceSpy).toBeCalledTimes(3); + }); + }); + }); + + describe('when regions query is used', () => { + describe('and region param is left out', () => { + it('should use the default region', async () => { + const { ds, instanceSettings } = getTestContext(); + ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]); + + await ds.metricFindQuery('metrics(testNamespace)'); + + expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', { + namespace: 'testNamespace', + region: instanceSettings.jsonData.defaultRegion, + }); + }); + }); + + describe('and region param is defined by user', () => { + it('should use the user defined region', async () => { + const { ds } = getTestContext(); + ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]); + + await ds.metricFindQuery('metrics(testNamespace2, custom-region)'); + + expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', { + namespace: 'testNamespace2', + region: 'custom-region', + }); + }); + }); + }); + }); + + describe('When query region is "default"', () => { + it('should return the datasource region if empty or "default"', () => { + const { ds, instanceSettings } = getTestContext(); + const defaultRegion = instanceSettings.jsonData.defaultRegion; + + expect(ds.getActualRegion()).toBe(defaultRegion); + expect(ds.getActualRegion('')).toBe(defaultRegion); + expect(ds.getActualRegion('default')).toBe(defaultRegion); + }); + + it('should return the specified region if specified', () => { + const { ds } = getTestContext(); + + expect(ds.getActualRegion('some-fake-region-1')).toBe('some-fake-region-1'); + }); + + it('should query for the datasource region if empty or "default"', async () => { + const { ds, instanceSettings } = getTestContext(); + const performTimeSeriesQueryMock = jest.spyOn(ds, 'performTimeSeriesQuery').mockReturnValue(of({})); + + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'default', + namespace: 'AWS/EC2', + metricName: 'CPUUtilization', + dimensions: { + InstanceId: 'i-12345678', + }, + statistics: ['Average'], + period: '300s', + }, + ], + }; + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(performTimeSeriesQueryMock.mock.calls[0][0].queries[0].region).toBe( + instanceSettings.jsonData.defaultRegion + ); + }); + }); + }); + + describe('When interpolating variables', () => { + it('should return an empty array if no queries are provided', () => { + const templateSrv: any = { replace: jest.fn() }; + const { ds } = getTestContext({ templateSrv }); + + expect(ds.interpolateVariablesInQueries([], {})).toHaveLength(0); + }); + + it('should replace correct variables in CloudWatchLogsQuery', () => { + const templateSrv: any = { replace: jest.fn() }; + const { ds } = getTestContext({ templateSrv }); + const variableName = 'someVar'; + const logQuery: CloudWatchLogsQuery = { + id: 'someId', + refId: 'someRefId', + queryMode: 'Logs', + expression: `$${variableName}`, + region: `$${variableName}`, + }; + + ds.interpolateVariablesInQueries([logQuery], {}); + + // We interpolate `expression` and `region` in CloudWatchLogsQuery + expect(templateSrv.replace).toHaveBeenCalledWith(`$${variableName}`, {}); + expect(templateSrv.replace).toHaveBeenCalledTimes(2); + }); + + it('should replace correct variables in CloudWatchMetricsQuery', () => { + const templateSrv: any = { replace: jest.fn() }; + const { ds } = getTestContext({ templateSrv }); + const variableName = 'someVar'; + const logQuery: CloudWatchMetricsQuery = { + id: 'someId', + refId: 'someRefId', + queryMode: 'Metrics', + expression: `$${variableName}`, + region: `$${variableName}`, + period: `$${variableName}`, + alias: `$${variableName}`, + metricName: `$${variableName}`, + namespace: `$${variableName}`, + dimensions: { + [`$${variableName}`]: `$${variableName}`, + }, + matchExact: false, + statistics: [], + }; + + ds.interpolateVariablesInQueries([logQuery], {}); + + // We interpolate `expression`, `region`, `period`, `alias`, `metricName`, `nameSpace` and `dimensions` in CloudWatchMetricsQuery + expect(templateSrv.replace).toHaveBeenCalledWith(`$${variableName}`, {}); + expect(templateSrv.replace).toHaveBeenCalledTimes(8); + }); + }); + + describe('When performing CloudWatch query for extended statistics', () => { + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'AWS/ApplicationELB', + metricName: 'TargetResponseTime', + dimensions: { + LoadBalancer: 'lb', + TargetGroup: 'tg', + }, + statistics: ['p90.00'], + period: '300s', + }, + ], + }; + + const response: any = { + timings: [null], + results: { + A: { + error: '', + refId: 'A', + meta: {}, + series: [ + { + name: 'TargetResponseTime_p90.00', + points: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + LoadBalancer: 'lb', + TargetGroup: 'tg', + }, + }, + ], + }, + }, + }; + + it('should return series list', async () => { + const { ds } = getTestContext({ response }); + + await expect(ds.query(query)).toEmitValuesWith((received) => { + const result = received[0]; + expect(getFrameDisplayName(result.data[0])).toBe(response.results.A.series[0].name); + expect(result.data[0].fields[1].values.buffer[0]).toBe(response.results.A.series[0].points[0][0]); + }); + }); + }); + + describe('When performing CloudWatch query with template variables', () => { + let templateSrv: TemplateSrv; + beforeEach(() => { + const var1: CustomVariableModel = { + ...initialVariableModelState, + id: 'var1', + name: 'var1', + index: 0, + current: { value: 'var1-foo', text: 'var1-foo', selected: true }, + options: [{ value: 'var1-foo', text: 'var1-foo', selected: true }], + multi: false, + includeAll: false, + query: '', + hide: VariableHide.dontHide, + type: 'custom', + }; + const var2: CustomVariableModel = { + ...initialVariableModelState, + id: 'var2', + name: 'var2', + index: 1, + current: { value: 'var2-foo', text: 'var2-foo', selected: true }, + options: [{ value: 'var2-foo', text: 'var2-foo', selected: true }], + multi: false, + includeAll: false, + query: '', + hide: VariableHide.dontHide, + type: 'custom', + }; + const var3: CustomVariableModel = { + ...initialVariableModelState, + id: 'var3', + name: 'var3', + index: 2, + current: { value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz', selected: true }, + options: [ + { selected: true, value: 'var3-foo', text: 'var3-foo' }, + { selected: false, value: 'var3-bar', text: 'var3-bar' }, + { selected: true, value: 'var3-baz', text: 'var3-baz' }, + ], + multi: true, + includeAll: false, + query: '', + hide: VariableHide.dontHide, + type: 'custom', + }; + const var4: CustomVariableModel = { + ...initialVariableModelState, + id: 'var4', + name: 'var4', + index: 3, + options: [ + { selected: true, value: 'var4-foo', text: 'var4-foo' }, + { selected: false, value: 'var4-bar', text: 'var4-bar' }, + { selected: true, value: 'var4-baz', text: 'var4-baz' }, + ], + current: { value: ['var4-foo', 'var4-baz'], text: 'var4-foo + var4-baz', selected: true }, + multi: true, + includeAll: false, + query: '', + hide: VariableHide.dontHide, + type: 'custom', + }; + const variables = [var1, var2, var3, var4]; + const state = convertToStoreState(variables); + templateSrv = new TemplateSrv(getTemplateSrvDependencies(state)); + templateSrv.init(variables); + }); + + it('should generate the correct query for single template variable', async () => { + const { ds, fetchMock } = getTestContext({ templateSrv }); + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'TestNamespace', + metricName: 'TestMetricName', + dimensions: { + dim2: '[[var2]]', + }, + statistics: ['Average'], + period: '300s', + }, + ], + }; + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']); + }); + }); + + it('should generate the correct query in the case of one multilple template variables', async () => { + const { ds, fetchMock } = getTestContext({ templateSrv }); + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'TestNamespace', + metricName: 'TestMetricName', + dimensions: { + dim1: '[[var1]]', + dim2: '[[var2]]', + dim3: '[[var3]]', + }, + statistics: ['Average'], + period: '300s', + }, + ], + scopedVars: { + var1: { selected: true, value: 'var1-foo' }, + var2: { selected: true, value: 'var2-foo' }, + }, + }; + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']); + }); + }); + + it('should generate the correct query in the case of multilple multi template variables', async () => { + const { ds, fetchMock } = getTestContext({ templateSrv }); + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'TestNamespace', + metricName: 'TestMetricName', + dimensions: { + dim1: '[[var1]]', + dim3: '[[var3]]', + dim4: '[[var4]]', + }, + statistics: ['Average'], + period: '300s', + }, + ], + }; + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim4']).toStrictEqual(['var4-foo', 'var4-baz']); + }); + }); + + it('should generate the correct query for multilple template variables, lack scopedVars', async () => { + const { ds, fetchMock } = getTestContext({ templateSrv }); + const query: any = { + range: defaultTimeRange, + rangeRaw: { from: 1483228800, to: 1483232400 }, + targets: [ + { + type: 'Metrics', + refId: 'A', + region: 'us-east-1', + namespace: 'TestNamespace', + metricName: 'TestMetricName', + dimensions: { + dim1: '[[var1]]', + dim2: '[[var2]]', + dim3: '[[var3]]', + }, + statistics: ['Average'], + period: '300', + }, + ], + scopedVars: { + var1: { selected: true, value: 'var1-foo' }, + }, + }; + + await expect(ds.query(query)).toEmitValuesWith(() => { + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim1']).toStrictEqual(['var1-foo']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim2']).toStrictEqual(['var2-foo']); + expect(fetchMock.mock.calls[0][0].data.queries[0].dimensions['dim3']).toStrictEqual(['var3-foo', 'var3-baz']); + }); + }); + }); + + function describeMetricFindQuery(query: any, func: any) { + describe('metricFindQuery ' + query, () => { + const scenario: any = {}; + scenario.setup = async (setupCallback: any) => { + beforeEach(async () => { + await setupCallback(); + const { ds } = getTestContext({ response: scenario.requestResponse }); + ds.metricFindQuery(query).then((args: any) => { + scenario.result = args; + }); + }); + }; + + func(scenario); + }); + } + + describeMetricFindQuery('regions()', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['us-east-1', 'us-east-1']] }], + }, + }, + }; + }); + + it('should call __GetRegions and return result', () => { + expect(scenario.result[0].text).toContain('us-east-1'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('regions'); + }); + }); + + describeMetricFindQuery('namespaces()', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['AWS/EC2', 'AWS/EC2']] }], + }, + }, + }; + }); + + it('should call __GetNamespaces and return result', () => { + expect(scenario.result[0].text).toContain('AWS/EC2'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('namespaces'); + }); + }); + + describeMetricFindQuery('metrics(AWS/EC2, us-east-2)', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['CPUUtilization', 'CPUUtilization']] }], + }, + }, + }; + }); + + it('should call __GetMetrics and return result', () => { + expect(scenario.result[0].text).toBe('CPUUtilization'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('metrics'); + }); + }); + + describeMetricFindQuery('dimension_keys(AWS/EC2)', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['InstanceId', 'InstanceId']] }], + }, + }, + }; + }); + + it('should call __GetDimensions and return result', () => { + expect(scenario.result[0].text).toBe('InstanceId'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('dimension_keys'); + }); + }); + + describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['i-12345678', 'i-12345678']] }], + }, + }, + }; + }); + + it('should call __ListMetrics and return result', () => { + expect(scenario.result[0].text).toContain('i-12345678'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('dimension_values'); + }); + }); + + describeMetricFindQuery('dimension_values(default,AWS/EC2,CPUUtilization,InstanceId)', async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [{ rows: [['i-12345678', 'i-12345678']] }], + }, + }, + }; + }); + + it('should call __ListMetrics and return result', () => { + expect(scenario.result[0].text).toContain('i-12345678'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('dimension_values'); + }); + }); + + describeMetricFindQuery( + 'resource_arns(default,ec2:instance,{"environment":["production"]})', + async (scenario: any) => { + await scenario.setup(() => { + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [ + { + rows: [ + [ + 'arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567', + 'arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321', + ], + ], + }, + ], + }, + }, + }; + }); + + it('should call __ListMetrics and return result', () => { + expect(scenario.result[0].text).toContain('arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567'); + expect(scenario.request.queries[0].type).toBe('metricFindQuery'); + expect(scenario.request.queries[0].subtype).toBe('resource_arns'); + }); + } + ); +}); + +function genMockFrames(numResponses: number): DataFrame[] { + const recordIncrement = 50; + const mockFrames: DataFrame[] = []; + + for (let i = 0; i < numResponses; i++) { + mockFrames.push({ + fields: [], + meta: { + custom: { + Status: i === numResponses - 1 ? CloudWatchLogsQueryStatus.Complete : CloudWatchLogsQueryStatus.Running, + }, + stats: [ + { + displayName: 'Records scanned', + value: (i + 1) * recordIncrement, + }, + ], + }, + refId: 'A', + length: 0, + }); + } + + return mockFrames; +} diff --git a/public/app/plugins/datasource/cloudwatch/syntax.ts b/public/app/plugins/datasource/cloudwatch/syntax.ts new file mode 100644 index 0000000..92c279e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/syntax.ts @@ -0,0 +1,365 @@ +import { Grammar } from 'prismjs'; +import { CompletionItem } from '@grafana/ui'; + +export const QUERY_COMMANDS: CompletionItem[] = [ + { + label: 'fields', + documentation: 'Retrieves the specified fields from log events', + }, + { label: 'display', documentation: 'Specifies which fields to display in the query results' }, + { + label: 'filter', + documentation: 'Filters the results of a query based on one or more conditions', + }, + { + label: 'stats', + documentation: 'Calculates aggregate statistics based on the values of log fields', + }, + { label: 'sort', documentation: 'Sorts the retrieved log events' }, + { label: 'limit', documentation: 'Specifies the number of log events returned by the query' }, + { + label: 'parse', + documentation: + 'Extracts data from a log field, creating one or more ephemeral fields that you can process further in the query', + }, +]; + +export const COMPARISON_OPERATORS = ['=', '!=', '<', '<=', '>', '>=']; +export const ARITHMETIC_OPERATORS = ['+', '-', '*', '/', '^', '%']; + +export const NUMERIC_OPERATORS = [ + { + label: 'abs', + detail: 'abs(a)', + documentation: 'Absolute value.', + }, + { + label: 'ceil', + detail: 'ceil(a)', + documentation: 'Round to ceiling (the smallest integer that is greater than the value of a).', + }, + { + label: 'floor', + detail: 'floor(a)', + documentation: 'Round to floor (the largest integer that is smaller than the value of a).', + }, + { + label: 'greatest', + detail: 'greatest(a,b, ... z)', + documentation: 'Returns the largest value.', + }, + { + label: 'least', + detail: 'least(a, b, ... z)', + documentation: 'Returns the smallest value.', + }, + { + label: 'log', + detail: 'log(a)', + documentation: 'Natural logarithm.', + }, + { + label: 'sqrt', + detail: 'sqrt(a)', + documentation: 'Square root.', + }, +]; + +export const GENERAL_FUNCTIONS = [ + { + label: 'ispresent', + detail: 'ispresent(fieldname)', + documentation: 'Returns true if the field exists.', + }, + { + label: 'coalesce', + detail: 'coalesce(fieldname1, fieldname2, ... fieldnamex)', + documentation: 'Returns the first non-null value from the list.', + }, +]; + +export const STRING_FUNCTIONS = [ + { + label: 'isempty', + detail: 'isempty(fieldname)', + documentation: 'Returns true if the field is missing or is an empty string.', + }, + { + label: 'isblank', + detail: 'isblank(fieldname)', + documentation: 'Returns true if the field is missing, an empty string, or contains only white space.', + }, + { + label: 'concat', + detail: 'concat(string1, string2, ... stringz)', + documentation: 'Concatenates the strings.', + }, + { + label: 'ltrim', + detail: 'ltrim(string) or ltrim(string1, string2)', + documentation: + 'Remove white space from the left of the string. If the function has a second string argument, it removes the characters of string2 from the left of string1.', + }, + { + label: 'rtrim', + detail: 'rtrim(string) or rtrim(string1, string2)', + documentation: + 'Remove white space from the right of the string. If the function has a second string argument, it removes the characters of string2 from the right of string1.', + }, + { + label: 'trim', + detail: 'trim(string) or trim(string1, string2)', + documentation: + 'Remove white space from both ends of the string. If the function has a second string argument, it removes the characters of string2 from both sides of string1.', + }, + { + label: 'strlen', + detail: 'strlen(string)', + documentation: 'Returns the length of the string in Unicode code points.', + }, + { + label: 'toupper', + detail: 'toupper(string)', + documentation: 'Converts the string to uppercase.', + }, + { + label: 'tolower', + detail: 'tolower(string)', + documentation: 'Converts the string to lowercase.', + }, + { + label: 'substr', + detail: 'substr(string1, x), or substr(string1, x, y)', + documentation: + 'Returns a substring from the index specified by the number argument to the end of the string. If the function has a second number argument, it contains the length of the substring to be retrieved.', + }, + { + label: 'replace', + detail: 'replace(string1, string2, string3)', + documentation: 'Replaces all instances of string2 in string1 with string3.', + }, + { + label: 'strcontains', + detail: 'strcontains(string1, string2)', + documentation: 'Returns 1 if string1 contains string2 and 0 otherwise.', + }, +]; + +export const DATETIME_FUNCTIONS = [ + { + label: 'bin', + detail: 'bin(period)', + documentation: 'Rounds the value of @timestamp to the given period and then truncates.', + }, + { + label: 'datefloor', + detail: 'datefloor(a, period)', + documentation: 'Truncates the timestamp to the given period.', + }, + { + label: 'dateceil', + detail: 'dateceil(a, period)', + documentation: 'Rounds up the timestamp to the given period and then truncates.', + }, + { + label: 'fromMillis', + detail: 'fromMillis(fieldname)', + documentation: + 'Interprets the input field as the number of milliseconds since the Unix epoch and converts it to a timestamp.', + }, + { + label: 'toMillis', + detail: 'toMillis(fieldname)', + documentation: + 'Converts the timestamp found in the named field into a number representing the milliseconds since the Unix epoch.', + }, +]; + +export const IP_FUNCTIONS = [ + { + label: 'isValidIp', + detail: 'isValidIp(fieldname)', + documentation: 'Returns true if the field is a valid v4 or v6 IP address.', + }, + { + label: 'isValidIpV4', + detail: 'isValidIpV4(fieldname)', + documentation: 'Returns true if the field is a valid v4 IP address.', + }, + { + label: 'isValidIpV6', + detail: 'isValidIpV6(fieldname)', + documentation: 'Returns true if the field is a valid v6 IP address.', + }, + { + label: 'isIpInSubnet', + detail: 'isIpInSubnet(fieldname, string)', + documentation: 'Returns true if the field is a valid v4 or v6 IP address within the specified v4 or v6 subnet.', + }, + { + label: 'isIpv4InSubnet', + detail: 'isIpv4InSubnet(fieldname, string)', + documentation: 'Returns true if the field is a valid v4 IP address within the specified v4 subnet.', + }, + { + label: 'isIpv6InSubnet', + detail: 'isIpv6InSubnet(fieldname, string)', + documentation: 'Returns true if the field is a valid v6 IP address within the specified v6 subnet.', + }, +]; + +export const BOOLEAN_FUNCTIONS = [ + { + label: 'ispresent', + detail: 'ispresent(fieldname)', + documentation: 'Returns true if the field exists.', + }, + { + label: 'isempty', + detail: 'isempty(fieldname)', + documentation: 'Returns true if the field is missing or is an empty string.', + }, + { + label: 'isblank', + detail: 'isblank(fieldname)', + documentation: 'Returns true if the field is missing, an empty string, or contains only white space.', + }, + { + label: 'strcontains', + detail: 'strcontains(string1, string2)', + documentation: 'Returns 1 if string1 contains string2 and 0 otherwise.', + }, + ...IP_FUNCTIONS, +]; + +export const AGGREGATION_FUNCTIONS_STATS = [ + { + label: 'avg', + detail: 'avg(NumericFieldname)', + documentation: 'The average of the values in the specified field.', + }, + { + label: 'count', + detail: 'count(fieldname) or count(*)', + documentation: 'Counts the log records.', + }, + { + label: 'count_distinct', + detail: 'count_distinct(fieldname)', + documentation: 'Returns the number of unique values for the field.', + }, + { + label: 'max', + detail: 'max(fieldname)', + documentation: 'The maximum of the values for this log field in the queried logs.', + }, + { + label: 'min', + detail: 'min(fieldname)', + documentation: 'The minimum of the values for this log field in the queried logs.', + }, + { + label: 'pct', + detail: 'pct(fieldname, value)', + documentation: 'A percentile indicates the relative standing of a value in a datas.', + }, + { + label: 'stddev', + detail: 'stddev(NumericFieldname)', + documentation: 'The standard deviation of the values in the specified field.', + }, + { + label: 'sum', + detail: 'sum(NumericFieldname)', + documentation: 'The sum of the values in the specified field.', + }, +]; + +export const NON_AGGREGATION_FUNCS_STATS = [ + { + label: 'earliest', + detail: 'earliest(fieldname)', + documentation: + 'Returns the value of fieldName from the log event that has the earliest time stamp in the queried logs.', + }, + { + label: 'latest', + detail: 'latest(fieldname)', + documentation: + 'Returns the value of fieldName from the log event that has the latest time stamp in the queried logs.', + }, + { + label: 'sortsFirst', + detail: 'sortsFirst(fieldname)', + documentation: 'Returns the value of fieldName that sorts first in the queried logs.', + }, + { + label: 'sortsLast', + detail: 'sortsLast(fieldname)', + documentation: 'Returns the value of fieldName that sorts last in the queried logs.', + }, +]; + +export const STATS_FUNCS = [...AGGREGATION_FUNCTIONS_STATS, ...NON_AGGREGATION_FUNCS_STATS]; + +export const KEYWORDS = ['as', 'like', 'by', 'in', 'desc', 'asc']; +export const FIELD_AND_FILTER_FUNCTIONS = [ + ...NUMERIC_OPERATORS, + ...GENERAL_FUNCTIONS, + ...STRING_FUNCTIONS, + ...DATETIME_FUNCTIONS, + ...IP_FUNCTIONS, +]; + +export const FUNCTIONS = [...FIELD_AND_FILTER_FUNCTIONS, ...STATS_FUNCS]; + +const tokenizer: Grammar = { + comment: { + pattern: /^#.*/, + greedy: true, + }, + backticks: { + pattern: /`.*?`/, + alias: 'string', + greedy: true, + }, + quote: { + pattern: /".*?"/, + alias: 'string', + greedy: true, + }, + regex: { + pattern: /\/.*?\/(?=\||\s*$|,)/, + greedy: true, + }, + 'query-command': { + pattern: new RegExp(`\\b(?:${QUERY_COMMANDS.map((command) => command.label).join('|')})\\b`, 'i'), + alias: 'function', + }, + function: { + pattern: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})\\b`, 'i'), + }, + keyword: { + pattern: new RegExp(`(\\s+)(${KEYWORDS.join('|')})(?=\\s+)`, 'i'), + lookbehind: true, + }, + // 'log-group-name': { + // pattern: /[\.\-_/#A-Za-z0-9]+/, + // }, + 'field-name': { + pattern: /(@?[_a-zA-Z]+[_.0-9a-zA-Z]*)|(`((\\`)|([^`]))*?`)/, + greedy: true, + }, + number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, + 'command-separator': { + pattern: /\|/, + alias: 'punctuation', + }, + 'comparison-operator': { + pattern: /([<>]=?)|(!?=)/, + }, + punctuation: /[{}()`,.]/, + whitespace: /\s+/, +}; + +export default tokenizer; diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts new file mode 100644 index 0000000..136a399 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -0,0 +1,337 @@ +import { DataQuery, SelectableValue } from '@grafana/data'; +import { AwsAuthDataSourceSecureJsonData, AwsAuthDataSourceJsonData } from '@grafana/aws-sdk'; + +export interface CloudWatchMetricsQuery extends DataQuery { + queryMode?: 'Metrics'; + + id: string; + region: string; + namespace: string; + expression: string; + + metricName: string; + dimensions: { [key: string]: string | string[] }; + statistics: string[]; + period: string; + alias: string; + matchExact: boolean; +} + +export type LogAction = + | 'DescribeLogGroups' + | 'GetQueryResults' + | 'GetLogGroupFields' + | 'GetLogEvents' + | 'StartQuery' + | 'StopQuery'; + +export enum CloudWatchLogsQueryStatus { + Scheduled = 'Scheduled', + Running = 'Running', + Complete = 'Complete', + Failed = 'Failed', + Cancelled = 'Cancelled', + Timeout = 'Timeout', +} + +export interface CloudWatchLogsQuery extends DataQuery { + queryMode: 'Logs'; + + id: string; + region: string; + expression?: string; + logGroupNames?: string[]; + statsGroups?: string[]; +} + +export type CloudWatchQuery = CloudWatchMetricsQuery | CloudWatchLogsQuery; + +export const isCloudWatchLogsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwatchQuery is CloudWatchLogsQuery => + (cloudwatchQuery as CloudWatchLogsQuery).queryMode === 'Logs'; + +export interface AnnotationQuery extends CloudWatchMetricsQuery { + prefixMatching: boolean; + actionPrefix: string; + alarmNamePrefix: string; +} + +export type SelectableStrings = Array>; + +export interface CloudWatchJsonData extends AwsAuthDataSourceJsonData { + timeField?: string; + database?: string; + customMetricsNamespaces?: string; + endpoint?: string; +} + +export interface CloudWatchSecureJsonData extends AwsAuthDataSourceSecureJsonData { + accessKey: string; + secretKey: string; +} + +export interface GetQueryResultsRequest { + /** + * The ID number of the query. + */ + queryId: string; +} + +export interface ResultField { + /** + * The log event field. + */ + field?: string; + /** + * The value of this field. + */ + value?: string; +} + +export interface QueryStatistics { + /** + * The number of log events that matched the query string. + */ + recordsMatched?: number; + /** + * The total number of log events scanned during the query. + */ + recordsScanned?: number; + /** + * The total number of bytes in the log events scanned during the query. + */ + bytesScanned?: number; +} + +export type QueryStatus = 'Scheduled' | 'Running' | 'Complete' | 'Failed' | 'Cancelled' | string; + +export interface GetLogEventsRequest { + /** + * The name of the log group. + */ + logGroupName: string; + /** + * The name of the log stream. + */ + logStreamName: string; + /** + * The start of the time range, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. Events with a timestamp equal to this time or later than this time are included. Events with a timestamp earlier than this time are not included. + */ + startTime?: number; + /** + * The end of the time range, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. Events with a timestamp equal to or later than this time are not included. + */ + endTime?: number; + /** + * The token for the next set of items to return. (You received this token from a previous call.) Using this token works only when you specify true for startFromHead. + */ + nextToken?: string; + /** + * The maximum number of log events returned. If you don't specify a value, the maximum is as many log events as can fit in a response size of 1 MB, up to 10,000 log events. + */ + limit?: number; + /** + * If the value is true, the earliest log events are returned first. If the value is false, the latest log events are returned first. The default value is false. If you are using nextToken in this operation, you must specify true for startFromHead. + */ + startFromHead?: boolean; +} + +export interface GetQueryResultsResponse { + /** + * The log events that matched the query criteria during the most recent time it ran. The results value is an array of arrays. Each log event is one object in the top-level array. Each of these log event objects is an array of field/value pairs. + */ + results?: ResultField[][]; + /** + * Includes the number of log events scanned by the query, the number of log events that matched the query criteria, and the total number of bytes in the log events that were scanned. + */ + statistics?: QueryStatistics; + /** + * The status of the most recent running of the query. Possible values are Cancelled, Complete, Failed, Running, Scheduled, Timeout, and Unknown. Queries time out after 15 minutes of execution. To avoid having your queries time out, reduce the time range being searched, or partition your query into a number of queries. + */ + status?: QueryStatus; +} + +export interface DescribeLogGroupsRequest { + /** + * The prefix to match. + */ + logGroupNamePrefix?: string; + /** + * The token for the next set of items to return. (You received this token from a previous call.) + */ + nextToken?: string; + /** + * The maximum number of items returned. If you don't specify a value, the default is up to 50 items. + */ + limit?: number; + refId?: string; + region?: string; +} + +export interface TSDBResponse { + results: Record>; + message?: string; +} + +export interface TSDBQueryResult { + refId: string; + series: TSDBTimeSeries[]; + tables: Array>; + dataframes: number[][]; + + error?: string; + meta?: any; +} + +export interface TSDBTable { + columns: Array<{ text: string }>; + rows: T[]; +} + +export interface TSDBTimeSeries { + name: string; + points: TSDBTimePoint[]; + tags?: Record; +} +export type TSDBTimePoint = [number, number]; + +export interface LogGroup { + /** + * The name of the log group. + */ + logGroupName?: string; + /** + * The creation time of the log group, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. + */ + creationTime?: number; + retentionInDays?: number; + /** + * The number of metric filters. + */ + metricFilterCount?: number; + /** + * The Amazon Resource Name (ARN) of the log group. + */ + arn?: string; + /** + * The number of bytes stored. + */ + storedBytes?: number; + /** + * The Amazon Resource Name (ARN) of the CMK to use when encrypting log data. + */ + kmsKeyId?: string; +} + +export interface DescribeLogGroupsResponse { + /** + * The log groups. + */ + logGroups?: LogGroup[]; + nextToken?: string; +} + +export interface GetLogGroupFieldsRequest { + /** + * The name of the log group to search. + */ + logGroupName: string; + /** + * The time to set as the center of the query. If you specify time, the 8 minutes before and 8 minutes after this time are searched. If you omit time, the past 15 minutes are queried. The time value is specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC. + */ + time?: number; +} + +export interface LogGroupField { + /** + * The name of a log field. + */ + name?: string; + /** + * The percentage of log events queried that contained the field. + */ + percent?: number; +} + +export interface GetLogGroupFieldsResponse { + /** + * The array of fields found in the query. Each object in the array contains the name of the field, along with the percentage of time it appeared in the log events that were queried. + */ + logGroupFields?: LogGroupField[]; +} + +export interface StartQueryRequest { + /** + * The log group on which to perform the query. A StartQuery operation must include a logGroupNames or a logGroupName parameter, but not both. + */ + logGroupName?: string; + /** + * The list of log groups to be queried. You can include up to 20 log groups. A StartQuery operation must include a logGroupNames or a logGroupName parameter, but not both. + */ + logGroupNames?: string[]; + /** + * The beginning of the time range to query. The range is inclusive, so the specified start time is included in the query. Specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC. + */ + startTime: number; + /** + * The end of the time range to query. The range is inclusive, so the specified end time is included in the query. Specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC. + */ + endTime: number; + /** + * The query string to use. For more information, see CloudWatch Logs Insights Query Syntax. + */ + queryString: string; + /** + * The maximum number of log events to return in the query. If the query string uses the fields command, only the specified fields and their values are returned. The default is 1000. + */ + limit?: number; +} +export interface StartQueryResponse { + /** + * The unique ID of the query. + */ + queryId?: string; +} + +export interface MetricRequest { + from: string; + to: string; + queries: MetricQuery[]; + debug?: boolean; +} + +export interface MetricQuery { + [key: string]: any; + datasourceId: number; + refId?: string; + maxDataPoints?: number; + intervalMs?: number; +} + +// interface TsdbQuery { +// TimeRange *TimeRange +// Queries []*Query +// Debug bool +// } + +// type Query struct { +// RefId string +// Model *simplejson.Json +// DataSource *models.DataSource +// MaxDataPoints int64 +// IntervalMs int64 +// } + +export interface CloudWatchMetricsAnnotation { + namespace: string; + metricName: string; + expression: string; + dimensions: {}; + region: string; + id: string; + alias: string; + statistics: string[]; + matchExact: true; + prefixMatching: false; + actionPrefix: string; + alarmNamePrefix: string; +} diff --git a/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLLexer.js b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLLexer.js new file mode 100644 index 0000000..4a8aee9 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLLexer.js @@ -0,0 +1,1390 @@ +// Generated from ScrollQLLexer.g4 by ANTLR 4.8 +// jshint ignore: start +// @ts-nocheck +var antlr4 = require('antlr4/index'); + +var serializedATN = [ + '\u0003\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964', + '\u0002r\u04b0\b\u0001\b\u0001\b\u0001\u0004\u0002\t\u0002\u0004\u0003', + '\t\u0003\u0004\u0004\t\u0004\u0004\u0005\t\u0005\u0004\u0006\t\u0006', + '\u0004\u0007\t\u0007\u0004\b\t\b\u0004\t\t\t\u0004\n\t\n\u0004\u000b', + '\t\u000b\u0004\f\t\f\u0004\r\t\r\u0004\u000e\t\u000e\u0004\u000f\t\u000f', + '\u0004\u0010\t\u0010\u0004\u0011\t\u0011\u0004\u0012\t\u0012\u0004\u0013', + '\t\u0013\u0004\u0014\t\u0014\u0004\u0015\t\u0015\u0004\u0016\t\u0016', + '\u0004\u0017\t\u0017\u0004\u0018\t\u0018\u0004\u0019\t\u0019\u0004\u001a', + '\t\u001a\u0004\u001b\t\u001b\u0004\u001c\t\u001c\u0004\u001d\t\u001d', + '\u0004\u001e\t\u001e\u0004\u001f\t\u001f\u0004 \t \u0004!\t!\u0004"', + "\t\"\u0004#\t#\u0004$\t$\u0004%\t%\u0004&\t&\u0004'\t'\u0004(\t(\u0004", + ')\t)\u0004*\t*\u0004+\t+\u0004,\t,\u0004-\t-\u0004.\t.\u0004/\t/\u0004', + '0\t0\u00041\t1\u00042\t2\u00043\t3\u00044\t4\u00045\t5\u00046\t6\u0004', + '7\t7\u00048\t8\u00049\t9\u0004:\t:\u0004;\t;\u0004<\t<\u0004=\t=\u0004', + '>\t>\u0004?\t?\u0004@\t@\u0004A\tA\u0004B\tB\u0004C\tC\u0004D\tD\u0004', + 'E\tE\u0004F\tF\u0004G\tG\u0004H\tH\u0004I\tI\u0004J\tJ\u0004K\tK\u0004', + 'L\tL\u0004M\tM\u0004N\tN\u0004O\tO\u0004P\tP\u0004Q\tQ\u0004R\tR\u0004', + 'S\tS\u0004T\tT\u0004U\tU\u0004V\tV\u0004W\tW\u0004X\tX\u0004Y\tY\u0004', + 'Z\tZ\u0004[\t[\u0004\\\t\\\u0004]\t]\u0004^\t^\u0004_\t_\u0004`\t`\u0004', + 'a\ta\u0004b\tb\u0004c\tc\u0004d\td\u0004e\te\u0004f\tf\u0004g\tg\u0004', + 'h\th\u0004i\ti\u0004j\tj\u0004k\tk\u0004l\tl\u0004m\tm\u0004n\tn\u0004', + 'o\to\u0004p\tp\u0004q\tq\u0004r\tr\u0004s\ts\u0004t\tt\u0004u\tu\u0004', + 'v\tv\u0004w\tw\u0004x\tx\u0004y\ty\u0004z\tz\u0004{\t{\u0004|\t|\u0004', + '}\t}\u0004~\t~\u0004\u007f\t\u007f\u0004\u0080\t\u0080\u0004\u0081\t', + '\u0081\u0004\u0082\t\u0082\u0004\u0083\t\u0083\u0004\u0084\t\u0084\u0004', + '\u0085\t\u0085\u0004\u0086\t\u0086\u0004\u0087\t\u0087\u0004\u0088\t', + '\u0088\u0004\u0089\t\u0089\u0004\u008a\t\u008a\u0004\u008b\t\u008b\u0004', + '\u008c\t\u008c\u0004\u008d\t\u008d\u0004\u008e\t\u008e\u0004\u008f\t', + '\u008f\u0004\u0090\t\u0090\u0004\u0091\t\u0091\u0004\u0092\t\u0092\u0004', + '\u0093\t\u0093\u0004\u0094\t\u0094\u0004\u0095\t\u0095\u0004\u0096\t', + '\u0096\u0004\u0097\t\u0097\u0004\u0098\t\u0098\u0004\u0099\t\u0099\u0004', + '\u009a\t\u009a\u0004\u009b\t\u009b\u0004\u009c\t\u009c\u0004\u009d\t', + '\u009d\u0004\u009e\t\u009e\u0004\u009f\t\u009f\u0004\u00a0\t\u00a0\u0004', + '\u00a1\t\u00a1\u0004\u00a2\t\u00a2\u0004\u00a3\t\u00a3\u0004\u00a4\t', + '\u00a4\u0004\u00a5\t\u00a5\u0003\u0002\u0006\u0002\u014f\n\u0002\r\u0002', + '\u000e\u0002\u0150\u0003\u0002\u0003\u0002\u0003\u0003\u0003\u0003\u0007', + '\u0003\u0157\n\u0003\f\u0003\u000e\u0003\u015a\u000b\u0003\u0003\u0003', + '\u0003\u0003\u0003\u0004\u0003\u0004\u0003\u0004\u0003\u0004\u0003\u0004', + '\u0003\u0004\u0003\u0004\u0003\u0005\u0003\u0005\u0003\u0005\u0003\u0005', + '\u0003\u0005\u0003\u0005\u0003\u0006\u0003\u0006\u0003\u0006\u0003\u0006', + '\u0003\u0007\u0003\u0007\u0003\u0007\u0003\u0007\u0003\b\u0003\b\u0003', + '\b\u0003\b\u0003\b\u0003\t\u0003\t\u0003\t\u0003\t\u0003\t\u0003\t\u0003', + '\t\u0003\t\u0003\n\u0003\n\u0003\n\u0003\n\u0003\n\u0003\n\u0003\n\u0003', + '\n\u0003\n\u0003\u000b\u0003\u000b\u0003\u000b\u0003\u000b\u0003\u000b', + '\u0003\u000b\u0005\u000b\u018f\n\u000b\u0003\f\u0003\f\u0003\f\u0003', + '\f\u0003\f\u0003\f\u0003\f\u0003\f\u0003\r\u0003\r\u0003\r\u0003\r\u0003', + '\r\u0003\r\u0003\r\u0003\u000e\u0003\u000e\u0003\u000e\u0003\u000e\u0003', + '\u000e\u0005\u000e\u01a5\n\u000e\u0003\u000f\u0003\u000f\u0003\u000f', + '\u0003\u000f\u0003\u000f\u0003\u0010\u0003\u0010\u0003\u0010\u0003\u0010', + '\u0003\u0010\u0003\u0010\u0003\u0011\u0003\u0011\u0003\u0011\u0003\u0011', + '\u0003\u0012\u0003\u0012\u0003\u0012\u0003\u0012\u0003\u0012\u0003\u0013', + '\u0003\u0013\u0003\u0013\u0003\u0013\u0003\u0013\u0003\u0014\u0003\u0014', + '\u0003\u0014\u0003\u0014\u0003\u0014\u0003\u0014\u0003\u0015\u0003\u0015', + '\u0003\u0015\u0003\u0015\u0003\u0015\u0003\u0016\u0003\u0016\u0003\u0016', + '\u0003\u0016\u0003\u0016\u0003\u0016\u0003\u0017\u0003\u0017\u0003\u0017', + '\u0003\u0018\u0003\u0018\u0003\u0018\u0003\u0018\u0003\u0018\u0003\u0018', + '\u0003\u0019\u0003\u0019\u0003\u0019\u0003\u001a\u0003\u001a\u0003\u001a', + '\u0003\u001b\u0003\u001b\u0003\u001b\u0003\u001b\u0003\u001c\u0003\u001c', + '\u0003\u001c\u0003\u001d\u0003\u001d\u0003\u001d\u0003\u001d\u0003\u001e', + '\u0003\u001e\u0003\u001e\u0003\u001e\u0003\u001e\u0003\u001e\u0003\u001e', + '\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f', + '\u0003\u001f\u0003\u001f\u0003 \u0003 \u0003 \u0003!\u0003!\u0003!\u0003', + '!\u0003!\u0005!\u0202\n!\u0003"\u0003"\u0003"\u0003"\u0003"\u0003', + '"\u0003"\u0003"\u0003"\u0003"\u0003"\u0003"\u0005"\u0210\n"', + '\u0003#\u0003#\u0003$\u0003$\u0003$\u0003$\u0005$\u0218\n$\u0003%\u0003', + '%\u0003%\u0003%\u0003%\u0003%\u0003%\u0005%\u0221\n%\u0003&\u0003&\u0003', + "'\u0003'\u0003'\u0003'\u0005'\u0229\n'\u0003(\u0003(\u0003(\u0003", + '(\u0003(\u0003(\u0003(\u0005(\u0232\n(\u0003)\u0003)\u0003*\u0003*\u0003', + '*\u0005*\u0239\n*\u0003+\u0003+\u0003+\u0003+\u0003+\u0005+\u0240\n', + '+\u0003,\u0003,\u0003-\u0003-\u0003-\u0003-\u0005-\u0248\n-\u0003.\u0003', + '.\u0003/\u0003/\u0003/\u0003/\u0003/\u0005/\u0251\n/\u00030\u00030\u0003', + '0\u00050\u0256\n0\u00031\u00031\u00031\u00031\u00051\u025c\n1\u0003', + '2\u00032\u00032\u00032\u00032\u00032\u00052\u0264\n2\u00033\u00033\u0003', + '4\u00034\u00034\u00034\u00054\u026c\n4\u00035\u00035\u00035\u00035\u0003', + '5\u00035\u00035\u00035\u00055\u0276\n5\u00036\u00036\u00037\u00037\u0003', + '7\u00057\u027d\n7\u00038\u00038\u00038\u00038\u00038\u00058\u0284\n', + '8\u00039\u00039\u00079\u0288\n9\f9\u000e9\u028b\u000b9\u0003:\u0003', + ':\u0003;\u0003;\u0003;\u0006;\u0292\n;\r;\u000e;\u0293\u0003;\u0005', + ';\u0297\n;\u0003;\u0005;\u029a\n;\u0003<\u0006<\u029d\n<\r<\u000e<\u029e', + '\u0003=\u0003=\u0005=\u02a3\n=\u0003=\u0003=\u0003>\u0003>\u0003>\u0007', + '>\u02aa\n>\f>\u000e>\u02ad\u000b>\u0003>\u0003>\u0003?\u0003?\u0003', + '?\u0003@\u0003@\u0003@\u0007@\u02b7\n@\f@\u000e@\u02ba\u000b@\u0003', + '@\u0003@\u0003A\u0003A\u0003A\u0003B\u0003B\u0003B\u0003B\u0003B\u0003', + 'B\u0007B\u02c7\nB\fB\u000eB\u02ca\u000bB\u0003B\u0003B\u0003B\u0003', + 'B\u0003C\u0003C\u0003C\u0003C\u0003C\u0003D\u0003D\u0003D\u0003D\u0003', + 'D\u0003D\u0007D\u02db\nD\fD\u000eD\u02de\u000bD\u0003D\u0003D\u0003', + 'D\u0003D\u0003E\u0003E\u0003E\u0003E\u0003E\u0003F\u0003F\u0003F\u0007', + 'F\u02ec\nF\fF\u000eF\u02ef\u000bF\u0003F\u0003F\u0003G\u0003G\u0003', + 'G\u0003H\u0003H\u0003H\u0003H\u0003H\u0003I\u0003I\u0003I\u0003I\u0003', + 'I\u0003J\u0003J\u0003K\u0003K\u0003L\u0003L\u0003M\u0003M\u0003N\u0003', + 'N\u0003N\u0003N\u0003N\u0005N\u030d\nN\u0003O\u0003O\u0003O\u0003P\u0003', + 'P\u0003Q\u0003Q\u0003R\u0003R\u0003S\u0003S\u0003T\u0003T\u0003U\u0003', + 'U\u0003V\u0003V\u0003W\u0003W\u0003X\u0003X\u0003Y\u0003Y\u0003Z\u0003', + 'Z\u0003[\u0003[\u0003[\u0003\\\u0003\\\u0003\\\u0003]\u0003]\u0003^', + '\u0003^\u0003^\u0003_\u0003_\u0003`\u0003`\u0003a\u0003a\u0003b\u0003', + 'b\u0003c\u0003c\u0003d\u0003d\u0003e\u0003e\u0003f\u0003f\u0003g\u0003', + 'g\u0003h\u0003h\u0003i\u0003i\u0003j\u0003j\u0003k\u0003k\u0003l\u0003', + 'l\u0003m\u0003m\u0003n\u0003n\u0003o\u0003o\u0003p\u0003p\u0003q\u0003', + 'q\u0003r\u0003r\u0003s\u0003s\u0003t\u0003t\u0003u\u0003u\u0003v\u0003', + 'v\u0003w\u0003w\u0003x\u0003x\u0003y\u0003y\u0003z\u0003z\u0003{\u0003', + '{\u0003{\u0003{\u0003{\u0003|\u0006|\u0371\n|\r|\u000e|\u0372\u0003', + '|\u0003|\u0003}\u0003}\u0007}\u0379\n}\f}\u000e}\u037c\u000b}\u0003', + '}\u0003}\u0003~\u0003~\u0003~\u0007~\u0383\n~\f~\u000e~\u0386\u000b', + '~\u0003~\u0003~\u0003~\u0003~\u0003\u007f\u0003\u007f\u0003\u007f\u0007', + '\u007f\u038f\n\u007f\f\u007f\u000e\u007f\u0392\u000b\u007f\u0003\u007f', + '\u0003\u007f\u0003\u007f\u0003\u007f\u0003\u0080\u0003\u0080\u0003\u0080', + '\u0003\u0081\u0003\u0081\u0003\u0081\u0007\u0081\u039e\n\u0081\f\u0081', + '\u000e\u0081\u03a1\u000b\u0081\u0003\u0081\u0003\u0081\u0003\u0081\u0003', + '\u0081\u0003\u0082\u0003\u0082\u0003\u0082\u0003\u0083\u0003\u0083\u0003', + '\u0083\u0003\u0083\u0003\u0083\u0003\u0083\u0007\u0083\u03b0\n\u0083', + '\f\u0083\u000e\u0083\u03b3\u000b\u0083\u0003\u0083\u0003\u0083\u0003', + '\u0083\u0003\u0083\u0003\u0083\u0003\u0083\u0003\u0084\u0003\u0084\u0003', + '\u0084\u0003\u0084\u0003\u0084\u0003\u0085\u0003\u0085\u0003\u0085\u0003', + '\u0085\u0003\u0085\u0003\u0085\u0007\u0085\u03c6\n\u0085\f\u0085\u000e', + '\u0085\u03c9\u000b\u0085\u0003\u0085\u0003\u0085\u0003\u0085\u0003\u0085', + '\u0003\u0085\u0003\u0085\u0003\u0086\u0003\u0086\u0003\u0086\u0003\u0086', + '\u0003\u0086\u0003\u0087\u0003\u0087\u0007\u0087\u03d8\n\u0087\f\u0087', + '\u000e\u0087\u03db\u000b\u0087\u0003\u0088\u0003\u0088\u0003\u0089\u0003', + '\u0089\u0003\u0089\u0007\u0089\u03e2\n\u0089\f\u0089\u000e\u0089\u03e5', + '\u000b\u0089\u0003\u0089\u0003\u0089\u0003\u008a\u0003\u008a\u0003\u008a', + '\u0003\u008b\u0003\u008b\u0003\u008b\u0003\u008c\u0006\u008c\u03f0\n', + '\u008c\r\u008c\u000e\u008c\u03f1\u0003\u008c\u0003\u008c\u0003\u008d', + '\u0003\u008d\u0007\u008d\u03f8\n\u008d\f\u008d\u000e\u008d\u03fb\u000b', + '\u008d\u0003\u008d\u0003\u008d\u0003\u008e\u0003\u008e\u0003\u008e\u0003', + '\u008e\u0003\u008e\u0003\u008f\u0003\u008f\u0003\u008f\u0003\u008f\u0003', + '\u008f\u0003\u008f\u0003\u008f\u0003\u008f\u0003\u008f\u0003\u008f\u0003', + '\u008f\u0003\u0090\u0003\u0090\u0003\u0090\u0003\u0090\u0003\u0090\u0003', + '\u0090\u0003\u0090\u0003\u0090\u0003\u0090\u0003\u0090\u0003\u0091\u0003', + '\u0091\u0003\u0091\u0003\u0091\u0003\u0091\u0003\u0091\u0003\u0091\u0003', + '\u0092\u0003\u0092\u0003\u0092\u0003\u0092\u0003\u0092\u0003\u0092\u0005', + '\u0092\u0426\n\u0092\u0003\u0092\u0003\u0092\u0003\u0092\u0003\u0093', + '\u0003\u0093\u0003\u0093\u0003\u0093\u0003\u0093\u0003\u0093\u0003\u0093', + '\u0003\u0093\u0003\u0093\u0003\u0093\u0003\u0093\u0003\u0094\u0003\u0094', + '\u0003\u0094\u0003\u0094\u0003\u0094\u0003\u0094\u0003\u0094\u0003\u0094', + '\u0003\u0094\u0003\u0094\u0003\u0095\u0003\u0095\u0003\u0095\u0003\u0095', + '\u0003\u0095\u0005\u0095\u0445\n\u0095\u0003\u0095\u0003\u0095\u0003', + '\u0095\u0003\u0096\u0003\u0096\u0003\u0096\u0003\u0096\u0003\u0096\u0003', + '\u0096\u0003\u0096\u0003\u0096\u0003\u0097\u0003\u0097\u0003\u0097\u0003', + '\u0097\u0003\u0097\u0003\u0097\u0003\u0097\u0003\u0097\u0003\u0097\u0003', + '\u0098\u0003\u0098\u0003\u0099\u0003\u0099\u0003\u009a\u0003\u009a\u0003', + '\u009a\u0003\u009a\u0003\u009b\u0003\u009b\u0003\u009b\u0003\u009c\u0003', + '\u009c\u0003\u009c\u0003\u009c\u0003\u009d\u0003\u009d\u0003\u009d\u0007', + '\u009d\u046d\n\u009d\f\u009d\u000e\u009d\u0470\u000b\u009d\u0003\u009d', + '\u0003\u009d\u0003\u009e\u0003\u009e\u0003\u009e\u0003\u009f\u0003\u009f', + '\u0003\u009f\u0007\u009f\u047a\n\u009f\f\u009f\u000e\u009f\u047d\u000b', + '\u009f\u0003\u009f\u0003\u009f\u0003\u00a0\u0003\u00a0\u0003\u00a0\u0003', + '\u00a1\u0003\u00a1\u0003\u00a1\u0003\u00a1\u0003\u00a1\u0003\u00a1\u0007', + '\u00a1\u048a\n\u00a1\f\u00a1\u000e\u00a1\u048d\u000b\u00a1\u0003\u00a1', + '\u0003\u00a1\u0003\u00a1\u0003\u00a1\u0003\u00a2\u0003\u00a2\u0003\u00a2', + '\u0003\u00a2\u0003\u00a2\u0003\u00a3\u0003\u00a3\u0003\u00a3\u0003\u00a3', + '\u0003\u00a3\u0003\u00a3\u0007\u00a3\u049e\n\u00a3\f\u00a3\u000e\u00a3', + '\u04a1\u000b\u00a3\u0003\u00a3\u0003\u00a3\u0003\u00a3\u0003\u00a3\u0003', + '\u00a4\u0003\u00a4\u0003\u00a4\u0003\u00a4\u0003\u00a4\u0003\u00a5\u0006', + '\u00a5\u04ad\n\u00a5\r\u00a5\u000e\u00a5\u04ae\u0011\u02ab\u02b8\u02c8', + '\u02dc\u02ed\u0384\u0390\u039f\u03b1\u03c7\u03e3\u046e\u047b\u048b\u049f', + '\u0002\u00a6\u0005\u0003\u0007\u0004\t\u0005\u000b\u0006\r\u0007\u000f', + '\b\u0011\t\u0013\n\u0015\u000b\u0017\f\u0019\r\u001b\u000e\u001d\u000f', + "\u001f\u0010!\u0011#\u0012%\u0013'\u0014)\u0015+\u0016-\u0017/\u0018", + '1\u00193\u001a5\u001b7\u001c9\u001d;\u001e=\u001f? A!C"E#G$I%K&M\'', + 'O(Q)S*U+W,Y-[.]/_0a1c2e3g4i5k6m7o8q9s:u;w\u0083\u0002\u0085?\u0087\u0002\u0089@\u008b\u0002\u008dA\u008f', + '\u0002\u0091B\u0093C\u0095D\u0097E\u0099F\u009bG\u009dH\u009fI\u00a1', + 'J\u00a3K\u00a5L\u00a7M\u00a9N\u00abO\u00adP\u00afQ\u00b1R\u00b3S\u00b5', + 'T\u00b7U\u00b9V\u00bbW\u00bdX\u00bfY\u00c1Z\u00c3\u0002\u00c5\u0002', + '\u00c7\u0002\u00c9\u0002\u00cb\u0002\u00cd\u0002\u00cf\u0002\u00d1\u0002', + '\u00d3\u0002\u00d5\u0002\u00d7\u0002\u00d9\u0002\u00db\u0002\u00dd\u0002', + '\u00df\u0002\u00e1\u0002\u00e3\u0002\u00e5\u0002\u00e7\u0002\u00e9\u0002', + '\u00eb\u0002\u00ed\u0002\u00ef\u0002\u00f1\u0002\u00f3\u0002\u00f5\u0002', + '\u00f7\u0002\u00f9[\u00fb\\\u00fd]\u00ff^\u0101\u0002\u0103_\u0105\u0002', + '\u0107`\u0109\u0002\u010ba\u010d\u0002\u010fb\u0111c\u0113d\u0115\u0002', + '\u0117\u0002\u0119e\u011bf\u011dr\u011f\u0002\u0121\u0002\u0123g\u0125', + '\u0002\u0127\u0002\u0129\u0002\u012b\u0002\u012d\u0002\u012f\u0002\u0131', + 'h\u0133i\u0135j\u0137k\u0139l\u013bm\u013d\u0002\u013fn\u0141\u0002', + '\u0143o\u0145\u0002\u0147p\u0149\u0002\u014bq\u0005\u0002\u0003\u0004', + '#\u0005\u0002\u000b\f\u000e\u000f""\u0004\u0002\f\f\u000f\u000f\u0005', + '\u0002C\\aac|\u0007\u0002002;C\\aac|\u0003\u00022;\u0004\u0002GGgg\u0004', + '\u0002--//\u0004\u0002CCcc\u0004\u0002DDdd\u0004\u0002EEee\u0004\u0002', + 'FFff\u0004\u0002HHhh\u0004\u0002IIii\u0004\u0002JJjj\u0004\u0002KKk', + 'k\u0004\u0002LLll\u0004\u0002MMmm\u0004\u0002NNnn\u0004\u0002OOoo\u0004', + '\u0002PPpp\u0004\u0002QQqq\u0004\u0002RRrr\u0004\u0002SSss\u0004\u0002', + 'TTtt\u0004\u0002UUuu\u0004\u0002VVvv\u0004\u0002WWww\u0004\u0002XXx', + 'x\u0004\u0002YYyy\u0004\u0002ZZzz\u0004\u0002[[{{\u0004\u0002\\\\||', + '\u0006\u0002\u000b\f\u000e\u000f""*+\u0002\u04c4\u0002\u0005\u0003', + '\u0002\u0002\u0002\u0002\u0007\u0003\u0002\u0002\u0002\u0002\t\u0003', + '\u0002\u0002\u0002\u0002\u000b\u0003\u0002\u0002\u0002\u0002\r\u0003', + '\u0002\u0002\u0002\u0002\u000f\u0003\u0002\u0002\u0002\u0002\u0011\u0003', + '\u0002\u0002\u0002\u0002\u0013\u0003\u0002\u0002\u0002\u0002\u0015\u0003', + '\u0002\u0002\u0002\u0002\u0017\u0003\u0002\u0002\u0002\u0002\u0019\u0003', + '\u0002\u0002\u0002\u0002\u001b\u0003\u0002\u0002\u0002\u0002\u001d\u0003', + '\u0002\u0002\u0002\u0002\u001f\u0003\u0002\u0002\u0002\u0002!\u0003', + '\u0002\u0002\u0002\u0002#\u0003\u0002\u0002\u0002\u0002%\u0003\u0002', + "\u0002\u0002\u0002'\u0003\u0002\u0002\u0002\u0002)\u0003\u0002\u0002", + '\u0002\u0002+\u0003\u0002\u0002\u0002\u0002-\u0003\u0002\u0002\u0002', + '\u0002/\u0003\u0002\u0002\u0002\u00021\u0003\u0002\u0002\u0002\u0002', + '3\u0003\u0002\u0002\u0002\u00025\u0003\u0002\u0002\u0002\u00027\u0003', + '\u0002\u0002\u0002\u00029\u0003\u0002\u0002\u0002\u0002;\u0003\u0002', + '\u0002\u0002\u0002=\u0003\u0002\u0002\u0002\u0002?\u0003\u0002\u0002', + '\u0002\u0002A\u0003\u0002\u0002\u0002\u0002C\u0003\u0002\u0002\u0002', + '\u0002E\u0003\u0002\u0002\u0002\u0002G\u0003\u0002\u0002\u0002\u0002', + 'I\u0003\u0002\u0002\u0002\u0002K\u0003\u0002\u0002\u0002\u0002M\u0003', + '\u0002\u0002\u0002\u0002O\u0003\u0002\u0002\u0002\u0002Q\u0003\u0002', + '\u0002\u0002\u0002S\u0003\u0002\u0002\u0002\u0002U\u0003\u0002\u0002', + '\u0002\u0002W\u0003\u0002\u0002\u0002\u0002Y\u0003\u0002\u0002\u0002', + '\u0002[\u0003\u0002\u0002\u0002\u0002]\u0003\u0002\u0002\u0002\u0002', + '_\u0003\u0002\u0002\u0002\u0002a\u0003\u0002\u0002\u0002\u0002c\u0003', + '\u0002\u0002\u0002\u0002e\u0003\u0002\u0002\u0002\u0002g\u0003\u0002', + '\u0002\u0002\u0002i\u0003\u0002\u0002\u0002\u0002k\u0003\u0002\u0002', + '\u0002\u0002m\u0003\u0002\u0002\u0002\u0002o\u0003\u0002\u0002\u0002', + '\u0002q\u0003\u0002\u0002\u0002\u0002s\u0003\u0002\u0002\u0002\u0002', + 'u\u0003\u0002\u0002\u0002\u0002w\u0003\u0002\u0002\u0002\u0002}\u0003', + '\u0002\u0002\u0002\u0002\u0081\u0003\u0002\u0002\u0002\u0002\u0085\u0003', + '\u0002\u0002\u0002\u0002\u0089\u0003\u0002\u0002\u0002\u0002\u008d\u0003', + '\u0002\u0002\u0002\u0002\u0091\u0003\u0002\u0002\u0002\u0002\u0093\u0003', + '\u0002\u0002\u0002\u0002\u0095\u0003\u0002\u0002\u0002\u0002\u0097\u0003', + '\u0002\u0002\u0002\u0002\u0099\u0003\u0002\u0002\u0002\u0002\u009b\u0003', + '\u0002\u0002\u0002\u0002\u009d\u0003\u0002\u0002\u0002\u0002\u009f\u0003', + '\u0002\u0002\u0002\u0002\u00a1\u0003\u0002\u0002\u0002\u0002\u00a3\u0003', + '\u0002\u0002\u0002\u0002\u00a5\u0003\u0002\u0002\u0002\u0002\u00a7\u0003', + '\u0002\u0002\u0002\u0002\u00a9\u0003\u0002\u0002\u0002\u0002\u00ab\u0003', + '\u0002\u0002\u0002\u0002\u00ad\u0003\u0002\u0002\u0002\u0002\u00af\u0003', + '\u0002\u0002\u0002\u0002\u00b1\u0003\u0002\u0002\u0002\u0002\u00b3\u0003', + '\u0002\u0002\u0002\u0002\u00b5\u0003\u0002\u0002\u0002\u0002\u00b7\u0003', + '\u0002\u0002\u0002\u0002\u00b9\u0003\u0002\u0002\u0002\u0002\u00bb\u0003', + '\u0002\u0002\u0002\u0002\u00bd\u0003\u0002\u0002\u0002\u0002\u00bf\u0003', + '\u0002\u0002\u0002\u0002\u00c1\u0003\u0002\u0002\u0002\u0003\u00f7\u0003', + '\u0002\u0002\u0002\u0003\u00f9\u0003\u0002\u0002\u0002\u0003\u00fb\u0003', + '\u0002\u0002\u0002\u0003\u00fd\u0003\u0002\u0002\u0002\u0003\u00ff\u0003', + '\u0002\u0002\u0002\u0003\u0103\u0003\u0002\u0002\u0002\u0003\u0107\u0003', + '\u0002\u0002\u0002\u0003\u010b\u0003\u0002\u0002\u0002\u0003\u010f\u0003', + '\u0002\u0002\u0002\u0003\u0111\u0003\u0002\u0002\u0002\u0003\u0113\u0003', + '\u0002\u0002\u0002\u0004\u0119\u0003\u0002\u0002\u0002\u0004\u011b\u0003', + '\u0002\u0002\u0002\u0004\u011d\u0003\u0002\u0002\u0002\u0004\u011f\u0003', + '\u0002\u0002\u0002\u0004\u0121\u0003\u0002\u0002\u0002\u0004\u0123\u0003', + '\u0002\u0002\u0002\u0004\u0125\u0003\u0002\u0002\u0002\u0004\u0127\u0003', + '\u0002\u0002\u0002\u0004\u0129\u0003\u0002\u0002\u0002\u0004\u012b\u0003', + '\u0002\u0002\u0002\u0004\u012d\u0003\u0002\u0002\u0002\u0004\u012f\u0003', + '\u0002\u0002\u0002\u0004\u0131\u0003\u0002\u0002\u0002\u0004\u0133\u0003', + '\u0002\u0002\u0002\u0004\u0135\u0003\u0002\u0002\u0002\u0004\u0137\u0003', + '\u0002\u0002\u0002\u0004\u0139\u0003\u0002\u0002\u0002\u0004\u013b\u0003', + '\u0002\u0002\u0002\u0004\u013f\u0003\u0002\u0002\u0002\u0004\u0143\u0003', + '\u0002\u0002\u0002\u0004\u0147\u0003\u0002\u0002\u0002\u0004\u014b\u0003', + '\u0002\u0002\u0002\u0005\u014e\u0003\u0002\u0002\u0002\u0007\u0154\u0003', + '\u0002\u0002\u0002\t\u015d\u0003\u0002\u0002\u0002\u000b\u0164\u0003', + '\u0002\u0002\u0002\r\u016a\u0003\u0002\u0002\u0002\u000f\u016e\u0003', + '\u0002\u0002\u0002\u0011\u0172\u0003\u0002\u0002\u0002\u0013\u0177\u0003', + '\u0002\u0002\u0002\u0015\u017f\u0003\u0002\u0002\u0002\u0017\u0188\u0003', + '\u0002\u0002\u0002\u0019\u0190\u0003\u0002\u0002\u0002\u001b\u0198\u0003', + '\u0002\u0002\u0002\u001d\u019f\u0003\u0002\u0002\u0002\u001f\u01a6\u0003', + '\u0002\u0002\u0002!\u01ab\u0003\u0002\u0002\u0002#\u01b1\u0003\u0002', + "\u0002\u0002%\u01b5\u0003\u0002\u0002\u0002'\u01ba\u0003\u0002\u0002", + '\u0002)\u01bf\u0003\u0002\u0002\u0002+\u01c5\u0003\u0002\u0002\u0002', + '-\u01ca\u0003\u0002\u0002\u0002/\u01d0\u0003\u0002\u0002\u00021\u01d3', + '\u0003\u0002\u0002\u00023\u01d9\u0003\u0002\u0002\u00025\u01dc\u0003', + '\u0002\u0002\u00027\u01df\u0003\u0002\u0002\u00029\u01e3\u0003\u0002', + '\u0002\u0002;\u01e6\u0003\u0002\u0002\u0002=\u01ea\u0003\u0002\u0002', + '\u0002?\u01f1\u0003\u0002\u0002\u0002A\u01f9\u0003\u0002\u0002\u0002', + 'C\u01fc\u0003\u0002\u0002\u0002E\u0203\u0003\u0002\u0002\u0002G\u0211', + '\u0003\u0002\u0002\u0002I\u0213\u0003\u0002\u0002\u0002K\u0219\u0003', + '\u0002\u0002\u0002M\u0222\u0003\u0002\u0002\u0002O\u0224\u0003\u0002', + '\u0002\u0002Q\u022a\u0003\u0002\u0002\u0002S\u0233\u0003\u0002\u0002', + '\u0002U\u0235\u0003\u0002\u0002\u0002W\u023a\u0003\u0002\u0002\u0002', + 'Y\u0241\u0003\u0002\u0002\u0002[\u0243\u0003\u0002\u0002\u0002]\u0249', + '\u0003\u0002\u0002\u0002_\u024b\u0003\u0002\u0002\u0002a\u0252\u0003', + '\u0002\u0002\u0002c\u0257\u0003\u0002\u0002\u0002e\u025d\u0003\u0002', + '\u0002\u0002g\u0265\u0003\u0002\u0002\u0002i\u0267\u0003\u0002\u0002', + '\u0002k\u026d\u0003\u0002\u0002\u0002m\u0277\u0003\u0002\u0002\u0002', + 'o\u0279\u0003\u0002\u0002\u0002q\u027e\u0003\u0002\u0002\u0002s\u0285', + '\u0003\u0002\u0002\u0002u\u028c\u0003\u0002\u0002\u0002w\u0299\u0003', + '\u0002\u0002\u0002y\u029c\u0003\u0002\u0002\u0002{\u02a0\u0003\u0002', + '\u0002\u0002}\u02a6\u0003\u0002\u0002\u0002\u007f\u02b0\u0003\u0002', + '\u0002\u0002\u0081\u02b3\u0003\u0002\u0002\u0002\u0083\u02bd\u0003\u0002', + '\u0002\u0002\u0085\u02c0\u0003\u0002\u0002\u0002\u0087\u02cf\u0003\u0002', + '\u0002\u0002\u0089\u02d4\u0003\u0002\u0002\u0002\u008b\u02e3\u0003\u0002', + '\u0002\u0002\u008d\u02e8\u0003\u0002\u0002\u0002\u008f\u02f2\u0003\u0002', + '\u0002\u0002\u0091\u02f5\u0003\u0002\u0002\u0002\u0093\u02fa\u0003\u0002', + '\u0002\u0002\u0095\u02ff\u0003\u0002\u0002\u0002\u0097\u0301\u0003\u0002', + '\u0002\u0002\u0099\u0303\u0003\u0002\u0002\u0002\u009b\u0305\u0003\u0002', + '\u0002\u0002\u009d\u030c\u0003\u0002\u0002\u0002\u009f\u030e\u0003\u0002', + '\u0002\u0002\u00a1\u0311\u0003\u0002\u0002\u0002\u00a3\u0313\u0003\u0002', + '\u0002\u0002\u00a5\u0315\u0003\u0002\u0002\u0002\u00a7\u0317\u0003\u0002', + '\u0002\u0002\u00a9\u0319\u0003\u0002\u0002\u0002\u00ab\u031b\u0003\u0002', + '\u0002\u0002\u00ad\u031d\u0003\u0002\u0002\u0002\u00af\u031f\u0003\u0002', + '\u0002\u0002\u00b1\u0321\u0003\u0002\u0002\u0002\u00b3\u0323\u0003\u0002', + '\u0002\u0002\u00b5\u0325\u0003\u0002\u0002\u0002\u00b7\u0327\u0003\u0002', + '\u0002\u0002\u00b9\u032a\u0003\u0002\u0002\u0002\u00bb\u032d\u0003\u0002', + '\u0002\u0002\u00bd\u032f\u0003\u0002\u0002\u0002\u00bf\u0332\u0003\u0002', + '\u0002\u0002\u00c1\u0334\u0003\u0002\u0002\u0002\u00c3\u0336\u0003\u0002', + '\u0002\u0002\u00c5\u0338\u0003\u0002\u0002\u0002\u00c7\u033a\u0003\u0002', + '\u0002\u0002\u00c9\u033c\u0003\u0002\u0002\u0002\u00cb\u033e\u0003\u0002', + '\u0002\u0002\u00cd\u0340\u0003\u0002\u0002\u0002\u00cf\u0342\u0003\u0002', + '\u0002\u0002\u00d1\u0344\u0003\u0002\u0002\u0002\u00d3\u0346\u0003\u0002', + '\u0002\u0002\u00d5\u0348\u0003\u0002\u0002\u0002\u00d7\u034a\u0003\u0002', + '\u0002\u0002\u00d9\u034c\u0003\u0002\u0002\u0002\u00db\u034e\u0003\u0002', + '\u0002\u0002\u00dd\u0350\u0003\u0002\u0002\u0002\u00df\u0352\u0003\u0002', + '\u0002\u0002\u00e1\u0354\u0003\u0002\u0002\u0002\u00e3\u0356\u0003\u0002', + '\u0002\u0002\u00e5\u0358\u0003\u0002\u0002\u0002\u00e7\u035a\u0003\u0002', + '\u0002\u0002\u00e9\u035c\u0003\u0002\u0002\u0002\u00eb\u035e\u0003\u0002', + '\u0002\u0002\u00ed\u0360\u0003\u0002\u0002\u0002\u00ef\u0362\u0003\u0002', + '\u0002\u0002\u00f1\u0364\u0003\u0002\u0002\u0002\u00f3\u0366\u0003\u0002', + '\u0002\u0002\u00f5\u0368\u0003\u0002\u0002\u0002\u00f7\u036a\u0003\u0002', + '\u0002\u0002\u00f9\u0370\u0003\u0002\u0002\u0002\u00fb\u0376\u0003\u0002', + '\u0002\u0002\u00fd\u037f\u0003\u0002\u0002\u0002\u00ff\u038b\u0003\u0002', + '\u0002\u0002\u0101\u0397\u0003\u0002\u0002\u0002\u0103\u039a\u0003\u0002', + '\u0002\u0002\u0105\u03a6\u0003\u0002\u0002\u0002\u0107\u03a9\u0003\u0002', + '\u0002\u0002\u0109\u03ba\u0003\u0002\u0002\u0002\u010b\u03bf\u0003\u0002', + '\u0002\u0002\u010d\u03d0\u0003\u0002\u0002\u0002\u010f\u03d5\u0003\u0002', + '\u0002\u0002\u0111\u03dc\u0003\u0002\u0002\u0002\u0113\u03de\u0003\u0002', + '\u0002\u0002\u0115\u03e8\u0003\u0002\u0002\u0002\u0117\u03eb\u0003\u0002', + '\u0002\u0002\u0119\u03ef\u0003\u0002\u0002\u0002\u011b\u03f5\u0003\u0002', + '\u0002\u0002\u011d\u03fe\u0003\u0002\u0002\u0002\u011f\u0403\u0003\u0002', + '\u0002\u0002\u0121\u040e\u0003\u0002\u0002\u0002\u0123\u0418\u0003\u0002', + '\u0002\u0002\u0125\u041f\u0003\u0002\u0002\u0002\u0127\u042a\u0003\u0002', + '\u0002\u0002\u0129\u0435\u0003\u0002\u0002\u0002\u012b\u043f\u0003\u0002', + '\u0002\u0002\u012d\u0449\u0003\u0002\u0002\u0002\u012f\u0451\u0003\u0002', + '\u0002\u0002\u0131\u045a\u0003\u0002\u0002\u0002\u0133\u045c\u0003\u0002', + '\u0002\u0002\u0135\u045e\u0003\u0002\u0002\u0002\u0137\u0462\u0003\u0002', + '\u0002\u0002\u0139\u0465\u0003\u0002\u0002\u0002\u013b\u0469\u0003\u0002', + '\u0002\u0002\u013d\u0473\u0003\u0002\u0002\u0002\u013f\u0476\u0003\u0002', + '\u0002\u0002\u0141\u0480\u0003\u0002\u0002\u0002\u0143\u0483\u0003\u0002', + '\u0002\u0002\u0145\u0492\u0003\u0002\u0002\u0002\u0147\u0497\u0003\u0002', + '\u0002\u0002\u0149\u04a6\u0003\u0002\u0002\u0002\u014b\u04ac\u0003\u0002', + '\u0002\u0002\u014d\u014f\t\u0002\u0002\u0002\u014e\u014d\u0003\u0002', + '\u0002\u0002\u014f\u0150\u0003\u0002\u0002\u0002\u0150\u014e\u0003\u0002', + '\u0002\u0002\u0150\u0151\u0003\u0002\u0002\u0002\u0151\u0152\u0003\u0002', + '\u0002\u0002\u0152\u0153\b\u0002\u0002\u0002\u0153\u0006\u0003\u0002', + '\u0002\u0002\u0154\u0158\u0007%\u0002\u0002\u0155\u0157\n\u0003\u0002', + '\u0002\u0156\u0155\u0003\u0002\u0002\u0002\u0157\u015a\u0003\u0002\u0002', + '\u0002\u0158\u0156\u0003\u0002\u0002\u0002\u0158\u0159\u0003\u0002\u0002', + '\u0002\u0159\u015b\u0003\u0002\u0002\u0002\u015a\u0158\u0003\u0002\u0002', + '\u0002\u015b\u015c\b\u0003\u0003\u0002\u015c\b\u0003\u0002\u0002\u0002', + '\u015d\u015e\u0005\u00e7s\u0002\u015e\u015f\u0005\u00dfo\u0002\u015f', + '\u0160\u0005\u00ebu\u0002\u0160\u0161\u0005\u00e5r\u0002\u0161\u0162', + '\u0005\u00c7c\u0002\u0162\u0163\u0005\u00cbe\u0002\u0163\n\u0003\u0002', + '\u0002\u0002\u0164\u0165\u0005\u00e7s\u0002\u0165\u0166\u0005\u00e9', + 't\u0002\u0166\u0167\u0005\u00c3a\u0002\u0167\u0168\u0005\u00e5r\u0002', + '\u0168\u0169\u0005\u00e9t\u0002\u0169\f\u0003\u0002\u0002\u0002\u016a', + '\u016b\u0005\u00cbe\u0002\u016b\u016c\u0005\u00ddn\u0002\u016c\u016d', + '\u0005\u00c9d\u0002\u016d\u000e\u0003\u0002\u0002\u0002\u016e\u016f', + '\u0005\u00ddn\u0002\u016f\u0170\u0005\u00dfo\u0002\u0170\u0171\u0005', + '\u00efw\u0002\u0171\u0010\u0003\u0002\u0002\u0002\u0172\u0173\u0005', + '\u00d9l\u0002\u0173\u0174\u0005\u00d3i\u0002\u0174\u0175\u0005\u00ed', + 'v\u0002\u0175\u0176\u0005\u00cbe\u0002\u0176\u0012\u0003\u0002\u0002', + '\u0002\u0177\u0178\u0005\u00e1p\u0002\u0178\u0179\u0005\u00c3a\u0002', + '\u0179\u017a\u0005\u00e5r\u0002\u017a\u017b\u0005\u00e7s\u0002\u017b', + '\u017c\u0005\u00cbe\u0002\u017c\u017d\u0003\u0002\u0002\u0002\u017d', + '\u017e\b\t\u0004\u0002\u017e\u0014\u0003\u0002\u0002\u0002\u017f\u0180', + '\u0005\u00e7s\u0002\u0180\u0181\u0005\u00cbe\u0002\u0181\u0182\u0005', + '\u00c3a\u0002\u0182\u0183\u0005\u00e5r\u0002\u0183\u0184\u0005\u00c7', + 'c\u0002\u0184\u0185\u0005\u00d1h\u0002\u0185\u0186\u0003\u0002\u0002', + '\u0002\u0186\u0187\b\n\u0005\u0002\u0187\u0016\u0003\u0002\u0002\u0002', + '\u0188\u0189\u0005\u00cdf\u0002\u0189\u018a\u0005\u00d3i\u0002\u018a', + '\u018b\u0005\u00cbe\u0002\u018b\u018c\u0005\u00d9l\u0002\u018c\u018e', + '\u0005\u00c9d\u0002\u018d\u018f\u0005\u00e7s\u0002\u018e\u018d\u0003', + '\u0002\u0002\u0002\u018e\u018f\u0003\u0002\u0002\u0002\u018f\u0018\u0003', + '\u0002\u0002\u0002\u0190\u0191\u0005\u00c9d\u0002\u0191\u0192\u0005', + '\u00d3i\u0002\u0192\u0193\u0005\u00e7s\u0002\u0193\u0194\u0005\u00e1', + 'p\u0002\u0194\u0195\u0005\u00d9l\u0002\u0195\u0196\u0005\u00c3a\u0002', + '\u0196\u0197\u0005\u00f3y\u0002\u0197\u001a\u0003\u0002\u0002\u0002', + '\u0198\u0199\u0005\u00cdf\u0002\u0199\u019a\u0005\u00d3i\u0002\u019a', + '\u019b\u0005\u00d9l\u0002\u019b\u019c\u0005\u00e9t\u0002\u019c\u019d', + '\u0005\u00cbe\u0002\u019d\u019e\u0005\u00e5r\u0002\u019e\u001c\u0003', + '\u0002\u0002\u0002\u019f\u01a0\u0005\u00e7s\u0002\u01a0\u01a1\u0005', + '\u00e9t\u0002\u01a1\u01a2\u0005\u00c3a\u0002\u01a2\u01a4\u0005\u00e9', + 't\u0002\u01a3\u01a5\u0005\u00e7s\u0002\u01a4\u01a3\u0003\u0002\u0002', + '\u0002\u01a4\u01a5\u0003\u0002\u0002\u0002\u01a5\u001e\u0003\u0002\u0002', + '\u0002\u01a6\u01a7\u0005\u00e7s\u0002\u01a7\u01a8\u0005\u00dfo\u0002', + '\u01a8\u01a9\u0005\u00e5r\u0002\u01a9\u01aa\u0005\u00e9t\u0002\u01aa', + ' \u0003\u0002\u0002\u0002\u01ab\u01ac\u0005\u00dfo\u0002\u01ac\u01ad', + '\u0005\u00e5r\u0002\u01ad\u01ae\u0005\u00c9d\u0002\u01ae\u01af\u0005', + '\u00cbe\u0002\u01af\u01b0\u0005\u00e5r\u0002\u01b0"\u0003\u0002\u0002', + '\u0002\u01b1\u01b2\u0005\u00c3a\u0002\u01b2\u01b3\u0005\u00e7s\u0002', + '\u01b3\u01b4\u0005\u00c7c\u0002\u01b4$\u0003\u0002\u0002\u0002\u01b5', + '\u01b6\u0005\u00c9d\u0002\u01b6\u01b7\u0005\u00cbe\u0002\u01b7\u01b8', + '\u0005\u00e7s\u0002\u01b8\u01b9\u0005\u00c7c\u0002\u01b9&\u0003\u0002', + '\u0002\u0002\u01ba\u01bb\u0005\u00d1h\u0002\u01bb\u01bc\u0005\u00cb', + 'e\u0002\u01bc\u01bd\u0005\u00c3a\u0002\u01bd\u01be\u0005\u00c9d\u0002', + '\u01be(\u0003\u0002\u0002\u0002\u01bf\u01c0\u0005\u00d9l\u0002\u01c0', + '\u01c1\u0005\u00d3i\u0002\u01c1\u01c2\u0005\u00dbm\u0002\u01c2\u01c3', + '\u0005\u00d3i\u0002\u01c3\u01c4\u0005\u00e9t\u0002\u01c4*\u0003\u0002', + '\u0002\u0002\u01c5\u01c6\u0005\u00e9t\u0002\u01c6\u01c7\u0005\u00c3', + 'a\u0002\u01c7\u01c8\u0005\u00d3i\u0002\u01c8\u01c9\u0005\u00d9l\u0002', + '\u01c9,\u0003\u0002\u0002\u0002\u01ca\u01cb\u0005\u00e5r\u0002\u01cb', + '\u01cc\u0005\u00cbe\u0002\u01cc\u01cd\u0005\u00cfg\u0002\u01cd\u01ce', + '\u0005\u00cbe\u0002\u01ce\u01cf\u0005\u00f1x\u0002\u01cf.\u0003\u0002', + '\u0002\u0002\u01d0\u01d1\u0005\u00d3i\u0002\u01d1\u01d2\u0005\u00dd', + 'n\u0002\u01d20\u0003\u0002\u0002\u0002\u01d3\u01d4\u0005\u00cfg\u0002', + '\u01d4\u01d5\u0005\u00e5r\u0002\u01d5\u01d6\u0005\u00dfo\u0002\u01d6', + '\u01d7\u0005\u00ebu\u0002\u01d7\u01d8\u0005\u00e1p\u0002\u01d82\u0003', + '\u0002\u0002\u0002\u01d9\u01da\u0005\u00c5b\u0002\u01da\u01db\u0005', + '\u00f3y\u0002\u01db4\u0003\u0002\u0002\u0002\u01dc\u01dd\u0005\u00c3', + 'a\u0002\u01dd\u01de\u0005\u00e7s\u0002\u01de6\u0003\u0002\u0002\u0002', + '\u01df\u01e0\u0005\u00c3a\u0002\u01e0\u01e1\u0005\u00ddn\u0002\u01e1', + '\u01e2\u0005\u00c9d\u0002\u01e28\u0003\u0002\u0002\u0002\u01e3\u01e4', + '\u0005\u00dfo\u0002\u01e4\u01e5\u0005\u00e5r\u0002\u01e5:\u0003\u0002', + '\u0002\u0002\u01e6\u01e7\u0005\u00ddn\u0002\u01e7\u01e8\u0005\u00df', + 'o\u0002\u01e8\u01e9\u0005\u00e9t\u0002\u01e9<\u0003\u0002\u0002\u0002', + '\u01ea\u01eb\u0005\u00d9l\u0002\u01eb\u01ec\u0005\u00d3i\u0002\u01ec', + '\u01ed\u0005\u00d7k\u0002\u01ed\u01ee\u0005\u00cbe\u0002\u01ee\u01ef', + '\u0003\u0002\u0002\u0002\u01ef\u01f0\b\u001e\u0004\u0002\u01f0>\u0003', + '\u0002\u0002\u0002\u01f1\u01f2\u0005\u00dbm\u0002\u01f2\u01f3\u0005', + '\u00c3a\u0002\u01f3\u01f4\u0005\u00e9t\u0002\u01f4\u01f5\u0005\u00c7', + 'c\u0002\u01f5\u01f6\u0005\u00d1h\u0002\u01f6\u01f7\u0005\u00cbe\u0002', + '\u01f7\u01f8\u0005\u00e7s\u0002\u01f8@\u0003\u0002\u0002\u0002\u01f9', + '\u01fa\u0005\u00dbm\u0002\u01fa\u01fb\u0005\u00e7s\u0002\u01fbB\u0003', + '\u0002\u0002\u0002\u01fc\u01fd\u0005\u00dbm\u0002\u01fd\u01fe\u0005', + '\u00e7s\u0002\u01fe\u01ff\u0005\u00cbe\u0002\u01ff\u0201\u0005\u00c7', + 'c\u0002\u0200\u0202\u0005\u00e7s\u0002\u0201\u0200\u0003\u0002\u0002', + '\u0002\u0201\u0202\u0003\u0002\u0002\u0002\u0202D\u0003\u0002\u0002', + '\u0002\u0203\u0204\u0005\u00dbm\u0002\u0204\u0205\u0005\u00d3i\u0002', + '\u0205\u0206\u0005\u00d9l\u0002\u0206\u0207\u0005\u00d9l\u0002\u0207', + '\u0208\u0005\u00d3i\u0002\u0208\u0209\u0005\u00e7s\u0002\u0209\u020a', + '\u0005\u00cbe\u0002\u020a\u020b\u0005\u00c7c\u0002\u020b\u020c\u0005', + '\u00dfo\u0002\u020c\u020d\u0005\u00ddn\u0002\u020d\u020f\u0005\u00c9', + 'd\u0002\u020e\u0210\u0005\u00e7s\u0002\u020f\u020e\u0003\u0002\u0002', + '\u0002\u020f\u0210\u0003\u0002\u0002\u0002\u0210F\u0003\u0002\u0002', + '\u0002\u0211\u0212\u0005\u00e7s\u0002\u0212H\u0003\u0002\u0002\u0002', + '\u0213\u0214\u0005\u00e7s\u0002\u0214\u0215\u0005\u00cbe\u0002\u0215', + '\u0217\u0005\u00c7c\u0002\u0216\u0218\u0005\u00e7s\u0002\u0217\u0216', + '\u0003\u0002\u0002\u0002\u0217\u0218\u0003\u0002\u0002\u0002\u0218J', + '\u0003\u0002\u0002\u0002\u0219\u021a\u0005\u00e7s\u0002\u021a\u021b', + '\u0005\u00cbe\u0002\u021b\u021c\u0005\u00c7c\u0002\u021c\u021d\u0005', + '\u00dfo\u0002\u021d\u021e\u0005\u00ddn\u0002\u021e\u0220\u0005\u00c9', + 'd\u0002\u021f\u0221\u0005\u00e7s\u0002\u0220\u021f\u0003\u0002\u0002', + '\u0002\u0220\u0221\u0003\u0002\u0002\u0002\u0221L\u0003\u0002\u0002', + '\u0002\u0222\u0223\u0005\u00dbm\u0002\u0223N\u0003\u0002\u0002\u0002', + '\u0224\u0225\u0005\u00dbm\u0002\u0225\u0226\u0005\u00d3i\u0002\u0226', + '\u0228\u0005\u00ddn\u0002\u0227\u0229\u0005\u00e7s\u0002\u0228\u0227', + '\u0003\u0002\u0002\u0002\u0228\u0229\u0003\u0002\u0002\u0002\u0229P', + '\u0003\u0002\u0002\u0002\u022a\u022b\u0005\u00dbm\u0002\u022b\u022c', + '\u0005\u00d3i\u0002\u022c\u022d\u0005\u00ddn\u0002\u022d\u022e\u0005', + '\u00ebu\u0002\u022e\u022f\u0005\u00e9t\u0002\u022f\u0231\u0005\u00cb', + 'e\u0002\u0230\u0232\u0005\u00e7s\u0002\u0231\u0230\u0003\u0002\u0002', + '\u0002\u0231\u0232\u0003\u0002\u0002\u0002\u0232R\u0003\u0002\u0002', + '\u0002\u0233\u0234\u0005\u00d1h\u0002\u0234T\u0003\u0002\u0002\u0002', + '\u0235\u0236\u0005\u00d1h\u0002\u0236\u0238\u0005\u00e5r\u0002\u0237', + '\u0239\u0005\u00e7s\u0002\u0238\u0237\u0003\u0002\u0002\u0002\u0238', + '\u0239\u0003\u0002\u0002\u0002\u0239V\u0003\u0002\u0002\u0002\u023a', + '\u023b\u0005\u00d1h\u0002\u023b\u023c\u0005\u00dfo\u0002\u023c\u023d', + '\u0005\u00ebu\u0002\u023d\u023f\u0005\u00e5r\u0002\u023e\u0240\u0005', + '\u00e7s\u0002\u023f\u023e\u0003\u0002\u0002\u0002\u023f\u0240\u0003', + '\u0002\u0002\u0002\u0240X\u0003\u0002\u0002\u0002\u0241\u0242\u0005', + '\u00c9d\u0002\u0242Z\u0003\u0002\u0002\u0002\u0243\u0244\u0005\u00c9', + 'd\u0002\u0244\u0245\u0005\u00c3a\u0002\u0245\u0247\u0005\u00f3y\u0002', + '\u0246\u0248\u0005\u00e7s\u0002\u0247\u0246\u0003\u0002\u0002\u0002', + '\u0247\u0248\u0003\u0002\u0002\u0002\u0248\\\u0003\u0002\u0002\u0002', + '\u0249\u024a\u0005\u00efw\u0002\u024a^\u0003\u0002\u0002\u0002\u024b', + '\u024c\u0005\u00efw\u0002\u024c\u024d\u0005\u00cbe\u0002\u024d\u024e', + '\u0005\u00cbe\u0002\u024e\u0250\u0005\u00d7k\u0002\u024f\u0251\u0005', + '\u00e7s\u0002\u0250\u024f\u0003\u0002\u0002\u0002\u0250\u0251\u0003', + '\u0002\u0002\u0002\u0251`\u0003\u0002\u0002\u0002\u0252\u0253\u0005', + '\u00dbm\u0002\u0253\u0255\u0005\u00dfo\u0002\u0254\u0256\u0005\u00e7', + 's\u0002\u0255\u0254\u0003\u0002\u0002\u0002\u0255\u0256\u0003\u0002', + '\u0002\u0002\u0256b\u0003\u0002\u0002\u0002\u0257\u0258\u0005\u00db', + 'm\u0002\u0258\u0259\u0005\u00dfo\u0002\u0259\u025b\u0005\u00ddn\u0002', + '\u025a\u025c\u0005\u00e7s\u0002\u025b\u025a\u0003\u0002\u0002\u0002', + '\u025b\u025c\u0003\u0002\u0002\u0002\u025cd\u0003\u0002\u0002\u0002', + '\u025d\u025e\u0005\u00dbm\u0002\u025e\u025f\u0005\u00dfo\u0002\u025f', + '\u0260\u0005\u00ddn\u0002\u0260\u0261\u0005\u00e9t\u0002\u0261\u0263', + '\u0005\u00d1h\u0002\u0262\u0264\u0005\u00e7s\u0002\u0263\u0262\u0003', + '\u0002\u0002\u0002\u0263\u0264\u0003\u0002\u0002\u0002\u0264f\u0003', + '\u0002\u0002\u0002\u0265\u0266\u0005\u00e3q\u0002\u0266h\u0003\u0002', + '\u0002\u0002\u0267\u0268\u0005\u00e3q\u0002\u0268\u0269\u0005\u00e9', + 't\u0002\u0269\u026b\u0005\u00e5r\u0002\u026a\u026c\u0005\u00e7s\u0002', + '\u026b\u026a\u0003\u0002\u0002\u0002\u026b\u026c\u0003\u0002\u0002\u0002', + '\u026cj\u0003\u0002\u0002\u0002\u026d\u026e\u0005\u00e3q\u0002\u026e', + '\u026f\u0005\u00ebu\u0002\u026f\u0270\u0005\u00c3a\u0002\u0270\u0271', + '\u0005\u00e5r\u0002\u0271\u0272\u0005\u00e9t\u0002\u0272\u0273\u0005', + '\u00cbe\u0002\u0273\u0275\u0005\u00e5r\u0002\u0274\u0276\u0005\u00e7', + 's\u0002\u0275\u0274\u0003\u0002\u0002\u0002\u0275\u0276\u0003\u0002', + '\u0002\u0002\u0276l\u0003\u0002\u0002\u0002\u0277\u0278\u0005\u00f3', + 'y\u0002\u0278n\u0003\u0002\u0002\u0002\u0279\u027a\u0005\u00f3y\u0002', + '\u027a\u027c\u0005\u00e5r\u0002\u027b\u027d\u0005\u00e7s\u0002\u027c', + '\u027b\u0003\u0002\u0002\u0002\u027c\u027d\u0003\u0002\u0002\u0002\u027d', + 'p\u0003\u0002\u0002\u0002\u027e\u027f\u0005\u00f3y\u0002\u027f\u0280', + '\u0005\u00cbe\u0002\u0280\u0281\u0005\u00c3a\u0002\u0281\u0283\u0005', + '\u00e5r\u0002\u0282\u0284\u0005\u00e7s\u0002\u0283\u0282\u0003\u0002', + '\u0002\u0002\u0283\u0284\u0003\u0002\u0002\u0002\u0284r\u0003\u0002', + '\u0002\u0002\u0285\u0289\t\u0004\u0002\u0002\u0286\u0288\t\u0005\u0002', + '\u0002\u0287\u0286\u0003\u0002\u0002\u0002\u0288\u028b\u0003\u0002\u0002', + '\u0002\u0289\u0287\u0003\u0002\u0002\u0002\u0289\u028a\u0003\u0002\u0002', + '\u0002\u028at\u0003\u0002\u0002\u0002\u028b\u0289\u0003\u0002\u0002', + '\u0002\u028c\u028d\u0005y<\u0002\u028dv\u0003\u0002\u0002\u0002\u028e', + '\u028f\u0005y<\u0002\u028f\u0291\u00070\u0002\u0002\u0290\u0292\t\u0006', + '\u0002\u0002\u0291\u0290\u0003\u0002\u0002\u0002\u0292\u0293\u0003\u0002', + '\u0002\u0002\u0293\u0291\u0003\u0002\u0002\u0002\u0293\u0294\u0003\u0002', + '\u0002\u0002\u0294\u0296\u0003\u0002\u0002\u0002\u0295\u0297\u0005{', + '=\u0002\u0296\u0295\u0003\u0002\u0002\u0002\u0296\u0297\u0003\u0002', + '\u0002\u0002\u0297\u029a\u0003\u0002\u0002\u0002\u0298\u029a\u0005y', + '<\u0002\u0299\u028e\u0003\u0002\u0002\u0002\u0299\u0298\u0003\u0002', + '\u0002\u0002\u029ax\u0003\u0002\u0002\u0002\u029b\u029d\t\u0006\u0002', + '\u0002\u029c\u029b\u0003\u0002\u0002\u0002\u029d\u029e\u0003\u0002\u0002', + '\u0002\u029e\u029c\u0003\u0002\u0002\u0002\u029e\u029f\u0003\u0002\u0002', + '\u0002\u029fz\u0003\u0002\u0002\u0002\u02a0\u02a2\t\u0007\u0002\u0002', + '\u02a1\u02a3\t\b\u0002\u0002\u02a2\u02a1\u0003\u0002\u0002\u0002\u02a2', + '\u02a3\u0003\u0002\u0002\u0002\u02a3\u02a4\u0003\u0002\u0002\u0002\u02a4', + '\u02a5\u0005y<\u0002\u02a5|\u0003\u0002\u0002\u0002\u02a6\u02ab\u0007', + '$\u0002\u0002\u02a7\u02aa\u0005\u007f?\u0002\u02a8\u02aa\u000b\u0002', + '\u0002\u0002\u02a9\u02a7\u0003\u0002\u0002\u0002\u02a9\u02a8\u0003\u0002', + '\u0002\u0002\u02aa\u02ad\u0003\u0002\u0002\u0002\u02ab\u02ac\u0003\u0002', + '\u0002\u0002\u02ab\u02a9\u0003\u0002\u0002\u0002\u02ac\u02ae\u0003\u0002', + '\u0002\u0002\u02ad\u02ab\u0003\u0002\u0002\u0002\u02ae\u02af\u0007$', + '\u0002\u0002\u02af~\u0003\u0002\u0002\u0002\u02b0\u02b1\u0007^\u0002', + '\u0002\u02b1\u02b2\u0007$\u0002\u0002\u02b2\u0080\u0003\u0002\u0002', + '\u0002\u02b3\u02b8\u0007)\u0002\u0002\u02b4\u02b7\u0005\u0083A\u0002', + '\u02b5\u02b7\u000b\u0002\u0002\u0002\u02b6\u02b4\u0003\u0002\u0002\u0002', + '\u02b6\u02b5\u0003\u0002\u0002\u0002\u02b7\u02ba\u0003\u0002\u0002\u0002', + '\u02b8\u02b9\u0003\u0002\u0002\u0002\u02b8\u02b6\u0003\u0002\u0002\u0002', + '\u02b9\u02bb\u0003\u0002\u0002\u0002\u02ba\u02b8\u0003\u0002\u0002\u0002', + '\u02bb\u02bc\u0007)\u0002\u0002\u02bc\u0082\u0003\u0002\u0002\u0002', + '\u02bd\u02be\u0007^\u0002\u0002\u02be\u02bf\u0007)\u0002\u0002\u02bf', + '\u0084\u0003\u0002\u0002\u0002\u02c0\u02c1\u0007\u00e4\u0002\u0002\u02c1', + '\u02c2\u0007\u20ae\u0002\u0002\u02c2\u02c3\u0007\u0155\u0002\u0002\u02c3', + '\u02c8\u0003\u0002\u0002\u0002\u02c4\u02c7\u0005\u0087C\u0002\u02c5', + '\u02c7\u000b\u0002\u0002\u0002\u02c6\u02c4\u0003\u0002\u0002\u0002\u02c6', + '\u02c5\u0003\u0002\u0002\u0002\u02c7\u02ca\u0003\u0002\u0002\u0002\u02c8', + '\u02c9\u0003\u0002\u0002\u0002\u02c8\u02c6\u0003\u0002\u0002\u0002\u02c9', + '\u02cb\u0003\u0002\u0002\u0002\u02ca\u02c8\u0003\u0002\u0002\u0002\u02cb', + '\u02cc\u0007\u00e4\u0002\u0002\u02cc\u02cd\u0007\u20ae\u0002\u0002\u02cd', + '\u02ce\u0007\u009f\u0002\u0002\u02ce\u0086\u0003\u0002\u0002\u0002\u02cf', + '\u02d0\u0007^\u0002\u0002\u02d0\u02d1\u0007\u00e4\u0002\u0002\u02d1', + '\u02d2\u0007\u20ae\u0002\u0002\u02d2\u02d3\u0007\u009f\u0002\u0002\u02d3', + '\u0088\u0003\u0002\u0002\u0002\u02d4\u02d5\u0007\u00e4\u0002\u0002\u02d5', + '\u02d6\u0007\u20ae\u0002\u0002\u02d6\u02d7\u0007\u02de\u0002\u0002\u02d7', + '\u02dc\u0003\u0002\u0002\u0002\u02d8\u02db\u0005\u008bE\u0002\u02d9', + '\u02db\u000b\u0002\u0002\u0002\u02da\u02d8\u0003\u0002\u0002\u0002\u02da', + '\u02d9\u0003\u0002\u0002\u0002\u02db\u02de\u0003\u0002\u0002\u0002\u02dc', + '\u02dd\u0003\u0002\u0002\u0002\u02dc\u02da\u0003\u0002\u0002\u0002\u02dd', + '\u02df\u0003\u0002\u0002\u0002\u02de\u02dc\u0003\u0002\u0002\u0002\u02df', + '\u02e0\u0007\u00e4\u0002\u0002\u02e0\u02e1\u0007\u20ae\u0002\u0002\u02e1', + '\u02e2\u0007\u2124\u0002\u0002\u02e2\u008a\u0003\u0002\u0002\u0002\u02e3', + '\u02e4\u0007^\u0002\u0002\u02e4\u02e5\u0007\u00e4\u0002\u0002\u02e5', + '\u02e6\u0007\u20ae\u0002\u0002\u02e6\u02e7\u0007\u2124\u0002\u0002\u02e7', + '\u008c\u0003\u0002\u0002\u0002\u02e8\u02ed\u0007b\u0002\u0002\u02e9', + '\u02ec\u0005\u008fG\u0002\u02ea\u02ec\u000b\u0002\u0002\u0002\u02eb', + '\u02e9\u0003\u0002\u0002\u0002\u02eb\u02ea\u0003\u0002\u0002\u0002\u02ec', + '\u02ef\u0003\u0002\u0002\u0002\u02ed\u02ee\u0003\u0002\u0002\u0002\u02ed', + '\u02eb\u0003\u0002\u0002\u0002\u02ee\u02f0\u0003\u0002\u0002\u0002\u02ef', + '\u02ed\u0003\u0002\u0002\u0002\u02f0\u02f1\u0007b\u0002\u0002\u02f1', + '\u008e\u0003\u0002\u0002\u0002\u02f2\u02f3\u0007^\u0002\u0002\u02f3', + '\u02f4\u0007b\u0002\u0002\u02f4\u0090\u0003\u0002\u0002\u0002\u02f5', + '\u02f6\u0007?\u0002\u0002\u02f6\u02f7\u0007\u0080\u0002\u0002\u02f7', + '\u02f8\u0003\u0002\u0002\u0002\u02f8\u02f9\bH\u0004\u0002\u02f9\u0092', + '\u0003\u0002\u0002\u0002\u02fa\u02fb\u0007\u0080\u0002\u0002\u02fb\u02fc', + '\u0007?\u0002\u0002\u02fc\u02fd\u0003\u0002\u0002\u0002\u02fd\u02fe', + '\bI\u0004\u0002\u02fe\u0094\u0003\u0002\u0002\u0002\u02ff\u0300\u0007', + 'B\u0002\u0002\u0300\u0096\u0003\u0002\u0002\u0002\u0301\u0302\u0007', + '.\u0002\u0002\u0302\u0098\u0003\u0002\u0002\u0002\u0303\u0304\u0007', + '*\u0002\u0002\u0304\u009a\u0003\u0002\u0002\u0002\u0305\u0306\u0007', + '+\u0002\u0002\u0306\u009c\u0003\u0002\u0002\u0002\u0307\u0308\u0006', + 'N\u0002\u0002\u0308\u0309\u0007~\u0002\u0002\u0309\u030d\bN\u0006\u0002', + '\u030a\u030b\u0006N\u0003\u0002\u030b\u030d\u0007~\u0002\u0002\u030c', + '\u0307\u0003\u0002\u0002\u0002\u030c\u030a\u0003\u0002\u0002\u0002\u030d', + '\u009e\u0003\u0002\u0002\u0002\u030e\u030f\u0007~\u0002\u0002\u030f', + '\u0310\u0007@\u0002\u0002\u0310\u00a0\u0003\u0002\u0002\u0002\u0311', + '\u0312\u0007<\u0002\u0002\u0312\u00a2\u0003\u0002\u0002\u0002\u0313', + '\u0314\u0007`\u0002\u0002\u0314\u00a4\u0003\u0002\u0002\u0002\u0315', + '\u0316\u0007#\u0002\u0002\u0316\u00a6\u0003\u0002\u0002\u0002\u0317', + '\u0318\u0007,\u0002\u0002\u0318\u00a8\u0003\u0002\u0002\u0002\u0319', + '\u031a\u00071\u0002\u0002\u031a\u00aa\u0003\u0002\u0002\u0002\u031b', + "\u031c\u0007'\u0002\u0002\u031c\u00ac\u0003\u0002\u0002\u0002\u031d", + '\u031e\u0007-\u0002\u0002\u031e\u00ae\u0003\u0002\u0002\u0002\u031f', + '\u0320\u0007/\u0002\u0002\u0320\u00b0\u0003\u0002\u0002\u0002\u0321', + '\u0322\u0007\u0080\u0002\u0002\u0322\u00b2\u0003\u0002\u0002\u0002\u0323', + '\u0324\u0007>\u0002\u0002\u0324\u00b4\u0003\u0002\u0002\u0002\u0325', + '\u0326\u0007@\u0002\u0002\u0326\u00b6\u0003\u0002\u0002\u0002\u0327', + '\u0328\u0007>\u0002\u0002\u0328\u0329\u0007?\u0002\u0002\u0329\u00b8', + '\u0003\u0002\u0002\u0002\u032a\u032b\u0007@\u0002\u0002\u032b\u032c', + '\u0007?\u0002\u0002\u032c\u00ba\u0003\u0002\u0002\u0002\u032d\u032e', + '\u0007?\u0002\u0002\u032e\u00bc\u0003\u0002\u0002\u0002\u032f\u0330', + '\u0007#\u0002\u0002\u0330\u0331\u0007?\u0002\u0002\u0331\u00be\u0003', + '\u0002\u0002\u0002\u0332\u0333\u0007]\u0002\u0002\u0333\u00c0\u0003', + '\u0002\u0002\u0002\u0334\u0335\u0007_\u0002\u0002\u0335\u00c2\u0003', + '\u0002\u0002\u0002\u0336\u0337\t\t\u0002\u0002\u0337\u00c4\u0003\u0002', + '\u0002\u0002\u0338\u0339\t\n\u0002\u0002\u0339\u00c6\u0003\u0002\u0002', + '\u0002\u033a\u033b\t\u000b\u0002\u0002\u033b\u00c8\u0003\u0002\u0002', + '\u0002\u033c\u033d\t\f\u0002\u0002\u033d\u00ca\u0003\u0002\u0002\u0002', + '\u033e\u033f\t\u0007\u0002\u0002\u033f\u00cc\u0003\u0002\u0002\u0002', + '\u0340\u0341\t\r\u0002\u0002\u0341\u00ce\u0003\u0002\u0002\u0002\u0342', + '\u0343\t\u000e\u0002\u0002\u0343\u00d0\u0003\u0002\u0002\u0002\u0344', + '\u0345\t\u000f\u0002\u0002\u0345\u00d2\u0003\u0002\u0002\u0002\u0346', + '\u0347\t\u0010\u0002\u0002\u0347\u00d4\u0003\u0002\u0002\u0002\u0348', + '\u0349\t\u0011\u0002\u0002\u0349\u00d6\u0003\u0002\u0002\u0002\u034a', + '\u034b\t\u0012\u0002\u0002\u034b\u00d8\u0003\u0002\u0002\u0002\u034c', + '\u034d\t\u0013\u0002\u0002\u034d\u00da\u0003\u0002\u0002\u0002\u034e', + '\u034f\t\u0014\u0002\u0002\u034f\u00dc\u0003\u0002\u0002\u0002\u0350', + '\u0351\t\u0015\u0002\u0002\u0351\u00de\u0003\u0002\u0002\u0002\u0352', + '\u0353\t\u0016\u0002\u0002\u0353\u00e0\u0003\u0002\u0002\u0002\u0354', + '\u0355\t\u0017\u0002\u0002\u0355\u00e2\u0003\u0002\u0002\u0002\u0356', + '\u0357\t\u0018\u0002\u0002\u0357\u00e4\u0003\u0002\u0002\u0002\u0358', + '\u0359\t\u0019\u0002\u0002\u0359\u00e6\u0003\u0002\u0002\u0002\u035a', + '\u035b\t\u001a\u0002\u0002\u035b\u00e8\u0003\u0002\u0002\u0002\u035c', + '\u035d\t\u001b\u0002\u0002\u035d\u00ea\u0003\u0002\u0002\u0002\u035e', + '\u035f\t\u001c\u0002\u0002\u035f\u00ec\u0003\u0002\u0002\u0002\u0360', + '\u0361\t\u001d\u0002\u0002\u0361\u00ee\u0003\u0002\u0002\u0002\u0362', + '\u0363\t\u001e\u0002\u0002\u0363\u00f0\u0003\u0002\u0002\u0002\u0364', + '\u0365\t\u001f\u0002\u0002\u0365\u00f2\u0003\u0002\u0002\u0002\u0366', + '\u0367\t \u0002\u0002\u0367\u00f4\u0003\u0002\u0002\u0002\u0368\u0369', + '\t!\u0002\u0002\u0369\u00f6\u0003\u0002\u0002\u0002\u036a\u036b\u0007', + '.\u0002\u0002\u036b\u036c\u0003\u0002\u0002\u0002\u036c\u036d\b{\u0007', + '\u0002\u036d\u036e\b{\b\u0002\u036e\u00f8\u0003\u0002\u0002\u0002\u036f', + '\u0371\t\u0002\u0002\u0002\u0370\u036f\u0003\u0002\u0002\u0002\u0371', + '\u0372\u0003\u0002\u0002\u0002\u0372\u0370\u0003\u0002\u0002\u0002\u0372', + '\u0373\u0003\u0002\u0002\u0002\u0373\u0374\u0003\u0002\u0002\u0002\u0374', + '\u0375\b|\t\u0002\u0375\u00fa\u0003\u0002\u0002\u0002\u0376\u037a\u0007', + '%\u0002\u0002\u0377\u0379\n\u0003\u0002\u0002\u0378\u0377\u0003\u0002', + '\u0002\u0002\u0379\u037c\u0003\u0002\u0002\u0002\u037a\u0378\u0003\u0002', + '\u0002\u0002\u037a\u037b\u0003\u0002\u0002\u0002\u037b\u037d\u0003\u0002', + '\u0002\u0002\u037c\u037a\u0003\u0002\u0002\u0002\u037d\u037e\b}\u0003', + '\u0002\u037e\u00fc\u0003\u0002\u0002\u0002\u037f\u0384\u00071\u0002', + '\u0002\u0380\u0383\u0005\u0117\u008b\u0002\u0381\u0383\u000b\u0002\u0002', + '\u0002\u0382\u0380\u0003\u0002\u0002\u0002\u0382\u0381\u0003\u0002\u0002', + '\u0002\u0383\u0386\u0003\u0002\u0002\u0002\u0384\u0385\u0003\u0002\u0002', + '\u0002\u0384\u0382\u0003\u0002\u0002\u0002\u0385\u0387\u0003\u0002\u0002', + '\u0002\u0386\u0384\u0003\u0002\u0002\u0002\u0387\u0388\u00071\u0002', + '\u0002\u0388\u0389\u0003\u0002\u0002\u0002\u0389\u038a\b~\b\u0002\u038a', + '\u00fe\u0003\u0002\u0002\u0002\u038b\u0390\u0007$\u0002\u0002\u038c', + '\u038f\u0005\u0101\u0080\u0002\u038d\u038f\u000b\u0002\u0002\u0002\u038e', + '\u038c\u0003\u0002\u0002\u0002\u038e\u038d\u0003\u0002\u0002\u0002\u038f', + '\u0392\u0003\u0002\u0002\u0002\u0390\u0391\u0003\u0002\u0002\u0002\u0390', + '\u038e\u0003\u0002\u0002\u0002\u0391\u0393\u0003\u0002\u0002\u0002\u0392', + '\u0390\u0003\u0002\u0002\u0002\u0393\u0394\u0007$\u0002\u0002\u0394', + '\u0395\u0003\u0002\u0002\u0002\u0395\u0396\b\u007f\b\u0002\u0396\u0100', + '\u0003\u0002\u0002\u0002\u0397\u0398\u0007^\u0002\u0002\u0398\u0399', + '\u0007$\u0002\u0002\u0399\u0102\u0003\u0002\u0002\u0002\u039a\u039f', + '\u0007)\u0002\u0002\u039b\u039e\u0005\u0105\u0082\u0002\u039c\u039e', + '\u000b\u0002\u0002\u0002\u039d\u039b\u0003\u0002\u0002\u0002\u039d\u039c', + '\u0003\u0002\u0002\u0002\u039e\u03a1\u0003\u0002\u0002\u0002\u039f\u03a0', + '\u0003\u0002\u0002\u0002\u039f\u039d\u0003\u0002\u0002\u0002\u03a0\u03a2', + '\u0003\u0002\u0002\u0002\u03a1\u039f\u0003\u0002\u0002\u0002\u03a2\u03a3', + '\u0007)\u0002\u0002\u03a3\u03a4\u0003\u0002\u0002\u0002\u03a4\u03a5', + '\b\u0081\b\u0002\u03a5\u0104\u0003\u0002\u0002\u0002\u03a6\u03a7\u0007', + '^\u0002\u0002\u03a7\u03a8\u0007)\u0002\u0002\u03a8\u0106\u0003\u0002', + '\u0002\u0002\u03a9\u03aa\u0007\u00e4\u0002\u0002\u03aa\u03ab\u0007\u20ae', + '\u0002\u0002\u03ab\u03ac\u0007\u0155\u0002\u0002\u03ac\u03b1\u0003\u0002', + '\u0002\u0002\u03ad\u03b0\u0005\u0109\u0084\u0002\u03ae\u03b0\u000b\u0002', + '\u0002\u0002\u03af\u03ad\u0003\u0002\u0002\u0002\u03af\u03ae\u0003\u0002', + '\u0002\u0002\u03b0\u03b3\u0003\u0002\u0002\u0002\u03b1\u03b2\u0003\u0002', + '\u0002\u0002\u03b1\u03af\u0003\u0002\u0002\u0002\u03b2\u03b4\u0003\u0002', + '\u0002\u0002\u03b3\u03b1\u0003\u0002\u0002\u0002\u03b4\u03b5\u0007\u00e4', + '\u0002\u0002\u03b5\u03b6\u0007\u20ae\u0002\u0002\u03b6\u03b7\u0007\u009f', + '\u0002\u0002\u03b7\u03b8\u0003\u0002\u0002\u0002\u03b8\u03b9\b\u0083', + '\b\u0002\u03b9\u0108\u0003\u0002\u0002\u0002\u03ba\u03bb\u0007^\u0002', + '\u0002\u03bb\u03bc\u0007\u00e4\u0002\u0002\u03bc\u03bd\u0007\u20ae\u0002', + '\u0002\u03bd\u03be\u0007\u009f\u0002\u0002\u03be\u010a\u0003\u0002\u0002', + '\u0002\u03bf\u03c0\u0007\u00e4\u0002\u0002\u03c0\u03c1\u0007\u20ae\u0002', + '\u0002\u03c1\u03c2\u0007\u02de\u0002\u0002\u03c2\u03c7\u0003\u0002\u0002', + '\u0002\u03c3\u03c6\u0005\u010d\u0086\u0002\u03c4\u03c6\u000b\u0002\u0002', + '\u0002\u03c5\u03c3\u0003\u0002\u0002\u0002\u03c5\u03c4\u0003\u0002\u0002', + '\u0002\u03c6\u03c9\u0003\u0002\u0002\u0002\u03c7\u03c8\u0003\u0002\u0002', + '\u0002\u03c7\u03c5\u0003\u0002\u0002\u0002\u03c8\u03ca\u0003\u0002\u0002', + '\u0002\u03c9\u03c7\u0003\u0002\u0002\u0002\u03ca\u03cb\u0007\u00e4\u0002', + '\u0002\u03cb\u03cc\u0007\u20ae\u0002\u0002\u03cc\u03cd\u0007\u2124\u0002', + '\u0002\u03cd\u03ce\u0003\u0002\u0002\u0002\u03ce\u03cf\b\u0085\b\u0002', + '\u03cf\u010c\u0003\u0002\u0002\u0002\u03d0\u03d1\u0007^\u0002\u0002', + '\u03d1\u03d2\u0007\u00e4\u0002\u0002\u03d2\u03d3\u0007\u20ae\u0002\u0002', + '\u03d3\u03d4\u0007\u2124\u0002\u0002\u03d4\u010e\u0003\u0002\u0002\u0002', + '\u03d5\u03d9\t\u0004\u0002\u0002\u03d6\u03d8\t\u0005\u0002\u0002\u03d7', + '\u03d6\u0003\u0002\u0002\u0002\u03d8\u03db\u0003\u0002\u0002\u0002\u03d9', + '\u03d7\u0003\u0002\u0002\u0002\u03d9\u03da\u0003\u0002\u0002\u0002\u03da', + '\u0110\u0003\u0002\u0002\u0002\u03db\u03d9\u0003\u0002\u0002\u0002\u03dc', + '\u03dd\u0007B\u0002\u0002\u03dd\u0112\u0003\u0002\u0002\u0002\u03de', + '\u03e3\u0007b\u0002\u0002\u03df\u03e2\u0005\u0115\u008a\u0002\u03e0', + '\u03e2\u000b\u0002\u0002\u0002\u03e1\u03df\u0003\u0002\u0002\u0002\u03e1', + '\u03e0\u0003\u0002\u0002\u0002\u03e2\u03e5\u0003\u0002\u0002\u0002\u03e3', + '\u03e4\u0003\u0002\u0002\u0002\u03e3\u03e1\u0003\u0002\u0002\u0002\u03e4', + '\u03e6\u0003\u0002\u0002\u0002\u03e5\u03e3\u0003\u0002\u0002\u0002\u03e6', + '\u03e7\u0007b\u0002\u0002\u03e7\u0114\u0003\u0002\u0002\u0002\u03e8', + '\u03e9\u0007^\u0002\u0002\u03e9\u03ea\u0007b\u0002\u0002\u03ea\u0116', + '\u0003\u0002\u0002\u0002\u03eb\u03ec\u0007^\u0002\u0002\u03ec\u03ed', + '\u00071\u0002\u0002\u03ed\u0118\u0003\u0002\u0002\u0002\u03ee\u03f0', + '\t\u0002\u0002\u0002\u03ef\u03ee\u0003\u0002\u0002\u0002\u03f0\u03f1', + '\u0003\u0002\u0002\u0002\u03f1\u03ef\u0003\u0002\u0002\u0002\u03f1\u03f2', + '\u0003\u0002\u0002\u0002\u03f2\u03f3\u0003\u0002\u0002\u0002\u03f3\u03f4', + '\b\u008c\t\u0002\u03f4\u011a\u0003\u0002\u0002\u0002\u03f5\u03f9\u0007', + '%\u0002\u0002\u03f6\u03f8\n\u0003\u0002\u0002\u03f7\u03f6\u0003\u0002', + '\u0002\u0002\u03f8\u03fb\u0003\u0002\u0002\u0002\u03f9\u03f7\u0003\u0002', + '\u0002\u0002\u03f9\u03fa\u0003\u0002\u0002\u0002\u03fa\u03fc\u0003\u0002', + '\u0002\u0002\u03fb\u03f9\u0003\u0002\u0002\u0002\u03fc\u03fd\b\u008d', + '\u0003\u0002\u03fd\u011c\u0003\u0002\u0002\u0002\u03fe\u03ff\u0007~', + '\u0002\u0002\u03ff\u0400\u0003\u0002\u0002\u0002\u0400\u0401\b\u008e', + '\n\u0002\u0401\u0402\b\u008e\b\u0002\u0402\u011e\u0003\u0002\u0002\u0002', + '\u0403\u0404\u0005\u00e7s\u0002\u0404\u0405\u0005\u00dfo\u0002\u0405', + '\u0406\u0005\u00ebu\u0002\u0406\u0407\u0005\u00e5r\u0002\u0407\u0408', + '\u0005\u00c7c\u0002\u0408\u0409\u0005\u00cbe\u0002\u0409\u040a\b\u008f', + '\u000b\u0002\u040a\u040b\u0003\u0002\u0002\u0002\u040b\u040c\b\u008f', + '\f\u0002\u040c\u040d\b\u008f\b\u0002\u040d\u0120\u0003\u0002\u0002\u0002', + '\u040e\u040f\u0005\u00e1p\u0002\u040f\u0410\u0005\u00c3a\u0002\u0410', + '\u0411\u0005\u00e5r\u0002\u0411\u0412\u0005\u00e7s\u0002\u0412\u0413', + '\u0005\u00cbe\u0002\u0413\u0414\u0003\u0002\u0002\u0002\u0414\u0415', + '\b\u0090\r\u0002\u0415\u0416\b\u0090\b\u0002\u0416\u0417\b\u0090\u0004', + '\u0002\u0417\u0122\u0003\u0002\u0002\u0002\u0418\u0419\u0005\u00e7s', + '\u0002\u0419\u041a\u0005\u00cbe\u0002\u041a\u041b\u0005\u00c3a\u0002', + '\u041b\u041c\u0005\u00e5r\u0002\u041c\u041d\u0005\u00c7c\u0002\u041d', + '\u041e\u0005\u00d1h\u0002\u041e\u0124\u0003\u0002\u0002\u0002\u041f', + '\u0420\u0005\u00cdf\u0002\u0420\u0421\u0005\u00d3i\u0002\u0421\u0422', + '\u0005\u00cbe\u0002\u0422\u0423\u0005\u00d9l\u0002\u0423\u0425\u0005', + '\u00c9d\u0002\u0424\u0426\u0005\u00e7s\u0002\u0425\u0424\u0003\u0002', + '\u0002\u0002\u0425\u0426\u0003\u0002\u0002\u0002\u0426\u0427\u0003\u0002', + '\u0002\u0002\u0427\u0428\b\u0092\u000e\u0002\u0428\u0429\b\u0092\b\u0002', + '\u0429\u0126\u0003\u0002\u0002\u0002\u042a\u042b\u0005\u00c9d\u0002', + '\u042b\u042c\u0005\u00d3i\u0002\u042c\u042d\u0005\u00e7s\u0002\u042d', + '\u042e\u0005\u00e1p\u0002\u042e\u042f\u0005\u00d9l\u0002\u042f\u0430', + '\u0005\u00c3a\u0002\u0430\u0431\u0005\u00f3y\u0002\u0431\u0432\u0003', + '\u0002\u0002\u0002\u0432\u0433\b\u0093\u000f\u0002\u0433\u0434\b\u0093', + '\b\u0002\u0434\u0128\u0003\u0002\u0002\u0002\u0435\u0436\u0005\u00cd', + 'f\u0002\u0436\u0437\u0005\u00d3i\u0002\u0437\u0438\u0005\u00d9l\u0002', + '\u0438\u0439\u0005\u00e9t\u0002\u0439\u043a\u0005\u00cbe\u0002\u043a', + '\u043b\u0005\u00e5r\u0002\u043b\u043c\u0003\u0002\u0002\u0002\u043c', + '\u043d\b\u0094\u0010\u0002\u043d\u043e\b\u0094\b\u0002\u043e\u012a\u0003', + '\u0002\u0002\u0002\u043f\u0440\u0005\u00e7s\u0002\u0440\u0441\u0005', + '\u00e9t\u0002\u0441\u0442\u0005\u00c3a\u0002\u0442\u0444\u0005\u00e9', + 't\u0002\u0443\u0445\u0005\u00e7s\u0002\u0444\u0443\u0003\u0002\u0002', + '\u0002\u0444\u0445\u0003\u0002\u0002\u0002\u0445\u0446\u0003\u0002\u0002', + '\u0002\u0446\u0447\b\u0095\u0011\u0002\u0447\u0448\b\u0095\b\u0002\u0448', + '\u012c\u0003\u0002\u0002\u0002\u0449\u044a\u0005\u00e7s\u0002\u044a', + '\u044b\u0005\u00dfo\u0002\u044b\u044c\u0005\u00e5r\u0002\u044c\u044d', + '\u0005\u00e9t\u0002\u044d\u044e\u0003\u0002\u0002\u0002\u044e\u044f', + '\b\u0096\u0012\u0002\u044f\u0450\b\u0096\b\u0002\u0450\u012e\u0003\u0002', + '\u0002\u0002\u0451\u0452\u0005\u00d9l\u0002\u0452\u0453\u0005\u00d3', + 'i\u0002\u0453\u0454\u0005\u00dbm\u0002\u0454\u0455\u0005\u00d3i\u0002', + '\u0455\u0456\u0005\u00e9t\u0002\u0456\u0457\u0003\u0002\u0002\u0002', + '\u0457\u0458\b\u0097\u0013\u0002\u0458\u0459\b\u0097\b\u0002\u0459\u0130', + '\u0003\u0002\u0002\u0002\u045a\u045b\u0007*\u0002\u0002\u045b\u0132', + '\u0003\u0002\u0002\u0002\u045c\u045d\u0007+\u0002\u0002\u045d\u0134', + '\u0003\u0002\u0002\u0002\u045e\u045f\u0005\u00c3a\u0002\u045f\u0460', + '\u0005\u00ddn\u0002\u0460\u0461\u0005\u00c9d\u0002\u0461\u0136\u0003', + '\u0002\u0002\u0002\u0462\u0463\u0005\u00dfo\u0002\u0463\u0464\u0005', + '\u00e5r\u0002\u0464\u0138\u0003\u0002\u0002\u0002\u0465\u0466\u0005', + '\u00ddn\u0002\u0466\u0467\u0005\u00dfo\u0002\u0467\u0468\u0005\u00e9', + 't\u0002\u0468\u013a\u0003\u0002\u0002\u0002\u0469\u046e\u0007$\u0002', + '\u0002\u046a\u046d\u0005\u013d\u009e\u0002\u046b\u046d\u000b\u0002\u0002', + '\u0002\u046c\u046a\u0003\u0002\u0002\u0002\u046c\u046b\u0003\u0002\u0002', + '\u0002\u046d\u0470\u0003\u0002\u0002\u0002\u046e\u046f\u0003\u0002\u0002', + '\u0002\u046e\u046c\u0003\u0002\u0002\u0002\u046f\u0471\u0003\u0002\u0002', + '\u0002\u0470\u046e\u0003\u0002\u0002\u0002\u0471\u0472\u0007$\u0002', + '\u0002\u0472\u013c\u0003\u0002\u0002\u0002\u0473\u0474\u0007^\u0002', + '\u0002\u0474\u0475\u0007$\u0002\u0002\u0475\u013e\u0003\u0002\u0002', + '\u0002\u0476\u047b\u0007)\u0002\u0002\u0477\u047a\u0005\u0141\u00a0', + '\u0002\u0478\u047a\u000b\u0002\u0002\u0002\u0479\u0477\u0003\u0002\u0002', + '\u0002\u0479\u0478\u0003\u0002\u0002\u0002\u047a\u047d\u0003\u0002\u0002', + '\u0002\u047b\u047c\u0003\u0002\u0002\u0002\u047b\u0479\u0003\u0002\u0002', + '\u0002\u047c\u047e\u0003\u0002\u0002\u0002\u047d\u047b\u0003\u0002\u0002', + '\u0002\u047e\u047f\u0007)\u0002\u0002\u047f\u0140\u0003\u0002\u0002', + '\u0002\u0480\u0481\u0007^\u0002\u0002\u0481\u0482\u0007)\u0002\u0002', + '\u0482\u0142\u0003\u0002\u0002\u0002\u0483\u0484\u0007\u00e4\u0002\u0002', + '\u0484\u0485\u0007\u20ae\u0002\u0002\u0485\u0486\u0007\u0155\u0002\u0002', + '\u0486\u048b\u0003\u0002\u0002\u0002\u0487\u048a\u0005\u0145\u00a2\u0002', + '\u0488\u048a\u000b\u0002\u0002\u0002\u0489\u0487\u0003\u0002\u0002\u0002', + '\u0489\u0488\u0003\u0002\u0002\u0002\u048a\u048d\u0003\u0002\u0002\u0002', + '\u048b\u048c\u0003\u0002\u0002\u0002\u048b\u0489\u0003\u0002\u0002\u0002', + '\u048c\u048e\u0003\u0002\u0002\u0002\u048d\u048b\u0003\u0002\u0002\u0002', + '\u048e\u048f\u0007\u00e4\u0002\u0002\u048f\u0490\u0007\u20ae\u0002\u0002', + '\u0490\u0491\u0007\u009f\u0002\u0002\u0491\u0144\u0003\u0002\u0002\u0002', + '\u0492\u0493\u0007^\u0002\u0002\u0493\u0494\u0007\u00e4\u0002\u0002', + '\u0494\u0495\u0007\u20ae\u0002\u0002\u0495\u0496\u0007\u009f\u0002\u0002', + '\u0496\u0146\u0003\u0002\u0002\u0002\u0497\u0498\u0007\u00e4\u0002\u0002', + '\u0498\u0499\u0007\u20ae\u0002\u0002\u0499\u049a\u0007\u02de\u0002\u0002', + '\u049a\u049f\u0003\u0002\u0002\u0002\u049b\u049e\u0005\u0149\u00a4\u0002', + '\u049c\u049e\u000b\u0002\u0002\u0002\u049d\u049b\u0003\u0002\u0002\u0002', + '\u049d\u049c\u0003\u0002\u0002\u0002\u049e\u04a1\u0003\u0002\u0002\u0002', + '\u049f\u04a0\u0003\u0002\u0002\u0002\u049f\u049d\u0003\u0002\u0002\u0002', + '\u04a0\u04a2\u0003\u0002\u0002\u0002\u04a1\u049f\u0003\u0002\u0002\u0002', + '\u04a2\u04a3\u0007\u00e4\u0002\u0002\u04a3\u04a4\u0007\u20ae\u0002\u0002', + '\u04a4\u04a5\u0007\u2124\u0002\u0002\u04a5\u0148\u0003\u0002\u0002\u0002', + '\u04a6\u04a7\u0007^\u0002\u0002\u04a7\u04a8\u0007\u00e4\u0002\u0002', + '\u04a8\u04a9\u0007\u20ae\u0002\u0002\u04a9\u04aa\u0007\u2124\u0002\u0002', + '\u04aa\u014a\u0003\u0002\u0002\u0002\u04ab\u04ad\n"\u0002\u0002\u04ac', + '\u04ab\u0003\u0002\u0002\u0002\u04ad\u04ae\u0003\u0002\u0002\u0002\u04ae', + '\u04ac\u0003\u0002\u0002\u0002\u04ae\u04af\u0003\u0002\u0002\u0002\u04af', + '\u014c\u0003\u0002\u0002\u0002G\u0002\u0003\u0004\u0150\u0158\u018e', + '\u01a4\u0201\u020f\u0217\u0220\u0228\u0231\u0238\u023f\u0247\u0250\u0255', + '\u025b\u0263\u026b\u0275\u027c\u0283\u0289\u0293\u0296\u0299\u029e\u02a2', + '\u02a9\u02ab\u02b6\u02b8\u02c6\u02c8\u02da\u02dc\u02eb\u02ed\u030c\u0372', + '\u037a\u0382\u0384\u038e\u0390\u039d\u039f\u03af\u03b1\u03c5\u03c7\u03d9', + '\u03e1\u03e3\u03f1\u03f9\u0425\u0444\u046c\u046e\u0479\u047b\u0489\u048b', + '\u049d\u049f\u04ae\u0014\u0002\u0004\u0002\u0002\u0005\u0002\u0007\u0003', + '\u0002\u0007\u0004\u0002\u0003N\u0002\tE\u0002\u0006\u0002\u0002\u0002', + '\u0003\u0002\tH\u0002\u0003\u008f\u0003\t\u0005\u0002\t\n\u0002\t\f', + '\u0002\t\r\u0002\t\u000e\u0002\t\u000f\u0002\t\u0010\u0002\t\u0015\u0002', +].join(''); + +var atn = new antlr4.atn.ATNDeserializer().deserialize(serializedATN); + +var decisionsToDFA = atn.decisionToState.map(function (ds, index) { + return new antlr4.dfa.DFA(ds, index); +}); + +function ScrollQLLexer(input) { + antlr4.Lexer.call(this, input); + this._interp = new antlr4.atn.LexerATNSimulator(this, atn, decisionsToDFA, new antlr4.PredictionContextCache()); + + this.isFirstPipe = true; + + return this; +} + +ScrollQLLexer.prototype = Object.create(antlr4.Lexer.prototype); +ScrollQLLexer.prototype.constructor = ScrollQLLexer; + +Object.defineProperty(ScrollQLLexer.prototype, 'atn', { + get: function () { + return atn; + }, +}); + +ScrollQLLexer.EOF = antlr4.Token.EOF; +ScrollQLLexer.WS = 1; +ScrollQLLexer.COMMENT = 2; +ScrollQLLexer.K_SOURCE = 3; +ScrollQLLexer.K_START = 4; +ScrollQLLexer.K_END = 5; +ScrollQLLexer.K_NOW = 6; +ScrollQLLexer.K_LIVE = 7; +ScrollQLLexer.K_PARSE = 8; +ScrollQLLexer.K_SEARCH = 9; +ScrollQLLexer.K_FIELDS = 10; +ScrollQLLexer.K_DISPLAY = 11; +ScrollQLLexer.K_FILTER = 12; +ScrollQLLexer.K_STATS = 13; +ScrollQLLexer.K_SORT = 14; +ScrollQLLexer.K_ORDER = 15; +ScrollQLLexer.K_ASC = 16; +ScrollQLLexer.K_DESC = 17; +ScrollQLLexer.K_HEAD = 18; +ScrollQLLexer.K_LIMIT = 19; +ScrollQLLexer.K_TAIL = 20; +ScrollQLLexer.K_REGEX = 21; +ScrollQLLexer.K_IN = 22; +ScrollQLLexer.K_GROUP = 23; +ScrollQLLexer.K_BY = 24; +ScrollQLLexer.K_AS = 25; +ScrollQLLexer.K_AND = 26; +ScrollQLLexer.K_OR = 27; +ScrollQLLexer.K_NOT = 28; +ScrollQLLexer.K_LIKE = 29; +ScrollQLLexer.K_MATCHES = 30; +ScrollQLLexer.K_TU_MS = 31; +ScrollQLLexer.K_TU_MSEC = 32; +ScrollQLLexer.K_TU_MSECOND = 33; +ScrollQLLexer.K_TU_S = 34; +ScrollQLLexer.K_TU_SEC = 35; +ScrollQLLexer.K_TU_SECOND = 36; +ScrollQLLexer.K_TU_M = 37; +ScrollQLLexer.K_TU_MIN = 38; +ScrollQLLexer.K_TU_MINUTE = 39; +ScrollQLLexer.K_TU_H = 40; +ScrollQLLexer.K_TU_HR = 41; +ScrollQLLexer.K_TU_HOUR = 42; +ScrollQLLexer.K_TU_D = 43; +ScrollQLLexer.K_TU_DAY = 44; +ScrollQLLexer.K_TU_W = 45; +ScrollQLLexer.K_TU_WEEK = 46; +ScrollQLLexer.K_TU_MO = 47; +ScrollQLLexer.K_TU_MON = 48; +ScrollQLLexer.K_TU_MONTH = 49; +ScrollQLLexer.K_TU_Q = 50; +ScrollQLLexer.K_TU_QTR = 51; +ScrollQLLexer.K_TU_QUARTER = 52; +ScrollQLLexer.K_TU_Y = 53; +ScrollQLLexer.K_TU_YR = 54; +ScrollQLLexer.K_TU_YEAR = 55; +ScrollQLLexer.RAW_ID = 56; +ScrollQLLexer.LIT_INTEGER = 57; +ScrollQLLexer.LIT_NUMBER = 58; +ScrollQLLexer.SDQUOTED_STRING = 59; +ScrollQLLexer.SSQUOTED_STRING = 60; +ScrollQLLexer.CDQUOTED_STRING = 61; +ScrollQLLexer.CSQUOTED_STRING = 62; +ScrollQLLexer.QUOTED_IDENT = 63; +ScrollQLLexer.SYM_EQTILDE = 64; +ScrollQLLexer.SYM_TILDEEQ = 65; +ScrollQLLexer.SYM_AT = 66; +ScrollQLLexer.SYM_COMMA = 67; +ScrollQLLexer.SYM_LPAREN = 68; +ScrollQLLexer.SYM_RPAREN = 69; +ScrollQLLexer.SYM_PIPE = 70; +ScrollQLLexer.SYM_WRITE = 71; +ScrollQLLexer.SYM_COLON = 72; +ScrollQLLexer.SYM_CARET = 73; +ScrollQLLexer.SYM_NOT = 74; +ScrollQLLexer.SYM_MUL = 75; +ScrollQLLexer.SYM_DIV = 76; +ScrollQLLexer.SYM_MOD = 77; +ScrollQLLexer.SYM_PLUS = 78; +ScrollQLLexer.SYM_MINUS = 79; +ScrollQLLexer.SYM_TILDE = 80; +ScrollQLLexer.SYM_LT = 81; +ScrollQLLexer.SYM_GT = 82; +ScrollQLLexer.SYM_LTEQ = 83; +ScrollQLLexer.SYM_GTEQ = 84; +ScrollQLLexer.SYM_EQ = 85; +ScrollQLLexer.SYM_NEQ = 86; +ScrollQLLexer.SYM_LBRACKET = 87; +ScrollQLLexer.SYM_RBRACKET = 88; +ScrollQLLexer.REGEX_WS = 89; +ScrollQLLexer.REGEX_COMMENT = 90; +ScrollQLLexer.REGEX = 91; +ScrollQLLexer.RE_SDQUOTED_STRING = 92; +ScrollQLLexer.RE_SSQUOTED_STRING = 93; +ScrollQLLexer.RE_CDQUOTED_STRING = 94; +ScrollQLLexer.RE_CSQUOTED_STRING = 95; +ScrollQLLexer.RE_RAW_ID = 96; +ScrollQLLexer.RE_SYM_AT = 97; +ScrollQLLexer.RE_QUOTED_IDENT = 98; +ScrollQLLexer.SE_WS = 99; +ScrollQLLexer.SE_COMMENT = 100; +ScrollQLLexer.SE_K_SEARCH = 101; +ScrollQLLexer.SE_SYM_LPAREN = 102; +ScrollQLLexer.SE_SYM_RPAREN = 103; +ScrollQLLexer.SE_K_AND = 104; +ScrollQLLexer.SE_K_OR = 105; +ScrollQLLexer.SE_K_NOT = 106; +ScrollQLLexer.SE_SDQUOTED_STRING = 107; +ScrollQLLexer.SE_SSQUOTED_STRING = 108; +ScrollQLLexer.SE_CDQUOTED_STRING = 109; +ScrollQLLexer.SE_CSQUOTED_STRING = 110; +ScrollQLLexer.SE_UNQUOTED_STRING = 111; +ScrollQLLexer.SE_PIPE = 112; + +ScrollQLLexer.WHITESPACE = 2; +ScrollQLLexer.COMMENTS = 3; + +ScrollQLLexer.REGEX_VALUE = 1; +ScrollQLLexer.SEARCH_VALUE = 2; + +ScrollQLLexer.prototype.channelNames = ['DEFAULT_TOKEN_CHANNEL', 'HIDDEN', 'WHITESPACE', 'COMMENTS']; + +ScrollQLLexer.prototype.modeNames = ['DEFAULT_MODE', 'REGEX_VALUE', 'SEARCH_VALUE']; + +ScrollQLLexer.prototype.literalNames = [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "'=~'", + "'~='", + null, + null, + null, + null, + null, + "'|>'", + "':'", + "'^'", + "'!'", + "'*'", + "'/'", + "'%'", + "'+'", + "'-'", + "'~'", + "'<'", + "'>'", + "'<='", + "'>='", + "'='", + "'!='", + "'['", + "']'", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "'|'", +]; + +ScrollQLLexer.prototype.symbolicNames = [ + null, + 'WS', + 'COMMENT', + 'K_SOURCE', + 'K_START', + 'K_END', + 'K_NOW', + 'K_LIVE', + 'K_PARSE', + 'K_SEARCH', + 'K_FIELDS', + 'K_DISPLAY', + 'K_FILTER', + 'K_STATS', + 'K_SORT', + 'K_ORDER', + 'K_ASC', + 'K_DESC', + 'K_HEAD', + 'K_LIMIT', + 'K_TAIL', + 'K_REGEX', + 'K_IN', + 'K_GROUP', + 'K_BY', + 'K_AS', + 'K_AND', + 'K_OR', + 'K_NOT', + 'K_LIKE', + 'K_MATCHES', + 'K_TU_MS', + 'K_TU_MSEC', + 'K_TU_MSECOND', + 'K_TU_S', + 'K_TU_SEC', + 'K_TU_SECOND', + 'K_TU_M', + 'K_TU_MIN', + 'K_TU_MINUTE', + 'K_TU_H', + 'K_TU_HR', + 'K_TU_HOUR', + 'K_TU_D', + 'K_TU_DAY', + 'K_TU_W', + 'K_TU_WEEK', + 'K_TU_MO', + 'K_TU_MON', + 'K_TU_MONTH', + 'K_TU_Q', + 'K_TU_QTR', + 'K_TU_QUARTER', + 'K_TU_Y', + 'K_TU_YR', + 'K_TU_YEAR', + 'RAW_ID', + 'LIT_INTEGER', + 'LIT_NUMBER', + 'SDQUOTED_STRING', + 'SSQUOTED_STRING', + 'CDQUOTED_STRING', + 'CSQUOTED_STRING', + 'QUOTED_IDENT', + 'SYM_EQTILDE', + 'SYM_TILDEEQ', + 'SYM_AT', + 'SYM_COMMA', + 'SYM_LPAREN', + 'SYM_RPAREN', + 'SYM_PIPE', + 'SYM_WRITE', + 'SYM_COLON', + 'SYM_CARET', + 'SYM_NOT', + 'SYM_MUL', + 'SYM_DIV', + 'SYM_MOD', + 'SYM_PLUS', + 'SYM_MINUS', + 'SYM_TILDE', + 'SYM_LT', + 'SYM_GT', + 'SYM_LTEQ', + 'SYM_GTEQ', + 'SYM_EQ', + 'SYM_NEQ', + 'SYM_LBRACKET', + 'SYM_RBRACKET', + 'REGEX_WS', + 'REGEX_COMMENT', + 'REGEX', + 'RE_SDQUOTED_STRING', + 'RE_SSQUOTED_STRING', + 'RE_CDQUOTED_STRING', + 'RE_CSQUOTED_STRING', + 'RE_RAW_ID', + 'RE_SYM_AT', + 'RE_QUOTED_IDENT', + 'SE_WS', + 'SE_COMMENT', + 'SE_K_SEARCH', + 'SE_SYM_LPAREN', + 'SE_SYM_RPAREN', + 'SE_K_AND', + 'SE_K_OR', + 'SE_K_NOT', + 'SE_SDQUOTED_STRING', + 'SE_SSQUOTED_STRING', + 'SE_CDQUOTED_STRING', + 'SE_CSQUOTED_STRING', + 'SE_UNQUOTED_STRING', + 'SE_PIPE', +]; + +ScrollQLLexer.prototype.ruleNames = [ + 'WS', + 'COMMENT', + 'K_SOURCE', + 'K_START', + 'K_END', + 'K_NOW', + 'K_LIVE', + 'K_PARSE', + 'K_SEARCH', + 'K_FIELDS', + 'K_DISPLAY', + 'K_FILTER', + 'K_STATS', + 'K_SORT', + 'K_ORDER', + 'K_ASC', + 'K_DESC', + 'K_HEAD', + 'K_LIMIT', + 'K_TAIL', + 'K_REGEX', + 'K_IN', + 'K_GROUP', + 'K_BY', + 'K_AS', + 'K_AND', + 'K_OR', + 'K_NOT', + 'K_LIKE', + 'K_MATCHES', + 'K_TU_MS', + 'K_TU_MSEC', + 'K_TU_MSECOND', + 'K_TU_S', + 'K_TU_SEC', + 'K_TU_SECOND', + 'K_TU_M', + 'K_TU_MIN', + 'K_TU_MINUTE', + 'K_TU_H', + 'K_TU_HR', + 'K_TU_HOUR', + 'K_TU_D', + 'K_TU_DAY', + 'K_TU_W', + 'K_TU_WEEK', + 'K_TU_MO', + 'K_TU_MON', + 'K_TU_MONTH', + 'K_TU_Q', + 'K_TU_QTR', + 'K_TU_QUARTER', + 'K_TU_Y', + 'K_TU_YR', + 'K_TU_YEAR', + 'RAW_ID', + 'LIT_INTEGER', + 'LIT_NUMBER', + 'INT', + 'EXP', + 'SDQUOTED_STRING', + 'SDQ_ESC_STRING', + 'SSQUOTED_STRING', + 'SSQ_ESC_STRING', + 'CDQUOTED_STRING', + 'CDQ_ESC_STRING', + 'CSQUOTED_STRING', + 'CSQ_ESC_STRING', + 'QUOTED_IDENT', + 'ESC_IDENT', + 'SYM_EQTILDE', + 'SYM_TILDEEQ', + 'SYM_AT', + 'SYM_COMMA', + 'SYM_LPAREN', + 'SYM_RPAREN', + 'SYM_PIPE', + 'SYM_WRITE', + 'SYM_COLON', + 'SYM_CARET', + 'SYM_NOT', + 'SYM_MUL', + 'SYM_DIV', + 'SYM_MOD', + 'SYM_PLUS', + 'SYM_MINUS', + 'SYM_TILDE', + 'SYM_LT', + 'SYM_GT', + 'SYM_LTEQ', + 'SYM_GTEQ', + 'SYM_EQ', + 'SYM_NEQ', + 'SYM_LBRACKET', + 'SYM_RBRACKET', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'RE_UNRECOGNIZED', + 'REGEX_WS', + 'REGEX_COMMENT', + 'REGEX', + 'RE_SDQUOTED_STRING', + 'RE_SDQ_ESC_STRING', + 'RE_SSQUOTED_STRING', + 'RE_SSQ_ESC_STRING', + 'RE_CDQUOTED_STRING', + 'RE_CDQ_ESC_STRING', + 'RE_CSQUOTED_STRING', + 'RE_CSQ_ESC_STRING', + 'RE_RAW_ID', + 'RE_SYM_AT', + 'RE_QUOTED_IDENT', + 'RE_ESC_IDENT', + 'RE_ESC_REGEX', + 'SE_WS', + 'SE_COMMENT', + 'SE_PIPE', + 'SE_K_SOURCE', + 'SE_K_PARSE', + 'SE_K_SEARCH', + 'SE_K_FIELDS', + 'SE_K_DISPLAY', + 'SE_K_FILTER', + 'SE_K_STATS', + 'SE_K_SORT', + 'SE_K_LIMIT', + 'SE_SYM_LPAREN', + 'SE_SYM_RPAREN', + 'SE_K_AND', + 'SE_K_OR', + 'SE_K_NOT', + 'SE_SDQUOTED_STRING', + 'SE_SDQ_ESC_STRING', + 'SE_SSQUOTED_STRING', + 'SE_SSQ_ESC_STRING', + 'SE_CDQUOTED_STRING', + 'SE_CDQ_ESC_STRING', + 'SE_CSQUOTED_STRING', + 'SE_CSQ_ESC_STRING', + 'SE_UNQUOTED_STRING', +]; + +ScrollQLLexer.prototype.grammarFileName = 'ScrollQLLexer.g4'; + +ScrollQLLexer.prototype.action = function (localctx, ruleIndex, actionIndex) { + switch (ruleIndex) { + case 76: + this.SYM_PIPE_action(localctx, actionIndex); + break; + case 141: + this.SE_K_SOURCE_action(localctx, actionIndex); + break; + default: + throw 'No registered action for:' + ruleIndex; + } +}; + +ScrollQLLexer.prototype.SYM_PIPE_action = function (localctx, actionIndex) { + switch (actionIndex) { + case 0: + this.isFirstPipe = false; + this.pushMode(ScrollQLLexer.SEARCH_VALUE); + break; + default: + throw 'No registered action for:' + actionIndex; + } +}; + +ScrollQLLexer.prototype.SE_K_SOURCE_action = function (localctx, actionIndex) { + switch (actionIndex) { + case 1: + this.isFirstPipe = true; + break; + default: + throw 'No registered action for:' + actionIndex; + } +}; +ScrollQLLexer.prototype.sempred = function (localctx, ruleIndex, predIndex) { + switch (ruleIndex) { + case 76: + return this.SYM_PIPE_sempred(localctx, predIndex); + default: + throw 'No registered predicate for:' + ruleIndex; + } +}; + +ScrollQLLexer.prototype.SYM_PIPE_sempred = function (localctx, predIndex) { + switch (predIndex) { + case 0: + return this.isFirstPipe; + case 1: + return !this.isFirstPipe; + default: + throw 'No predicate with index:' + predIndex; + } +}; + +exports.ScrollQLLexer = ScrollQLLexer; diff --git a/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParser.js b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParser.js new file mode 100644 index 0000000..48c96ed --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParser.js @@ -0,0 +1,8603 @@ +// Generated from ScrollQLParser.g4 by ANTLR 4.8 +/* eslint-disable */ +// jshint ignore: start +// @ts-nocheck +var antlr4 = require('antlr4/index'); +var ScrollQLParserListener = require('./ScrollQLParserListener').ScrollQLParserListener; +var grammarFileName = 'ScrollQLParser.g4'; + +var serializedATN = [ + '\u0003\u608b\ua72a\u8133\ub9ed\u417c\u3be7\u7786\u5964', + '\u0003r\u0244\u0004\u0002\t\u0002\u0004\u0003\t\u0003\u0004\u0004\t', + '\u0004\u0004\u0005\t\u0005\u0004\u0006\t\u0006\u0004\u0007\t\u0007\u0004', + '\b\t\b\u0004\t\t\t\u0004\n\t\n\u0004\u000b\t\u000b\u0004\f\t\f\u0004', + '\r\t\r\u0004\u000e\t\u000e\u0004\u000f\t\u000f\u0004\u0010\t\u0010\u0004', + '\u0011\t\u0011\u0004\u0012\t\u0012\u0004\u0013\t\u0013\u0004\u0014\t', + '\u0014\u0004\u0015\t\u0015\u0004\u0016\t\u0016\u0004\u0017\t\u0017\u0004', + '\u0018\t\u0018\u0004\u0019\t\u0019\u0004\u001a\t\u001a\u0004\u001b\t', + '\u001b\u0004\u001c\t\u001c\u0004\u001d\t\u001d\u0004\u001e\t\u001e\u0004', + '\u001f\t\u001f\u0004 \t \u0004!\t!\u0004"\t"\u0004#\t#\u0004$\t$\u0004', + "%\t%\u0004&\t&\u0004'\t'\u0004(\t(\u0004)\t)\u0004*\t*\u0004+\t+\u0004", + ',\t,\u0004-\t-\u0004.\t.\u0004/\t/\u00040\t0\u00041\t1\u00042\t2\u0004', + '3\t3\u00044\t4\u00045\t5\u00046\t6\u00047\t7\u00048\t8\u00049\t9\u0004', + ':\t:\u0003\u0002\u0003\u0002\u0003\u0002\u0003\u0003\u0003\u0003\u0003', + '\u0003\u0005\u0003{\n\u0003\u0003\u0003\u0003\u0003\u0005\u0003\u007f', + '\n\u0003\u0003\u0004\u0003\u0004\u0003\u0004\u0007\u0004\u0084\n\u0004', + '\f\u0004\u000e\u0004\u0087\u000b\u0004\u0003\u0005\u0003\u0005\u0003', + '\u0005\u0007\u0005\u008c\n\u0005\f\u0005\u000e\u0005\u008f\u000b\u0005', + '\u0003\u0005\u0003\u0005\u0005\u0005\u0093\n\u0005\u0003\u0005\u0003', + '\u0005\u0007\u0005\u0097\n\u0005\f\u0005\u000e\u0005\u009a\u000b\u0005', + '\u0003\u0006\u0003\u0006\u0003\u0006\u0007\u0006\u009f\n\u0006\f\u0006', + '\u000e\u0006\u00a2\u000b\u0006\u0003\u0007\u0003\u0007\u0003\u0007\u0003', + '\u0007\u0003\u0007\u0003\u0007\u0005\u0007\u00aa\n\u0007\u0003\b\u0003', + '\b\u0003\b\u0003\b\u0003\b\u0005\b\u00b1\n\b\u0007\b\u00b3\n\b\f\b\u000e', + '\b\u00b6\u000b\b\u0003\t\u0003\t\u0003\t\u0003\t\u0003\t\u0003\t\u0003', + '\t\u0003\t\u0003\t\u0003\n\u0003\n\u0003\n\u0003\n\u0005\n\u00c5\n\n', + '\u0003\u000b\u0003\u000b\u0003\f\u0003\f\u0005\f\u00cb\n\f\u0003\f\u0003', + '\f\u0005\f\u00cf\n\f\u0003\f\u0005\f\u00d2\n\f\u0003\f\u0005\f\u00d5', + '\n\f\u0003\r\u0003\r\u0005\r\u00d9\n\r\u0003\u000e\u0003\u000e\u0003', + '\u000f\u0007\u000f\u00de\n\u000f\f\u000f\u000e\u000f\u00e1\u000b\u000f', + '\u0003\u0010\u0003\u0010\u0003\u0010\u0003\u0010\u0007\u0010\u00e7\n', + '\u0010\f\u0010\u000e\u0010\u00ea\u000b\u0010\u0003\u0010\u0005\u0010', + '\u00ed\n\u0010\u0003\u0010\u0003\u0010\u0003\u0010\u0003\u0010\u0007', + '\u0010\u00f3\n\u0010\f\u0010\u000e\u0010\u00f6\u000b\u0010\u0005\u0010', + '\u00f8\n\u0010\u0003\u0011\u0003\u0011\u0003\u0011\u0005\u0011\u00fd', + '\n\u0011\u0003\u0012\u0003\u0012\u0005\u0012\u0101\n\u0012\u0003\u0013', + '\u0003\u0013\u0003\u0013\u0003\u0013\u0007\u0013\u0107\n\u0013\f\u0013', + '\u000e\u0013\u010a\u000b\u0013\u0003\u0013\u0003\u0013\u0003\u0013\u0003', + '\u0013\u0007\u0013\u0110\n\u0013\f\u0013\u000e\u0013\u0113\u000b\u0013', + '\u0005\u0013\u0115\n\u0013\u0003\u0014\u0003\u0014\u0003\u0014\u0005', + '\u0014\u011a\n\u0014\u0003\u0015\u0003\u0015\u0005\u0015\u011e\n\u0015', + '\u0003\u0015\u0003\u0015\u0003\u0015\u0003\u0015\u0003\u0015\u0007\u0015', + '\u0125\n\u0015\f\u0015\u000e\u0015\u0128\u000b\u0015\u0003\u0015\u0003', + '\u0015\u0005\u0015\u012c\n\u0015\u0003\u0015\u0005\u0015\u012f\n\u0015', + '\u0003\u0016\u0003\u0016\u0003\u0016\u0003\u0017\u0005\u0017\u0135\n', + '\u0017\u0003\u0017\u0003\u0017\u0003\u0018\u0003\u0018\u0003\u0018\u0003', + '\u0018\u0003\u0018\u0003\u0018\u0003\u0018\u0003\u0018\u0005\u0018\u0141', + '\n\u0018\u0003\u0018\u0003\u0018\u0005\u0018\u0145\n\u0018\u0003\u0018', + '\u0003\u0018\u0003\u0018\u0003\u0018\u0007\u0018\u014b\n\u0018\f\u0018', + '\u000e\u0018\u014e\u000b\u0018\u0003\u0019\u0003\u0019\u0003\u0019\u0003', + '\u0019\u0003\u0019\u0005\u0019\u0155\n\u0019\u0003\u001a\u0003\u001a', + '\u0003\u001a\u0003\u001b\u0003\u001b\u0005\u001b\u015c\n\u001b\u0003', + '\u001b\u0003\u001b\u0003\u001b\u0007\u001b\u0161\n\u001b\f\u001b\u000e', + '\u001b\u0164\u000b\u001b\u0003\u001c\u0003\u001c\u0003\u001c\u0003\u001c', + '\u0003\u001c\u0005\u001c\u016b\n\u001c\u0005\u001c\u016d\n\u001c\u0003', + '\u001d\u0003\u001d\u0003\u001d\u0003\u001d\u0005\u001d\u0173\n\u001d', + '\u0003\u001e\u0003\u001e\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f', + '\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f', + '\u0003\u001f\u0003\u001f\u0005\u001f\u0183\n\u001f\u0003\u001f\u0003', + '\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003', + '\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003', + '\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0005\u001f\u0196\n\u001f', + '\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0003\u001f', + '\u0003\u001f\u0003\u001f\u0003\u001f\u0005\u001f\u01a1\n\u001f\u0003', + '\u001f\u0003\u001f\u0003\u001f\u0003\u001f\u0005\u001f\u01a7\n\u001f', + '\u0003\u001f\u0003\u001f\u0007\u001f\u01ab\n\u001f\f\u001f\u000e\u001f', + '\u01ae\u000b\u001f\u0003 \u0003 \u0003 \u0003 \u0005 \u01b4\n \u0003', + '!\u0003!\u0005!\u01b8\n!\u0003"\u0003"\u0003"\u0003"\u0003"\u0007', + '"\u01bf\n"\f"\u000e"\u01c2\u000b"\u0003"\u0003"\u0003"\u0003', + '"\u0003"\u0005"\u01c9\n"\u0003"\u0003"\u0005"\u01cd\n"\u0003', + '#\u0003#\u0005#\u01d1\n#\u0003$\u0003$\u0003$\u0003$\u0007$\u01d7\n', + '$\f$\u000e$\u01da\u000b$\u0003$\u0003$\u0003$\u0003$\u0005$\u01e0\n', + '$\u0003%\u0003%\u0005%\u01e4\n%\u0003&\u0003&\u0005&\u01e8\n&\u0003', + "'\u0003'\u0003'\u0003'\u0005'\u01ee\n'\u0003(\u0003(\u0003(\u0003", + '(\u0003(\u0005(\u01f5\n(\u0003)\u0003)\u0003*\u0003*\u0003*\u0003*\u0005', + '*\u01fd\n*\u0003+\u0003+\u0003+\u0003+\u0003+\u0003+\u0003+\u0005+\u0206', + '\n+\u0003,\u0003,\u0005,\u020a\n,\u0003-\u0003-\u0005-\u020e\n-\u0003', + '.\u0003.\u0005.\u0212\n.\u0003/\u0003/\u0005/\u0216\n/\u00030\u0003', + '0\u00060\u021a\n0\r0\u000e0\u021b\u00030\u00030\u00031\u00031\u0003', + '2\u00032\u00033\u00033\u00053\u0226\n3\u00034\u00034\u00034\u00035\u0003', + '5\u00035\u00036\u00036\u00037\u00037\u00038\u00038\u00038\u00058\u0235', + '\n8\u00039\u00039\u0003:\u0003:\u0003:\u0003:\u0003:\u0003:\u0003:\u0003', + ':\u0003:\u0005:\u0242\n:\u0003:\u0002\u0004.<;\u0002\u0004\u0006\b\n', + '\f\u000e\u0010\u0012\u0014\u0016\u0018\u001a\u001c\u001e "$&(*,.02', + '468:<>@BDFHJLNPRTVXZ\\^`bdfhjlnpr\u0002\u001c\u0003\u0002;<\u0005\u0002', + ':>__\u0004\u0002??``\u0004\u0002', + '@@aa\u0004\u0002DDcc\u0004\u0002AAdd\u0004\u0002::bb\u0003\u0002\u0005', + " \u0003\u0002!#\u0003\u0002$&\u0003\u0002')\u0003\u0002*,\u0003\u0002", + '-.\u0003\u0002/0\u0003\u000213\u0003\u000246\u0003\u000279\u0002\u027b', + '\u0002t\u0003\u0002\u0002\u0002\u0004w\u0003\u0002\u0002\u0002\u0006', + '\u0080\u0003\u0002\u0002\u0002\b\u0088\u0003\u0002\u0002\u0002\n\u009b', + '\u0003\u0002\u0002\u0002\f\u00a9\u0003\u0002\u0002\u0002\u000e\u00ab', + '\u0003\u0002\u0002\u0002\u0010\u00b7\u0003\u0002\u0002\u0002\u0012\u00c4', + '\u0003\u0002\u0002\u0002\u0014\u00c6\u0003\u0002\u0002\u0002\u0016\u00d4', + '\u0003\u0002\u0002\u0002\u0018\u00d8\u0003\u0002\u0002\u0002\u001a\u00da', + '\u0003\u0002\u0002\u0002\u001c\u00df\u0003\u0002\u0002\u0002\u001e\u00e2', + '\u0003\u0002\u0002\u0002 \u00f9\u0003\u0002\u0002\u0002"\u0100\u0003', + '\u0002\u0002\u0002$\u0114\u0003\u0002\u0002\u0002&\u0116\u0003\u0002', + '\u0002\u0002(\u012e\u0003\u0002\u0002\u0002*\u0130\u0003\u0002\u0002', + '\u0002,\u0134\u0003\u0002\u0002\u0002.\u0140\u0003\u0002\u0002\u0002', + '0\u0154\u0003\u0002\u0002\u00022\u0156\u0003\u0002\u0002\u00024\u0159', + '\u0003\u0002\u0002\u00026\u016c\u0003\u0002\u0002\u00028\u0172\u0003', + '\u0002\u0002\u0002:\u0174\u0003\u0002\u0002\u0002<\u0182\u0003\u0002', + '\u0002\u0002>\u01b3\u0003\u0002\u0002\u0002@\u01b7\u0003\u0002\u0002', + '\u0002B\u01cc\u0003\u0002\u0002\u0002D\u01d0\u0003\u0002\u0002\u0002', + 'F\u01df\u0003\u0002\u0002\u0002H\u01e3\u0003\u0002\u0002\u0002J\u01e7', + '\u0003\u0002\u0002\u0002L\u01ed\u0003\u0002\u0002\u0002N\u01f4\u0003', + '\u0002\u0002\u0002P\u01f6\u0003\u0002\u0002\u0002R\u01fc\u0003\u0002', + '\u0002\u0002T\u0205\u0003\u0002\u0002\u0002V\u0209\u0003\u0002\u0002', + '\u0002X\u020d\u0003\u0002\u0002\u0002Z\u0211\u0003\u0002\u0002\u0002', + '\\\u0215\u0003\u0002\u0002\u0002^\u0217\u0003\u0002\u0002\u0002`\u021f', + '\u0003\u0002\u0002\u0002b\u0221\u0003\u0002\u0002\u0002d\u0225\u0003', + '\u0002\u0002\u0002f\u0227\u0003\u0002\u0002\u0002h\u022a\u0003\u0002', + '\u0002\u0002j\u022d\u0003\u0002\u0002\u0002l\u022f\u0003\u0002\u0002', + '\u0002n\u0234\u0003\u0002\u0002\u0002p\u0236\u0003\u0002\u0002\u0002', + 'r\u0241\u0003\u0002\u0002\u0002tu\u0005\u0004\u0003\u0002uv\u0007\u0002', + '\u0002\u0003v\u0003\u0003\u0002\u0002\u0002wz\u0005\b\u0005\u0002xy', + '\u0007H\u0002\u0002y{\u0005\n\u0006\u0002zx\u0003\u0002\u0002\u0002', + 'z{\u0003\u0002\u0002\u0002{~\u0003\u0002\u0002\u0002|}\u0007I\u0002', + '\u0002}\u007f\u0005j6\u0002~|\u0003\u0002\u0002\u0002~\u007f\u0003\u0002', + '\u0002\u0002\u007f\u0005\u0003\u0002\u0002\u0002\u0080\u0085\u0005j', + '6\u0002\u0081\u0082\u0007H\u0002\u0002\u0082\u0084\u0005\u000e\b\u0002', + '\u0083\u0081\u0003\u0002\u0002\u0002\u0084\u0087\u0003\u0002\u0002\u0002', + '\u0085\u0083\u0003\u0002\u0002\u0002\u0085\u0086\u0003\u0002\u0002\u0002', + '\u0086\u0007\u0003\u0002\u0002\u0002\u0087\u0085\u0003\u0002\u0002\u0002', + '\u0088\u008d\u0005\u0010\t\u0002\u0089\u008a\u0007H\u0002\u0002\u008a', + '\u008c\u0005\u0010\t\u0002\u008b\u0089\u0003\u0002\u0002\u0002\u008c', + '\u008f\u0003\u0002\u0002\u0002\u008d\u008b\u0003\u0002\u0002\u0002\u008d', + '\u008e\u0003\u0002\u0002\u0002\u008e\u0092\u0003\u0002\u0002\u0002\u008f', + '\u008d\u0003\u0002\u0002\u0002\u0090\u0091\u0007H\u0002\u0002\u0091', + '\u0093\u0005,\u0017\u0002\u0092\u0090\u0003\u0002\u0002\u0002\u0092', + '\u0093\u0003\u0002\u0002\u0002\u0093\u0098\u0003\u0002\u0002\u0002\u0094', + '\u0095\u0007H\u0002\u0002\u0095\u0097\u0005\f\u0007\u0002\u0096\u0094', + '\u0003\u0002\u0002\u0002\u0097\u009a\u0003\u0002\u0002\u0002\u0098\u0096', + '\u0003\u0002\u0002\u0002\u0098\u0099\u0003\u0002\u0002\u0002\u0099\t', + '\u0003\u0002\u0002\u0002\u009a\u0098\u0003\u0002\u0002\u0002\u009b\u00a0', + '\u0005\u001e\u0010\u0002\u009c\u009d\u0007H\u0002\u0002\u009d\u009f', + '\u0005\f\u0007\u0002\u009e\u009c\u0003\u0002\u0002\u0002\u009f\u00a2', + '\u0003\u0002\u0002\u0002\u00a0\u009e\u0003\u0002\u0002\u0002\u00a0\u00a1', + '\u0003\u0002\u0002\u0002\u00a1\u000b\u0003\u0002\u0002\u0002\u00a2\u00a0', + '\u0003\u0002\u0002\u0002\u00a3\u00aa\u0005$\u0013\u0002\u00a4\u00aa', + '\u0005(\u0015\u0002\u00a5\u00aa\u00052\u001a\u0002\u00a6\u00aa\u0005', + '*\u0016\u0002\u00a7\u00aa\u00054\u001b\u0002\u00a8\u00aa\u00058\u001d', + '\u0002\u00a9\u00a3\u0003\u0002\u0002\u0002\u00a9\u00a4\u0003\u0002\u0002', + '\u0002\u00a9\u00a5\u0003\u0002\u0002\u0002\u00a9\u00a6\u0003\u0002\u0002', + '\u0002\u00a9\u00a7\u0003\u0002\u0002\u0002\u00a9\u00a8\u0003\u0002\u0002', + '\u0002\u00aa\r\u0003\u0002\u0002\u0002\u00ab\u00b4\u0005l7\u0002\u00ac', + '\u00ad\u0005n8\u0002\u00ad\u00ae\u0007W\u0002\u0002\u00ae\u00b0\u0005', + 'V,\u0002\u00af\u00b1\u0007E\u0002\u0002\u00b0\u00af\u0003\u0002\u0002', + '\u0002\u00b0\u00b1\u0003\u0002\u0002\u0002\u00b1\u00b3\u0003\u0002\u0002', + '\u0002\u00b2\u00ac\u0003\u0002\u0002\u0002\u00b3\u00b6\u0003\u0002\u0002', + '\u0002\u00b4\u00b2\u0003\u0002\u0002\u0002\u00b4\u00b5\u0003\u0002\u0002', + '\u0002\u00b5\u000f\u0003\u0002\u0002\u0002\u00b6\u00b4\u0003\u0002\u0002', + '\u0002\u00b7\u00b8\u0007\u0005\u0002\u0002\u00b8\u00b9\u0005T+\u0002', + '\u00b9\u00ba\u0007\u0006\u0002\u0002\u00ba\u00bb\u0007W\u0002\u0002', + '\u00bb\u00bc\u0005\u0012\n\u0002\u00bc\u00bd\u0007\u0007\u0002\u0002', + '\u00bd\u00be\u0007W\u0002\u0002\u00be\u00bf\u0005\u0012\n\u0002\u00bf', + '\u0011\u0003\u0002\u0002\u0002\u00c0\u00c5\u0005\u0014\u000b\u0002\u00c1', + '\u00c5\u0005\u0016\f\u0002\u00c2\u00c5\u0005\u001a\u000e\u0002\u00c3', + '\u00c5\u0005\u0018\r\u0002\u00c4\u00c0\u0003\u0002\u0002\u0002\u00c4', + '\u00c1\u0003\u0002\u0002\u0002\u00c4\u00c2\u0003\u0002\u0002\u0002\u00c4', + '\u00c3\u0003\u0002\u0002\u0002\u00c5\u0013\u0003\u0002\u0002\u0002\u00c6', + '\u00c7\u0007\b\u0002\u0002\u00c7\u0015\u0003\u0002\u0002\u0002\u00c8', + '\u00ca\u0007Q\u0002\u0002\u00c9\u00cb\t\u0002\u0002\u0002\u00ca\u00c9', + '\u0003\u0002\u0002\u0002\u00ca\u00cb\u0003\u0002\u0002\u0002\u00cb\u00cc', + '\u0003\u0002\u0002\u0002\u00cc\u00d5\u0005r:\u0002\u00cd\u00cf\u0007', + 'P\u0002\u0002\u00ce\u00cd\u0003\u0002\u0002\u0002\u00ce\u00cf\u0003', + '\u0002\u0002\u0002\u00cf\u00d1\u0003\u0002\u0002\u0002\u00d0\u00d2\t', + '\u0002\u0002\u0002\u00d1\u00d0\u0003\u0002\u0002\u0002\u00d1\u00d2\u0003', + '\u0002\u0002\u0002\u00d2\u00d3\u0003\u0002\u0002\u0002\u00d3\u00d5\u0005', + 'r:\u0002\u00d4\u00c8\u0003\u0002\u0002\u0002\u00d4\u00ce\u0003\u0002', + '\u0002\u0002\u00d5\u0017\u0003\u0002\u0002\u0002\u00d6\u00d9\u0005N', + '(\u0002\u00d7\u00d9\u0005\u001c\u000f\u0002\u00d8\u00d6\u0003\u0002', + '\u0002\u0002\u00d8\u00d7\u0003\u0002\u0002\u0002\u00d9\u0019\u0003\u0002', + '\u0002\u0002\u00da\u00db\t\u0002\u0002\u0002\u00db\u001b\u0003\u0002', + '\u0002\u0002\u00dc\u00de\t\u0003\u0002\u0002\u00dd\u00dc\u0003\u0002', + '\u0002\u0002\u00de\u00e1\u0003\u0002\u0002\u0002\u00df\u00dd\u0003\u0002', + '\u0002\u0002\u00df\u00e0\u0003\u0002\u0002\u0002\u00e0\u001d\u0003\u0002', + '\u0002\u0002\u00e1\u00df\u0003\u0002\u0002\u0002\u00e2\u00e3\u0007\u000f', + '\u0002\u0002\u00e3\u00e8\u0005 \u0011\u0002\u00e4\u00e5\u0007E\u0002', + '\u0002\u00e5\u00e7\u0005 \u0011\u0002\u00e6\u00e4\u0003\u0002\u0002', + '\u0002\u00e7\u00ea\u0003\u0002\u0002\u0002\u00e8\u00e6\u0003\u0002\u0002', + '\u0002\u00e8\u00e9\u0003\u0002\u0002\u0002\u00e9\u00f7\u0003\u0002\u0002', + '\u0002\u00ea\u00e8\u0003\u0002\u0002\u0002\u00eb\u00ed\u0007\u0019\u0002', + '\u0002\u00ec\u00eb\u0003\u0002\u0002\u0002\u00ec\u00ed\u0003\u0002\u0002', + '\u0002\u00ed\u00ee\u0003\u0002\u0002\u0002\u00ee\u00ef\u0007\u001a\u0002', + '\u0002\u00ef\u00f4\u0005"\u0012\u0002\u00f0\u00f1\u0007E\u0002\u0002', + '\u00f1\u00f3\u0005"\u0012\u0002\u00f2\u00f0\u0003\u0002\u0002\u0002', + '\u00f3\u00f6\u0003\u0002\u0002\u0002\u00f4\u00f2\u0003\u0002\u0002\u0002', + '\u00f4\u00f5\u0003\u0002\u0002\u0002\u00f5\u00f8\u0003\u0002\u0002\u0002', + '\u00f6\u00f4\u0003\u0002\u0002\u0002\u00f7\u00ec\u0003\u0002\u0002\u0002', + '\u00f7\u00f8\u0003\u0002\u0002\u0002\u00f8\u001f\u0003\u0002\u0002\u0002', + '\u00f9\u00fc\u0005:\u001e\u0002\u00fa\u00fb\u0007\u001b\u0002\u0002', + '\u00fb\u00fd\u0005X-\u0002\u00fc\u00fa\u0003\u0002\u0002\u0002\u00fc', + '\u00fd\u0003\u0002\u0002\u0002\u00fd!\u0003\u0002\u0002\u0002\u00fe', + '\u0101\u0005V,\u0002\u00ff\u0101\u0005&\u0014\u0002\u0100\u00fe\u0003', + '\u0002\u0002\u0002\u0100\u00ff\u0003\u0002\u0002\u0002\u0101#\u0003', + '\u0002\u0002\u0002\u0102\u0103\u0007\f\u0002\u0002\u0103\u0108\u0005', + '&\u0014\u0002\u0104\u0105\u0007E\u0002\u0002\u0105\u0107\u0005&\u0014', + '\u0002\u0106\u0104\u0003\u0002\u0002\u0002\u0107\u010a\u0003\u0002\u0002', + '\u0002\u0108\u0106\u0003\u0002\u0002\u0002\u0108\u0109\u0003\u0002\u0002', + '\u0002\u0109\u0115\u0003\u0002\u0002\u0002\u010a\u0108\u0003\u0002\u0002', + '\u0002\u010b\u010c\u0007\r\u0002\u0002\u010c\u0111\u0005&\u0014\u0002', + '\u010d\u010e\u0007E\u0002\u0002\u010e\u0110\u0005&\u0014\u0002\u010f', + '\u010d\u0003\u0002\u0002\u0002\u0110\u0113\u0003\u0002\u0002\u0002\u0111', + '\u010f\u0003\u0002\u0002\u0002\u0111\u0112\u0003\u0002\u0002\u0002\u0112', + '\u0115\u0003\u0002\u0002\u0002\u0113\u0111\u0003\u0002\u0002\u0002\u0114', + '\u0102\u0003\u0002\u0002\u0002\u0114\u010b\u0003\u0002\u0002\u0002\u0115', + '%\u0003\u0002\u0002\u0002\u0116\u0119\u0005:\u001e\u0002\u0117\u0118', + '\u0007\u001b\u0002\u0002\u0118\u011a\u0005X-\u0002\u0119\u0117\u0003', + "\u0002\u0002\u0002\u0119\u011a\u0003\u0002\u0002\u0002\u011a'\u0003", + '\u0002\u0002\u0002\u011b\u011d\u0007\n\u0002\u0002\u011c\u011e\u0005', + 'V,\u0002\u011d\u011c\u0003\u0002\u0002\u0002\u011d\u011e\u0003\u0002', + '\u0002\u0002\u011e\u011f\u0003\u0002\u0002\u0002\u011f\u0120\u0005L', + "'\u0002\u0120\u0121\u0007\u001b\u0002\u0002\u0121\u0126\u0005X-\u0002", + '\u0122\u0123\u0007E\u0002\u0002\u0123\u0125\u0005X-\u0002\u0124\u0122', + '\u0003\u0002\u0002\u0002\u0125\u0128\u0003\u0002\u0002\u0002\u0126\u0124', + '\u0003\u0002\u0002\u0002\u0126\u0127\u0003\u0002\u0002\u0002\u0127\u012f', + '\u0003\u0002\u0002\u0002\u0128\u0126\u0003\u0002\u0002\u0002\u0129\u012b', + '\u0007\n\u0002\u0002\u012a\u012c\u0005V,\u0002\u012b\u012a\u0003\u0002', + '\u0002\u0002\u012b\u012c\u0003\u0002\u0002\u0002\u012c\u012d\u0003\u0002', + '\u0002\u0002\u012d\u012f\u0005P)\u0002\u012e\u011b\u0003\u0002\u0002', + '\u0002\u012e\u0129\u0003\u0002\u0002\u0002\u012f)\u0003\u0002\u0002', + '\u0002\u0130\u0131\u0007\u000b\u0002\u0002\u0131\u0132\u0005.\u0018', + '\u0002\u0132+\u0003\u0002\u0002\u0002\u0133\u0135\u0007g\u0002\u0002', + '\u0134\u0133\u0003\u0002\u0002\u0002\u0134\u0135\u0003\u0002\u0002\u0002', + '\u0135\u0136\u0003\u0002\u0002\u0002\u0136\u0137\u0005.\u0018\u0002', + '\u0137-\u0003\u0002\u0002\u0002\u0138\u0139\b\u0018\u0001\u0002\u0139', + '\u013a\u0007l\u0002\u0002\u013a\u0141\u0005.\u0018\u0007\u013b\u013c', + '\u0007h\u0002\u0002\u013c\u013d\u0005.\u0018\u0002\u013d\u013e\u0007', + 'i\u0002\u0002\u013e\u0141\u0003\u0002\u0002\u0002\u013f\u0141\u0005', + '0\u0019\u0002\u0140\u0138\u0003\u0002\u0002\u0002\u0140\u013b\u0003', + '\u0002\u0002\u0002\u0140\u013f\u0003\u0002\u0002\u0002\u0141\u014c\u0003', + '\u0002\u0002\u0002\u0142\u0144\f\u0005\u0002\u0002\u0143\u0145\u0007', + 'j\u0002\u0002\u0144\u0143\u0003\u0002\u0002\u0002\u0144\u0145\u0003', + '\u0002\u0002\u0002\u0145\u0146\u0003\u0002\u0002\u0002\u0146\u014b\u0005', + '.\u0018\u0006\u0147\u0148\f\u0004\u0002\u0002\u0148\u0149\u0007k\u0002', + '\u0002\u0149\u014b\u0005.\u0018\u0005\u014a\u0142\u0003\u0002\u0002', + '\u0002\u014a\u0147\u0003\u0002\u0002\u0002\u014b\u014e\u0003\u0002\u0002', + '\u0002\u014c\u014a\u0003\u0002\u0002\u0002\u014c\u014d\u0003\u0002\u0002', + '\u0002\u014d/\u0003\u0002\u0002\u0002\u014e\u014c\u0003\u0002\u0002', + '\u0002\u014f\u0155\u0007q\u0002\u0002\u0150\u0155\u0007m\u0002\u0002', + '\u0151\u0155\u0007n\u0002\u0002\u0152\u0155\u0007o\u0002\u0002\u0153', + '\u0155\u0007p\u0002\u0002\u0154\u014f\u0003\u0002\u0002\u0002\u0154', + '\u0150\u0003\u0002\u0002\u0002\u0154\u0151\u0003\u0002\u0002\u0002\u0154', + '\u0152\u0003\u0002\u0002\u0002\u0154\u0153\u0003\u0002\u0002\u0002\u0155', + '1\u0003\u0002\u0002\u0002\u0156\u0157\u0007\u000e\u0002\u0002\u0157', + '\u0158\u0005:\u001e\u0002\u01583\u0003\u0002\u0002\u0002\u0159\u015b', + '\t\u0004\u0002\u0002\u015a\u015c\u0007\u001a\u0002\u0002\u015b\u015a', + '\u0003\u0002\u0002\u0002\u015b\u015c\u0003\u0002\u0002\u0002\u015c\u015d', + '\u0003\u0002\u0002\u0002\u015d\u0162\u00056\u001c\u0002\u015e\u015f', + '\u0007E\u0002\u0002\u015f\u0161\u00056\u001c\u0002\u0160\u015e\u0003', + '\u0002\u0002\u0002\u0161\u0164\u0003\u0002\u0002\u0002\u0162\u0160\u0003', + '\u0002\u0002\u0002\u0162\u0163\u0003\u0002\u0002\u0002\u01635\u0003', + '\u0002\u0002\u0002\u0164\u0162\u0003\u0002\u0002\u0002\u0165\u0166\u0005', + 'V,\u0002\u0166\u0167\u0007\u0013\u0002\u0002\u0167\u016d\u0003\u0002', + '\u0002\u0002\u0168\u016a\u0005V,\u0002\u0169\u016b\u0007\u0012\u0002', + '\u0002\u016a\u0169\u0003\u0002\u0002\u0002\u016a\u016b\u0003\u0002\u0002', + '\u0002\u016b\u016d\u0003\u0002\u0002\u0002\u016c\u0165\u0003\u0002\u0002', + '\u0002\u016c\u0168\u0003\u0002\u0002\u0002\u016d7\u0003\u0002\u0002', + '\u0002\u016e\u016f\t\u0005\u0002\u0002\u016f\u0173\u0007;\u0002\u0002', + '\u0170\u0171\u0007\u0016\u0002\u0002\u0171\u0173\u0007;\u0002\u0002', + '\u0172\u016e\u0003\u0002\u0002\u0002\u0172\u0170\u0003\u0002\u0002\u0002', + '\u01739\u0003\u0002\u0002\u0002\u0174\u0175\u0005<\u001f\u0002\u0175', + ';\u0003\u0002\u0002\u0002\u0176\u0177\b\u001f\u0001\u0002\u0177\u0178', + '\u0007F\u0002\u0002\u0178\u0179\u0005<\u001f\u0002\u0179\u017a\u0007', + 'G\u0002\u0002\u017a\u0183\u0003\u0002\u0002\u0002\u017b\u017c\t\u0006', + '\u0002\u0002\u017c\u0183\u0005<\u001f\u000e\u017d\u017e\u0007Q\u0002', + '\u0002\u017e\u0183\u0005<\u001f\r\u017f\u0180\u0007P\u0002\u0002\u0180', + '\u0183\u0005<\u001f\f\u0181\u0183\u0005> \u0002\u0182\u0176\u0003\u0002', + '\u0002\u0002\u0182\u017b\u0003\u0002\u0002\u0002\u0182\u017d\u0003\u0002', + '\u0002\u0002\u0182\u017f\u0003\u0002\u0002\u0002\u0182\u0181\u0003\u0002', + '\u0002\u0002\u0183\u01ac\u0003\u0002\u0002\u0002\u0184\u0185\f\u000f', + '\u0002\u0002\u0185\u0186\u0007K\u0002\u0002\u0186\u01ab\u0005<\u001f', + '\u000f\u0187\u0188\f\u000b\u0002\u0002\u0188\u0189\t\u0007\u0002\u0002', + '\u0189\u01ab\u0005<\u001f\f\u018a\u018b\f\n\u0002\u0002\u018b\u018c', + '\t\b\u0002\u0002\u018c\u01ab\u0005<\u001f\u000b\u018d\u018e\f\t\u0002', + '\u0002\u018e\u018f\t\t\u0002\u0002\u018f\u01ab\u0005<\u001f\n\u0190', + '\u0195\f\b\u0002\u0002\u0191\u0192\u0007W\u0002\u0002\u0192\u0196\u0007', + 'W\u0002\u0002\u0193\u0196\u0007W\u0002\u0002\u0194\u0196\u0007X\u0002', + '\u0002\u0195\u0191\u0003\u0002\u0002\u0002\u0195\u0193\u0003\u0002\u0002', + '\u0002\u0195\u0194\u0003\u0002\u0002\u0002\u0196\u0197\u0003\u0002\u0002', + '\u0002\u0197\u01ab\u0005<\u001f\t\u0198\u0199\f\u0005\u0002\u0002\u0199', + '\u019a\u0007\u001c\u0002\u0002\u019a\u01ab\u0005<\u001f\u0006\u019b', + '\u019c\f\u0004\u0002\u0002\u019c\u019d\u0007\u001d\u0002\u0002\u019d', + '\u01ab\u0005<\u001f\u0005\u019e\u01a0\f\u0007\u0002\u0002\u019f\u01a1', + '\u0007\u001e\u0002\u0002\u01a0\u019f\u0003\u0002\u0002\u0002\u01a0\u01a1', + '\u0003\u0002\u0002\u0002\u01a1\u01a2\u0003\u0002\u0002\u0002\u01a2\u01a3', + '\t\n\u0002\u0002\u01a3\u01ab\u0005@!\u0002\u01a4\u01a6\f\u0006\u0002', + '\u0002\u01a5\u01a7\u0007\u001e\u0002\u0002\u01a6\u01a5\u0003\u0002\u0002', + '\u0002\u01a6\u01a7\u0003\u0002\u0002\u0002\u01a7\u01a8\u0003\u0002\u0002', + '\u0002\u01a8\u01a9\u0007\u0018\u0002\u0002\u01a9\u01ab\u0005F$\u0002', + '\u01aa\u0184\u0003\u0002\u0002\u0002\u01aa\u0187\u0003\u0002\u0002\u0002', + '\u01aa\u018a\u0003\u0002\u0002\u0002\u01aa\u018d\u0003\u0002\u0002\u0002', + '\u01aa\u0190\u0003\u0002\u0002\u0002\u01aa\u0198\u0003\u0002\u0002\u0002', + '\u01aa\u019b\u0003\u0002\u0002\u0002\u01aa\u019e\u0003\u0002\u0002\u0002', + '\u01aa\u01a4\u0003\u0002\u0002\u0002\u01ab\u01ae\u0003\u0002\u0002\u0002', + '\u01ac\u01aa\u0003\u0002\u0002\u0002\u01ac\u01ad\u0003\u0002\u0002\u0002', + '\u01ad=\u0003\u0002\u0002\u0002\u01ae\u01ac\u0003\u0002\u0002\u0002', + '\u01af\u01b4\u0005V,\u0002\u01b0\u01b4\u0005J&\u0002\u01b1\u01b4\u0005', + 'N(\u0002\u01b2\u01b4\u0005B"\u0002\u01b3\u01af\u0003\u0002\u0002\u0002', + '\u01b3\u01b0\u0003\u0002\u0002\u0002\u01b3\u01b1\u0003\u0002\u0002\u0002', + '\u01b3\u01b2\u0003\u0002\u0002\u0002\u01b4?\u0003\u0002\u0002\u0002', + '\u01b5\u01b8\u0005P)\u0002\u01b6\u01b8\u0005R*\u0002\u01b7\u01b5\u0003', + '\u0002\u0002\u0002\u01b7\u01b6\u0003\u0002\u0002\u0002\u01b8A\u0003', + '\u0002\u0002\u0002\u01b9\u01ba\u0005l7\u0002\u01ba\u01bb\u0007F\u0002', + '\u0002\u01bb\u01c0\u0005D#\u0002\u01bc\u01bd\u0007E\u0002\u0002\u01bd', + '\u01bf\u0005D#\u0002\u01be\u01bc\u0003\u0002\u0002\u0002\u01bf\u01c2', + '\u0003\u0002\u0002\u0002\u01c0\u01be\u0003\u0002\u0002\u0002\u01c0\u01c1', + '\u0003\u0002\u0002\u0002\u01c1\u01c3\u0003\u0002\u0002\u0002\u01c2\u01c0', + '\u0003\u0002\u0002\u0002\u01c3\u01c4\u0007G\u0002\u0002\u01c4\u01cd', + '\u0003\u0002\u0002\u0002\u01c5\u01c6\u0005l7\u0002\u01c6\u01c8\u0007', + 'F\u0002\u0002\u01c7\u01c9\u0007M\u0002\u0002\u01c8\u01c7\u0003\u0002', + '\u0002\u0002\u01c8\u01c9\u0003\u0002\u0002\u0002\u01c9\u01ca\u0003\u0002', + '\u0002\u0002\u01ca\u01cb\u0007G\u0002\u0002\u01cb\u01cd\u0003\u0002', + '\u0002\u0002\u01cc\u01b9\u0003\u0002\u0002\u0002\u01cc\u01c5\u0003\u0002', + '\u0002\u0002\u01cdC\u0003\u0002\u0002\u0002\u01ce\u01d1\u0005\u0016', + '\f\u0002\u01cf\u01d1\u0005<\u001f\u0002\u01d0\u01ce\u0003\u0002\u0002', + '\u0002\u01d0\u01cf\u0003\u0002\u0002\u0002\u01d1E\u0003\u0002\u0002', + '\u0002\u01d2\u01d3\u0007Y\u0002\u0002\u01d3\u01d8\u0005H%\u0002\u01d4', + '\u01d5\u0007E\u0002\u0002\u01d5\u01d7\u0005H%\u0002\u01d6\u01d4\u0003', + '\u0002\u0002\u0002\u01d7\u01da\u0003\u0002\u0002\u0002\u01d8\u01d6\u0003', + '\u0002\u0002\u0002\u01d8\u01d9\u0003\u0002\u0002\u0002\u01d9\u01db\u0003', + '\u0002\u0002\u0002\u01da\u01d8\u0003\u0002\u0002\u0002\u01db\u01dc\u0007', + 'Z\u0002\u0002\u01dc\u01e0\u0003\u0002\u0002\u0002\u01dd\u01de\u0007', + 'Y\u0002\u0002\u01de\u01e0\u0007Z\u0002\u0002\u01df\u01d2\u0003\u0002', + '\u0002\u0002\u01df\u01dd\u0003\u0002\u0002\u0002\u01e0G\u0003\u0002', + "\u0002\u0002\u01e1\u01e4\u0005L'\u0002\u01e2\u01e4\u0005J&\u0002\u01e3", + '\u01e1\u0003\u0002\u0002\u0002\u01e3\u01e2\u0003\u0002\u0002\u0002\u01e4', + 'I\u0003\u0002\u0002\u0002\u01e5\u01e8\u0007<\u0002\u0002\u01e6\u01e8', + '\u0007;\u0002\u0002\u01e7\u01e5\u0003\u0002\u0002\u0002\u01e7\u01e6', + '\u0003\u0002\u0002\u0002\u01e8K\u0003\u0002\u0002\u0002\u01e9\u01ee', + '\t\u000b\u0002\u0002\u01ea\u01ee\t\f\u0002\u0002\u01eb\u01ee\t\r\u0002', + '\u0002\u01ec\u01ee\t\u000e\u0002\u0002\u01ed\u01e9\u0003\u0002\u0002', + '\u0002\u01ed\u01ea\u0003\u0002\u0002\u0002\u01ed\u01eb\u0003\u0002\u0002', + '\u0002\u01ed\u01ec\u0003\u0002\u0002\u0002\u01eeM\u0003\u0002\u0002', + '\u0002\u01ef\u01f5\t\u000b\u0002\u0002\u01f0\u01f5\t\f\u0002\u0002\u01f1', + '\u01f5\t\r\u0002\u0002\u01f2\u01f5\t\u000e\u0002\u0002\u01f3\u01f5\u0007', + ':\u0002\u0002\u01f4\u01ef\u0003\u0002\u0002\u0002\u01f4\u01f0\u0003', + '\u0002\u0002\u0002\u01f4\u01f1\u0003\u0002\u0002\u0002\u01f4\u01f2\u0003', + '\u0002\u0002\u0002\u01f4\u01f3\u0003\u0002\u0002\u0002\u01f5O\u0003', + '\u0002\u0002\u0002\u01f6\u01f7\u0007]\u0002\u0002\u01f7Q\u0003\u0002', + '\u0002\u0002\u01f8\u01fd\u0007^\u0002\u0002\u01f9\u01fd\u0007_\u0002', + '\u0002\u01fa\u01fd\u0007`\u0002\u0002\u01fb\u01fd\u0007a\u0002\u0002', + '\u01fc\u01f8\u0003\u0002\u0002\u0002\u01fc\u01f9\u0003\u0002\u0002\u0002', + '\u01fc\u01fa\u0003\u0002\u0002\u0002\u01fc\u01fb\u0003\u0002\u0002\u0002', + '\u01fdS\u0003\u0002\u0002\u0002\u01fe\u0206\u0007:\u0002\u0002\u01ff', + '\u0206\u0005p9\u0002\u0200\u0206\u0005r:\u0002\u0201\u0206\t\u000b\u0002', + '\u0002\u0202\u0206\t\f\u0002\u0002\u0203\u0206\t\r\u0002\u0002\u0204', + '\u0206\t\u000e\u0002\u0002\u0205\u01fe\u0003\u0002\u0002\u0002\u0205', + '\u01ff\u0003\u0002\u0002\u0002\u0205\u0200\u0003\u0002\u0002\u0002\u0205', + '\u0201\u0003\u0002\u0002\u0002\u0205\u0202\u0003\u0002\u0002\u0002\u0205', + '\u0203\u0003\u0002\u0002\u0002\u0205\u0204\u0003\u0002\u0002\u0002\u0206', + 'U\u0003\u0002\u0002\u0002\u0207\u020a\u0005Z.\u0002\u0208\u020a\u0005', + 'd3\u0002\u0209\u0207\u0003\u0002\u0002\u0002\u0209\u0208\u0003\u0002', + '\u0002\u0002\u020aW\u0003\u0002\u0002\u0002\u020b\u020e\u0005Z.\u0002', + '\u020c\u020e\u0005d3\u0002\u020d\u020b\u0003\u0002\u0002\u0002\u020d', + '\u020c\u0003\u0002\u0002\u0002\u020eY\u0003\u0002\u0002\u0002\u020f', + '\u0212\u0005\\/\u0002\u0210\u0212\u0005b2\u0002\u0211\u020f\u0003\u0002', + '\u0002\u0002\u0211\u0210\u0003\u0002\u0002\u0002\u0212[\u0003\u0002', + '\u0002\u0002\u0213\u0216\u0005`1\u0002\u0214\u0216\u0005^0\u0002\u0215', + '\u0213\u0003\u0002\u0002\u0002\u0215\u0214\u0003\u0002\u0002\u0002\u0216', + ']\u0003\u0002\u0002\u0002\u0217\u0219\t\u000f\u0002\u0002\u0218\u021a', + '\t\u000f\u0002\u0002\u0219\u0218\u0003\u0002\u0002\u0002\u021a\u021b', + '\u0003\u0002\u0002\u0002\u021b\u0219\u0003\u0002\u0002\u0002\u021b\u021c', + '\u0003\u0002\u0002\u0002\u021c\u021d\u0003\u0002\u0002\u0002\u021d\u021e', + '\u0005n8\u0002\u021e_\u0003\u0002\u0002\u0002\u021f\u0220\u0005n8\u0002', + '\u0220a\u0003\u0002\u0002\u0002\u0221\u0222\t\u0010\u0002\u0002\u0222', + 'c\u0003\u0002\u0002\u0002\u0223\u0226\u0005f4\u0002\u0224\u0226\u0005', + 'h5\u0002\u0225\u0223\u0003\u0002\u0002\u0002\u0225\u0224\u0003\u0002', + '\u0002\u0002\u0226e\u0003\u0002\u0002\u0002\u0227\u0228\t\u000f\u0002', + '\u0002\u0228\u0229\u0005n8\u0002\u0229g\u0003\u0002\u0002\u0002\u022a', + '\u022b\t\u000f\u0002\u0002\u022b\u022c\t\u0010\u0002\u0002\u022ci\u0003', + '\u0002\u0002\u0002\u022d\u022e\u0005n8\u0002\u022ek\u0003\u0002\u0002', + '\u0002\u022f\u0230\u0005n8\u0002\u0230m\u0003\u0002\u0002\u0002\u0231', + '\u0235\t\u0011\u0002\u0002\u0232\u0235\u0005p9\u0002\u0233\u0235\u0005', + 'r:\u0002\u0234\u0231\u0003\u0002\u0002\u0002\u0234\u0232\u0003\u0002', + '\u0002\u0002\u0234\u0233\u0003\u0002\u0002\u0002\u0235o\u0003\u0002', + '\u0002\u0002\u0236\u0237\t\u0012\u0002\u0002\u0237q\u0003\u0002\u0002', + '\u0002\u0238\u0242\t\u0013\u0002\u0002\u0239\u0242\t\u0014\u0002\u0002', + '\u023a\u0242\t\u0015\u0002\u0002\u023b\u0242\t\u0016\u0002\u0002\u023c', + '\u0242\t\u0017\u0002\u0002\u023d\u0242\t\u0018\u0002\u0002\u023e\u0242', + '\t\u0019\u0002\u0002\u023f\u0242\t\u001a\u0002\u0002\u0240\u0242\t\u001b', + '\u0002\u0002\u0241\u0238\u0003\u0002\u0002\u0002\u0241\u0239\u0003\u0002', + '\u0002\u0002\u0241\u023a\u0003\u0002\u0002\u0002\u0241\u023b\u0003\u0002', + '\u0002\u0002\u0241\u023c\u0003\u0002\u0002\u0002\u0241\u023d\u0003\u0002', + '\u0002\u0002\u0241\u023e\u0003\u0002\u0002\u0002\u0241\u023f\u0003\u0002', + '\u0002\u0002\u0241\u0240\u0003\u0002\u0002\u0002\u0242s\u0003\u0002', + '\u0002\u0002Hz~\u0085\u008d\u0092\u0098\u00a0\u00a9\u00b0\u00b4\u00c4', + '\u00ca\u00ce\u00d1\u00d4\u00d8\u00df\u00e8\u00ec\u00f4\u00f7\u00fc\u0100', + '\u0108\u0111\u0114\u0119\u011d\u0126\u012b\u012e\u0134\u0140\u0144\u014a', + '\u014c\u0154\u015b\u0162\u016a\u016c\u0172\u0182\u0195\u01a0\u01a6\u01aa', + '\u01ac\u01b3\u01b7\u01c0\u01c8\u01cc\u01d0\u01d8\u01df\u01e3\u01e7\u01ed', + '\u01f4\u01fc\u0205\u0209\u020d\u0211\u0215\u021b\u0225\u0234\u0241', +].join(''); + +var atn = new antlr4.atn.ATNDeserializer().deserialize(serializedATN); + +var decisionsToDFA = atn.decisionToState.map(function (ds, index) { + return new antlr4.dfa.DFA(ds, index); +}); + +var sharedContextCache = new antlr4.PredictionContextCache(); + +var literalNames = [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "'=~'", + "'~='", + null, + null, + null, + null, + null, + "'|>'", + "':'", + "'^'", + "'!'", + "'*'", + "'/'", + "'%'", + "'+'", + "'-'", + "'~'", + "'<'", + "'>'", + "'<='", + "'>='", + "'='", + "'!='", + "'['", + "']'", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "'|'", +]; + +var symbolicNames = [ + null, + 'WS', + 'COMMENT', + 'K_SOURCE', + 'K_START', + 'K_END', + 'K_NOW', + 'K_LIVE', + 'K_PARSE', + 'K_SEARCH', + 'K_FIELDS', + 'K_DISPLAY', + 'K_FILTER', + 'K_STATS', + 'K_SORT', + 'K_ORDER', + 'K_ASC', + 'K_DESC', + 'K_HEAD', + 'K_LIMIT', + 'K_TAIL', + 'K_REGEX', + 'K_IN', + 'K_GROUP', + 'K_BY', + 'K_AS', + 'K_AND', + 'K_OR', + 'K_NOT', + 'K_LIKE', + 'K_MATCHES', + 'K_TU_MS', + 'K_TU_MSEC', + 'K_TU_MSECOND', + 'K_TU_S', + 'K_TU_SEC', + 'K_TU_SECOND', + 'K_TU_M', + 'K_TU_MIN', + 'K_TU_MINUTE', + 'K_TU_H', + 'K_TU_HR', + 'K_TU_HOUR', + 'K_TU_D', + 'K_TU_DAY', + 'K_TU_W', + 'K_TU_WEEK', + 'K_TU_MO', + 'K_TU_MON', + 'K_TU_MONTH', + 'K_TU_Q', + 'K_TU_QTR', + 'K_TU_QUARTER', + 'K_TU_Y', + 'K_TU_YR', + 'K_TU_YEAR', + 'RAW_ID', + 'LIT_INTEGER', + 'LIT_NUMBER', + 'SDQUOTED_STRING', + 'SSQUOTED_STRING', + 'CDQUOTED_STRING', + 'CSQUOTED_STRING', + 'QUOTED_IDENT', + 'SYM_EQTILDE', + 'SYM_TILDEEQ', + 'SYM_AT', + 'SYM_COMMA', + 'SYM_LPAREN', + 'SYM_RPAREN', + 'SYM_PIPE', + 'SYM_WRITE', + 'SYM_COLON', + 'SYM_CARET', + 'SYM_NOT', + 'SYM_MUL', + 'SYM_DIV', + 'SYM_MOD', + 'SYM_PLUS', + 'SYM_MINUS', + 'SYM_TILDE', + 'SYM_LT', + 'SYM_GT', + 'SYM_LTEQ', + 'SYM_GTEQ', + 'SYM_EQ', + 'SYM_NEQ', + 'SYM_LBRACKET', + 'SYM_RBRACKET', + 'REGEX_WS', + 'REGEX_COMMENT', + 'REGEX', + 'RE_SDQUOTED_STRING', + 'RE_SSQUOTED_STRING', + 'RE_CDQUOTED_STRING', + 'RE_CSQUOTED_STRING', + 'RE_RAW_ID', + 'RE_SYM_AT', + 'RE_QUOTED_IDENT', + 'SE_WS', + 'SE_COMMENT', + 'SE_K_SEARCH', + 'SE_SYM_LPAREN', + 'SE_SYM_RPAREN', + 'SE_K_AND', + 'SE_K_OR', + 'SE_K_NOT', + 'SE_SDQUOTED_STRING', + 'SE_SSQUOTED_STRING', + 'SE_CDQUOTED_STRING', + 'SE_CSQUOTED_STRING', + 'SE_UNQUOTED_STRING', + 'SE_PIPE', +]; + +var ruleNames = [ + 'query', + 'logQuery', + 'logAesthetic', + 'logSourceStage', + 'logStatsStage', + 'logOp', + 'logAestheticOp', + 'logSource', + 'timeExpr', + 'nowTimeExpr', + 'relativeTimeExpr', + 'iso8601TimeExpr', + 'epochTimeExpr', + 'bareSpaceDelimited', + 'logStats', + 'statsExpr', + 'statsGroupField', + 'logOpFields', + 'fieldSpec', + 'logOpParse', + 'logOpSearch', + 'implicitLogOpSearch', + 'searchExpr', + 'searchTerm', + 'logOpFilter', + 'logOpSort', + 'sortExpr', + 'logOpLimit', + 'expressionRoot', + 'expression', + 'term', + 'likeTerm', + 'func', + 'functionArg', + 'array', + 'arrayElem', + 'number', + 'string', + 'stringOrBareString', + 'regex', + 'regexString', + 'logId', + 'fieldId', + 'aliasId', + 'userId', + 'unquotedUserId', + 'unquotedUserAtId', + 'unquotedUserBareId', + 'quotedUserId', + 'systemId', + 'unquotedSystemId', + 'quotedSystemId', + 'resultId', + 'functionId', + 'rawId', + 'keywords', + 'timeUnitKeywords', +]; + +function ScrollQLParser(input) { + antlr4.Parser.call(this, input); + this._interp = new antlr4.atn.ParserATNSimulator(this, atn, decisionsToDFA, sharedContextCache); + this.ruleNames = ruleNames; + this.literalNames = literalNames; + this.symbolicNames = symbolicNames; + return this; +} + +ScrollQLParser.prototype = Object.create(antlr4.Parser.prototype); +ScrollQLParser.prototype.constructor = ScrollQLParser; + +Object.defineProperty(ScrollQLParser.prototype, 'atn', { + get: function () { + return atn; + }, +}); + +ScrollQLParser.EOF = antlr4.Token.EOF; +ScrollQLParser.WS = 1; +ScrollQLParser.COMMENT = 2; +ScrollQLParser.K_SOURCE = 3; +ScrollQLParser.K_START = 4; +ScrollQLParser.K_END = 5; +ScrollQLParser.K_NOW = 6; +ScrollQLParser.K_LIVE = 7; +ScrollQLParser.K_PARSE = 8; +ScrollQLParser.K_SEARCH = 9; +ScrollQLParser.K_FIELDS = 10; +ScrollQLParser.K_DISPLAY = 11; +ScrollQLParser.K_FILTER = 12; +ScrollQLParser.K_STATS = 13; +ScrollQLParser.K_SORT = 14; +ScrollQLParser.K_ORDER = 15; +ScrollQLParser.K_ASC = 16; +ScrollQLParser.K_DESC = 17; +ScrollQLParser.K_HEAD = 18; +ScrollQLParser.K_LIMIT = 19; +ScrollQLParser.K_TAIL = 20; +ScrollQLParser.K_REGEX = 21; +ScrollQLParser.K_IN = 22; +ScrollQLParser.K_GROUP = 23; +ScrollQLParser.K_BY = 24; +ScrollQLParser.K_AS = 25; +ScrollQLParser.K_AND = 26; +ScrollQLParser.K_OR = 27; +ScrollQLParser.K_NOT = 28; +ScrollQLParser.K_LIKE = 29; +ScrollQLParser.K_MATCHES = 30; +ScrollQLParser.K_TU_MS = 31; +ScrollQLParser.K_TU_MSEC = 32; +ScrollQLParser.K_TU_MSECOND = 33; +ScrollQLParser.K_TU_S = 34; +ScrollQLParser.K_TU_SEC = 35; +ScrollQLParser.K_TU_SECOND = 36; +ScrollQLParser.K_TU_M = 37; +ScrollQLParser.K_TU_MIN = 38; +ScrollQLParser.K_TU_MINUTE = 39; +ScrollQLParser.K_TU_H = 40; +ScrollQLParser.K_TU_HR = 41; +ScrollQLParser.K_TU_HOUR = 42; +ScrollQLParser.K_TU_D = 43; +ScrollQLParser.K_TU_DAY = 44; +ScrollQLParser.K_TU_W = 45; +ScrollQLParser.K_TU_WEEK = 46; +ScrollQLParser.K_TU_MO = 47; +ScrollQLParser.K_TU_MON = 48; +ScrollQLParser.K_TU_MONTH = 49; +ScrollQLParser.K_TU_Q = 50; +ScrollQLParser.K_TU_QTR = 51; +ScrollQLParser.K_TU_QUARTER = 52; +ScrollQLParser.K_TU_Y = 53; +ScrollQLParser.K_TU_YR = 54; +ScrollQLParser.K_TU_YEAR = 55; +ScrollQLParser.RAW_ID = 56; +ScrollQLParser.LIT_INTEGER = 57; +ScrollQLParser.LIT_NUMBER = 58; +ScrollQLParser.SDQUOTED_STRING = 59; +ScrollQLParser.SSQUOTED_STRING = 60; +ScrollQLParser.CDQUOTED_STRING = 61; +ScrollQLParser.CSQUOTED_STRING = 62; +ScrollQLParser.QUOTED_IDENT = 63; +ScrollQLParser.SYM_EQTILDE = 64; +ScrollQLParser.SYM_TILDEEQ = 65; +ScrollQLParser.SYM_AT = 66; +ScrollQLParser.SYM_COMMA = 67; +ScrollQLParser.SYM_LPAREN = 68; +ScrollQLParser.SYM_RPAREN = 69; +ScrollQLParser.SYM_PIPE = 70; +ScrollQLParser.SYM_WRITE = 71; +ScrollQLParser.SYM_COLON = 72; +ScrollQLParser.SYM_CARET = 73; +ScrollQLParser.SYM_NOT = 74; +ScrollQLParser.SYM_MUL = 75; +ScrollQLParser.SYM_DIV = 76; +ScrollQLParser.SYM_MOD = 77; +ScrollQLParser.SYM_PLUS = 78; +ScrollQLParser.SYM_MINUS = 79; +ScrollQLParser.SYM_TILDE = 80; +ScrollQLParser.SYM_LT = 81; +ScrollQLParser.SYM_GT = 82; +ScrollQLParser.SYM_LTEQ = 83; +ScrollQLParser.SYM_GTEQ = 84; +ScrollQLParser.SYM_EQ = 85; +ScrollQLParser.SYM_NEQ = 86; +ScrollQLParser.SYM_LBRACKET = 87; +ScrollQLParser.SYM_RBRACKET = 88; +ScrollQLParser.REGEX_WS = 89; +ScrollQLParser.REGEX_COMMENT = 90; +ScrollQLParser.REGEX = 91; +ScrollQLParser.RE_SDQUOTED_STRING = 92; +ScrollQLParser.RE_SSQUOTED_STRING = 93; +ScrollQLParser.RE_CDQUOTED_STRING = 94; +ScrollQLParser.RE_CSQUOTED_STRING = 95; +ScrollQLParser.RE_RAW_ID = 96; +ScrollQLParser.RE_SYM_AT = 97; +ScrollQLParser.RE_QUOTED_IDENT = 98; +ScrollQLParser.SE_WS = 99; +ScrollQLParser.SE_COMMENT = 100; +ScrollQLParser.SE_K_SEARCH = 101; +ScrollQLParser.SE_SYM_LPAREN = 102; +ScrollQLParser.SE_SYM_RPAREN = 103; +ScrollQLParser.SE_K_AND = 104; +ScrollQLParser.SE_K_OR = 105; +ScrollQLParser.SE_K_NOT = 106; +ScrollQLParser.SE_SDQUOTED_STRING = 107; +ScrollQLParser.SE_SSQUOTED_STRING = 108; +ScrollQLParser.SE_CDQUOTED_STRING = 109; +ScrollQLParser.SE_CSQUOTED_STRING = 110; +ScrollQLParser.SE_UNQUOTED_STRING = 111; +ScrollQLParser.SE_PIPE = 112; + +ScrollQLParser.RULE_query = 0; +ScrollQLParser.RULE_logQuery = 1; +ScrollQLParser.RULE_logAesthetic = 2; +ScrollQLParser.RULE_logSourceStage = 3; +ScrollQLParser.RULE_logStatsStage = 4; +ScrollQLParser.RULE_logOp = 5; +ScrollQLParser.RULE_logAestheticOp = 6; +ScrollQLParser.RULE_logSource = 7; +ScrollQLParser.RULE_timeExpr = 8; +ScrollQLParser.RULE_nowTimeExpr = 9; +ScrollQLParser.RULE_relativeTimeExpr = 10; +ScrollQLParser.RULE_iso8601TimeExpr = 11; +ScrollQLParser.RULE_epochTimeExpr = 12; +ScrollQLParser.RULE_bareSpaceDelimited = 13; +ScrollQLParser.RULE_logStats = 14; +ScrollQLParser.RULE_statsExpr = 15; +ScrollQLParser.RULE_statsGroupField = 16; +ScrollQLParser.RULE_logOpFields = 17; +ScrollQLParser.RULE_fieldSpec = 18; +ScrollQLParser.RULE_logOpParse = 19; +ScrollQLParser.RULE_logOpSearch = 20; +ScrollQLParser.RULE_implicitLogOpSearch = 21; +ScrollQLParser.RULE_searchExpr = 22; +ScrollQLParser.RULE_searchTerm = 23; +ScrollQLParser.RULE_logOpFilter = 24; +ScrollQLParser.RULE_logOpSort = 25; +ScrollQLParser.RULE_sortExpr = 26; +ScrollQLParser.RULE_logOpLimit = 27; +ScrollQLParser.RULE_expressionRoot = 28; +ScrollQLParser.RULE_expression = 29; +ScrollQLParser.RULE_term = 30; +ScrollQLParser.RULE_likeTerm = 31; +ScrollQLParser.RULE_func = 32; +ScrollQLParser.RULE_functionArg = 33; +ScrollQLParser.RULE_array = 34; +ScrollQLParser.RULE_arrayElem = 35; +ScrollQLParser.RULE_number = 36; +ScrollQLParser.RULE_string = 37; +ScrollQLParser.RULE_stringOrBareString = 38; +ScrollQLParser.RULE_regex = 39; +ScrollQLParser.RULE_regexString = 40; +ScrollQLParser.RULE_logId = 41; +ScrollQLParser.RULE_fieldId = 42; +ScrollQLParser.RULE_aliasId = 43; +ScrollQLParser.RULE_userId = 44; +ScrollQLParser.RULE_unquotedUserId = 45; +ScrollQLParser.RULE_unquotedUserAtId = 46; +ScrollQLParser.RULE_unquotedUserBareId = 47; +ScrollQLParser.RULE_quotedUserId = 48; +ScrollQLParser.RULE_systemId = 49; +ScrollQLParser.RULE_unquotedSystemId = 50; +ScrollQLParser.RULE_quotedSystemId = 51; +ScrollQLParser.RULE_resultId = 52; +ScrollQLParser.RULE_functionId = 53; +ScrollQLParser.RULE_rawId = 54; +ScrollQLParser.RULE_keywords = 55; +ScrollQLParser.RULE_timeUnitKeywords = 56; + +function QueryContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_query; + return this; +} + +QueryContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +QueryContext.prototype.constructor = QueryContext; + +QueryContext.prototype.logQuery = function () { + return this.getTypedRuleContext(LogQueryContext, 0); +}; + +QueryContext.prototype.EOF = function () { + return this.getToken(ScrollQLParser.EOF, 0); +}; + +QueryContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterQuery(this); + } +}; + +QueryContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitQuery(this); + } +}; + +ScrollQLParser.QueryContext = QueryContext; + +ScrollQLParser.prototype.query = function () { + var localctx = new QueryContext(this, this._ctx, this.state); + this.enterRule(localctx, 0, ScrollQLParser.RULE_query); + try { + this.enterOuterAlt(localctx, 1); + this.state = 114; + this.logQuery(); + this.state = 115; + this.match(ScrollQLParser.EOF); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogQueryContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logQuery; + this.result = null; // ResultIdContext + return this; +} + +LogQueryContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogQueryContext.prototype.constructor = LogQueryContext; + +LogQueryContext.prototype.logSourceStage = function () { + return this.getTypedRuleContext(LogSourceStageContext, 0); +}; + +LogQueryContext.prototype.SYM_PIPE = function () { + return this.getToken(ScrollQLParser.SYM_PIPE, 0); +}; + +LogQueryContext.prototype.logStatsStage = function () { + return this.getTypedRuleContext(LogStatsStageContext, 0); +}; + +LogQueryContext.prototype.SYM_WRITE = function () { + return this.getToken(ScrollQLParser.SYM_WRITE, 0); +}; + +LogQueryContext.prototype.resultId = function () { + return this.getTypedRuleContext(ResultIdContext, 0); +}; + +LogQueryContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogQuery(this); + } +}; + +LogQueryContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogQuery(this); + } +}; + +ScrollQLParser.LogQueryContext = LogQueryContext; + +ScrollQLParser.prototype.logQuery = function () { + var localctx = new LogQueryContext(this, this._ctx, this.state); + this.enterRule(localctx, 2, ScrollQLParser.RULE_logQuery); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 117; + this.logSourceStage(); + this.state = 120; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SYM_PIPE) { + this.state = 118; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 119; + this.logStatsStage(); + } + + this.state = 124; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SYM_WRITE) { + this.state = 122; + this.match(ScrollQLParser.SYM_WRITE); + this.state = 123; + localctx.result = this.resultId(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogAestheticContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logAesthetic; + this.result = null; // ResultIdContext + this._logAestheticOp = null; // LogAestheticOpContext + this.aes = []; // of LogAestheticOpContexts + return this; +} + +LogAestheticContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogAestheticContext.prototype.constructor = LogAestheticContext; + +LogAestheticContext.prototype.resultId = function () { + return this.getTypedRuleContext(ResultIdContext, 0); +}; + +LogAestheticContext.prototype.SYM_PIPE = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_PIPE); + } else { + return this.getToken(ScrollQLParser.SYM_PIPE, i); + } +}; + +LogAestheticContext.prototype.logAestheticOp = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(LogAestheticOpContext); + } else { + return this.getTypedRuleContext(LogAestheticOpContext, i); + } +}; + +LogAestheticContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogAesthetic(this); + } +}; + +LogAestheticContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogAesthetic(this); + } +}; + +ScrollQLParser.LogAestheticContext = LogAestheticContext; + +ScrollQLParser.prototype.logAesthetic = function () { + var localctx = new LogAestheticContext(this, this._ctx, this.state); + this.enterRule(localctx, 4, ScrollQLParser.RULE_logAesthetic); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 126; + localctx.result = this.resultId(); + this.state = 131; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_PIPE) { + this.state = 127; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 128; + localctx._logAestheticOp = this.logAestheticOp(); + localctx.aes.push(localctx._logAestheticOp); + this.state = 133; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogSourceStageContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logSourceStage; + this._logSource = null; // LogSourceContext + this.source = []; // of LogSourceContexts + return this; +} + +LogSourceStageContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogSourceStageContext.prototype.constructor = LogSourceStageContext; + +LogSourceStageContext.prototype.logSource = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(LogSourceContext); + } else { + return this.getTypedRuleContext(LogSourceContext, i); + } +}; + +LogSourceStageContext.prototype.SYM_PIPE = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_PIPE); + } else { + return this.getToken(ScrollQLParser.SYM_PIPE, i); + } +}; + +LogSourceStageContext.prototype.implicitLogOpSearch = function () { + return this.getTypedRuleContext(ImplicitLogOpSearchContext, 0); +}; + +LogSourceStageContext.prototype.logOp = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(LogOpContext); + } else { + return this.getTypedRuleContext(LogOpContext, i); + } +}; + +LogSourceStageContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogSourceStage(this); + } +}; + +LogSourceStageContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogSourceStage(this); + } +}; + +ScrollQLParser.LogSourceStageContext = LogSourceStageContext; + +ScrollQLParser.prototype.logSourceStage = function () { + var localctx = new LogSourceStageContext(this, this._ctx, this.state); + this.enterRule(localctx, 6, ScrollQLParser.RULE_logSourceStage); + try { + this.enterOuterAlt(localctx, 1); + this.state = 134; + localctx._logSource = this.logSource(); + localctx.source.push(localctx._logSource); + this.state = 139; + this._errHandler.sync(this); + var _alt = this._interp.adaptivePredict(this._input, 3, this._ctx); + while (_alt != 2 && _alt != antlr4.atn.ATN.INVALID_ALT_NUMBER) { + if (_alt === 1) { + this.state = 135; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 136; + localctx._logSource = this.logSource(); + localctx.source.push(localctx._logSource); + } + this.state = 141; + this._errHandler.sync(this); + _alt = this._interp.adaptivePredict(this._input, 3, this._ctx); + } + + this.state = 144; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 4, this._ctx); + if (la_ === 1) { + this.state = 142; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 143; + this.implicitLogOpSearch(); + } + this.state = 150; + this._errHandler.sync(this); + var _alt = this._interp.adaptivePredict(this._input, 5, this._ctx); + while (_alt != 2 && _alt != antlr4.atn.ATN.INVALID_ALT_NUMBER) { + if (_alt === 1) { + this.state = 146; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 147; + this.logOp(); + } + this.state = 152; + this._errHandler.sync(this); + _alt = this._interp.adaptivePredict(this._input, 5, this._ctx); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogStatsStageContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logStatsStage; + this.stats = null; // LogStatsContext + return this; +} + +LogStatsStageContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogStatsStageContext.prototype.constructor = LogStatsStageContext; + +LogStatsStageContext.prototype.logStats = function () { + return this.getTypedRuleContext(LogStatsContext, 0); +}; + +LogStatsStageContext.prototype.SYM_PIPE = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_PIPE); + } else { + return this.getToken(ScrollQLParser.SYM_PIPE, i); + } +}; + +LogStatsStageContext.prototype.logOp = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(LogOpContext); + } else { + return this.getTypedRuleContext(LogOpContext, i); + } +}; + +LogStatsStageContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogStatsStage(this); + } +}; + +LogStatsStageContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogStatsStage(this); + } +}; + +ScrollQLParser.LogStatsStageContext = LogStatsStageContext; + +ScrollQLParser.prototype.logStatsStage = function () { + var localctx = new LogStatsStageContext(this, this._ctx, this.state); + this.enterRule(localctx, 8, ScrollQLParser.RULE_logStatsStage); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 153; + localctx.stats = this.logStats(); + this.state = 158; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_PIPE) { + this.state = 154; + this.match(ScrollQLParser.SYM_PIPE); + this.state = 155; + this.logOp(); + this.state = 160; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOp; + return this; +} + +LogOpContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpContext.prototype.constructor = LogOpContext; + +LogOpContext.prototype.logOpFields = function () { + return this.getTypedRuleContext(LogOpFieldsContext, 0); +}; + +LogOpContext.prototype.logOpParse = function () { + return this.getTypedRuleContext(LogOpParseContext, 0); +}; + +LogOpContext.prototype.logOpFilter = function () { + return this.getTypedRuleContext(LogOpFilterContext, 0); +}; + +LogOpContext.prototype.logOpSearch = function () { + return this.getTypedRuleContext(LogOpSearchContext, 0); +}; + +LogOpContext.prototype.logOpSort = function () { + return this.getTypedRuleContext(LogOpSortContext, 0); +}; + +LogOpContext.prototype.logOpLimit = function () { + return this.getTypedRuleContext(LogOpLimitContext, 0); +}; + +LogOpContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOp(this); + } +}; + +LogOpContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOp(this); + } +}; + +ScrollQLParser.LogOpContext = LogOpContext; + +ScrollQLParser.prototype.logOp = function () { + var localctx = new LogOpContext(this, this._ctx, this.state); + this.enterRule(localctx, 10, ScrollQLParser.RULE_logOp); + try { + this.state = 167; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_FIELDS: + case ScrollQLParser.K_DISPLAY: + this.enterOuterAlt(localctx, 1); + this.state = 161; + this.logOpFields(); + break; + case ScrollQLParser.K_PARSE: + this.enterOuterAlt(localctx, 2); + this.state = 162; + this.logOpParse(); + break; + case ScrollQLParser.K_FILTER: + this.enterOuterAlt(localctx, 3); + this.state = 163; + this.logOpFilter(); + break; + case ScrollQLParser.K_SEARCH: + this.enterOuterAlt(localctx, 4); + this.state = 164; + this.logOpSearch(); + break; + case ScrollQLParser.K_SORT: + case ScrollQLParser.K_ORDER: + this.enterOuterAlt(localctx, 5); + this.state = 165; + this.logOpSort(); + break; + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + case ScrollQLParser.K_TAIL: + this.enterOuterAlt(localctx, 6); + this.state = 166; + this.logOpLimit(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogAestheticOpContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logAestheticOp; + this.aesFun = null; // FunctionIdContext + this._rawId = null; // RawIdContext + this.params = []; // of RawIdContexts + this._fieldId = null; // FieldIdContext + this.vals = []; // of FieldIdContexts + return this; +} + +LogAestheticOpContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogAestheticOpContext.prototype.constructor = LogAestheticOpContext; + +LogAestheticOpContext.prototype.functionId = function () { + return this.getTypedRuleContext(FunctionIdContext, 0); +}; + +LogAestheticOpContext.prototype.SYM_EQ = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_EQ); + } else { + return this.getToken(ScrollQLParser.SYM_EQ, i); + } +}; + +LogAestheticOpContext.prototype.rawId = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(RawIdContext); + } else { + return this.getTypedRuleContext(RawIdContext, i); + } +}; + +LogAestheticOpContext.prototype.fieldId = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(FieldIdContext); + } else { + return this.getTypedRuleContext(FieldIdContext, i); + } +}; + +LogAestheticOpContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogAestheticOpContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogAestheticOp(this); + } +}; + +LogAestheticOpContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogAestheticOp(this); + } +}; + +ScrollQLParser.LogAestheticOpContext = LogAestheticOpContext; + +ScrollQLParser.prototype.logAestheticOp = function () { + var localctx = new LogAestheticOpContext(this, this._ctx, this.state); + this.enterRule(localctx, 12, ScrollQLParser.RULE_logAestheticOp); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 169; + localctx.aesFun = this.functionId(); + this.state = 178; + this._errHandler.sync(this); + _la = this._input.LA(1); + while ( + ((_la & ~0x1f) == 0 && + ((1 << _la) & + ((1 << ScrollQLParser.K_SOURCE) | + (1 << ScrollQLParser.K_START) | + (1 << ScrollQLParser.K_END) | + (1 << ScrollQLParser.K_NOW) | + (1 << ScrollQLParser.K_LIVE) | + (1 << ScrollQLParser.K_PARSE) | + (1 << ScrollQLParser.K_SEARCH) | + (1 << ScrollQLParser.K_FIELDS) | + (1 << ScrollQLParser.K_DISPLAY) | + (1 << ScrollQLParser.K_FILTER) | + (1 << ScrollQLParser.K_STATS) | + (1 << ScrollQLParser.K_SORT) | + (1 << ScrollQLParser.K_ORDER) | + (1 << ScrollQLParser.K_ASC) | + (1 << ScrollQLParser.K_DESC) | + (1 << ScrollQLParser.K_HEAD) | + (1 << ScrollQLParser.K_LIMIT) | + (1 << ScrollQLParser.K_TAIL) | + (1 << ScrollQLParser.K_REGEX) | + (1 << ScrollQLParser.K_IN) | + (1 << ScrollQLParser.K_GROUP) | + (1 << ScrollQLParser.K_BY) | + (1 << ScrollQLParser.K_AS) | + (1 << ScrollQLParser.K_AND) | + (1 << ScrollQLParser.K_OR) | + (1 << ScrollQLParser.K_NOT) | + (1 << ScrollQLParser.K_LIKE) | + (1 << ScrollQLParser.K_MATCHES) | + (1 << ScrollQLParser.K_TU_MS))) !== + 0) || + (((_la - 32) & ~0x1f) == 0 && + ((1 << (_la - 32)) & + ((1 << (ScrollQLParser.K_TU_MSEC - 32)) | + (1 << (ScrollQLParser.K_TU_MSECOND - 32)) | + (1 << (ScrollQLParser.K_TU_S - 32)) | + (1 << (ScrollQLParser.K_TU_SEC - 32)) | + (1 << (ScrollQLParser.K_TU_SECOND - 32)) | + (1 << (ScrollQLParser.K_TU_M - 32)) | + (1 << (ScrollQLParser.K_TU_MIN - 32)) | + (1 << (ScrollQLParser.K_TU_MINUTE - 32)) | + (1 << (ScrollQLParser.K_TU_H - 32)) | + (1 << (ScrollQLParser.K_TU_HR - 32)) | + (1 << (ScrollQLParser.K_TU_HOUR - 32)) | + (1 << (ScrollQLParser.K_TU_D - 32)) | + (1 << (ScrollQLParser.K_TU_DAY - 32)) | + (1 << (ScrollQLParser.K_TU_W - 32)) | + (1 << (ScrollQLParser.K_TU_WEEK - 32)) | + (1 << (ScrollQLParser.K_TU_MO - 32)) | + (1 << (ScrollQLParser.K_TU_MON - 32)) | + (1 << (ScrollQLParser.K_TU_MONTH - 32)) | + (1 << (ScrollQLParser.K_TU_Q - 32)) | + (1 << (ScrollQLParser.K_TU_QTR - 32)) | + (1 << (ScrollQLParser.K_TU_QUARTER - 32)) | + (1 << (ScrollQLParser.K_TU_Y - 32)) | + (1 << (ScrollQLParser.K_TU_YR - 32)) | + (1 << (ScrollQLParser.K_TU_YEAR - 32)) | + (1 << (ScrollQLParser.RAW_ID - 32)))) !== + 0) || + _la === ScrollQLParser.RE_RAW_ID + ) { + this.state = 170; + localctx._rawId = this.rawId(); + localctx.params.push(localctx._rawId); + this.state = 171; + this.match(ScrollQLParser.SYM_EQ); + this.state = 172; + localctx._fieldId = this.fieldId(); + localctx.vals.push(localctx._fieldId); + this.state = 174; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SYM_COMMA) { + this.state = 173; + this.match(ScrollQLParser.SYM_COMMA); + } + + this.state = 180; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogSourceContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logSource; + this.source = null; // LogIdContext + this.start = null; // TimeExprContext + this.end = null; // TimeExprContext + return this; +} + +LogSourceContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogSourceContext.prototype.constructor = LogSourceContext; + +LogSourceContext.prototype.K_SOURCE = function () { + return this.getToken(ScrollQLParser.K_SOURCE, 0); +}; + +LogSourceContext.prototype.K_START = function () { + return this.getToken(ScrollQLParser.K_START, 0); +}; + +LogSourceContext.prototype.SYM_EQ = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_EQ); + } else { + return this.getToken(ScrollQLParser.SYM_EQ, i); + } +}; + +LogSourceContext.prototype.K_END = function () { + return this.getToken(ScrollQLParser.K_END, 0); +}; + +LogSourceContext.prototype.logId = function () { + return this.getTypedRuleContext(LogIdContext, 0); +}; + +LogSourceContext.prototype.timeExpr = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(TimeExprContext); + } else { + return this.getTypedRuleContext(TimeExprContext, i); + } +}; + +LogSourceContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogSource(this); + } +}; + +LogSourceContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogSource(this); + } +}; + +ScrollQLParser.LogSourceContext = LogSourceContext; + +ScrollQLParser.prototype.logSource = function () { + var localctx = new LogSourceContext(this, this._ctx, this.state); + this.enterRule(localctx, 14, ScrollQLParser.RULE_logSource); + try { + this.enterOuterAlt(localctx, 1); + this.state = 181; + this.match(ScrollQLParser.K_SOURCE); + this.state = 182; + localctx.source = this.logId(); + this.state = 183; + this.match(ScrollQLParser.K_START); + this.state = 184; + this.match(ScrollQLParser.SYM_EQ); + this.state = 185; + localctx.start = this.timeExpr(); + this.state = 186; + this.match(ScrollQLParser.K_END); + this.state = 187; + this.match(ScrollQLParser.SYM_EQ); + this.state = 188; + localctx.end = this.timeExpr(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function TimeExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_timeExpr; + return this; +} + +TimeExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +TimeExprContext.prototype.constructor = TimeExprContext; + +TimeExprContext.prototype.nowTimeExpr = function () { + return this.getTypedRuleContext(NowTimeExprContext, 0); +}; + +TimeExprContext.prototype.relativeTimeExpr = function () { + return this.getTypedRuleContext(RelativeTimeExprContext, 0); +}; + +TimeExprContext.prototype.epochTimeExpr = function () { + return this.getTypedRuleContext(EpochTimeExprContext, 0); +}; + +TimeExprContext.prototype.iso8601TimeExpr = function () { + return this.getTypedRuleContext(Iso8601TimeExprContext, 0); +}; + +TimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeExpr(this); + } +}; + +TimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeExpr(this); + } +}; + +ScrollQLParser.TimeExprContext = TimeExprContext; + +ScrollQLParser.prototype.timeExpr = function () { + var localctx = new TimeExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 16, ScrollQLParser.RULE_timeExpr); + try { + this.state = 194; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 10, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 190; + this.nowTimeExpr(); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 191; + this.relativeTimeExpr(); + break; + + case 3: + this.enterOuterAlt(localctx, 3); + this.state = 192; + this.epochTimeExpr(); + break; + + case 4: + this.enterOuterAlt(localctx, 4); + this.state = 193; + this.iso8601TimeExpr(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function NowTimeExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_nowTimeExpr; + return this; +} + +NowTimeExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +NowTimeExprContext.prototype.constructor = NowTimeExprContext; + +NowTimeExprContext.prototype.K_NOW = function () { + return this.getToken(ScrollQLParser.K_NOW, 0); +}; + +NowTimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterNowTimeExpr(this); + } +}; + +NowTimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitNowTimeExpr(this); + } +}; + +ScrollQLParser.NowTimeExprContext = NowTimeExprContext; + +ScrollQLParser.prototype.nowTimeExpr = function () { + var localctx = new NowTimeExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 18, ScrollQLParser.RULE_nowTimeExpr); + try { + this.enterOuterAlt(localctx, 1); + this.state = 196; + this.match(ScrollQLParser.K_NOW); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function RelativeTimeExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_relativeTimeExpr; + return this; +} + +RelativeTimeExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +RelativeTimeExprContext.prototype.constructor = RelativeTimeExprContext; + +RelativeTimeExprContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function PosRelativeTimeExprContext(parser, ctx) { + RelativeTimeExprContext.call(this, parser); + this.num = null; // Token; + RelativeTimeExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +PosRelativeTimeExprContext.prototype = Object.create(RelativeTimeExprContext.prototype); +PosRelativeTimeExprContext.prototype.constructor = PosRelativeTimeExprContext; + +ScrollQLParser.PosRelativeTimeExprContext = PosRelativeTimeExprContext; + +PosRelativeTimeExprContext.prototype.timeUnitKeywords = function () { + return this.getTypedRuleContext(TimeUnitKeywordsContext, 0); +}; + +PosRelativeTimeExprContext.prototype.SYM_PLUS = function () { + return this.getToken(ScrollQLParser.SYM_PLUS, 0); +}; + +PosRelativeTimeExprContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; + +PosRelativeTimeExprContext.prototype.LIT_NUMBER = function () { + return this.getToken(ScrollQLParser.LIT_NUMBER, 0); +}; +PosRelativeTimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterPosRelativeTimeExpr(this); + } +}; + +PosRelativeTimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitPosRelativeTimeExpr(this); + } +}; + +function NegRelativeTimeExprContext(parser, ctx) { + RelativeTimeExprContext.call(this, parser); + this.num = null; // Token; + RelativeTimeExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +NegRelativeTimeExprContext.prototype = Object.create(RelativeTimeExprContext.prototype); +NegRelativeTimeExprContext.prototype.constructor = NegRelativeTimeExprContext; + +ScrollQLParser.NegRelativeTimeExprContext = NegRelativeTimeExprContext; + +NegRelativeTimeExprContext.prototype.SYM_MINUS = function () { + return this.getToken(ScrollQLParser.SYM_MINUS, 0); +}; + +NegRelativeTimeExprContext.prototype.timeUnitKeywords = function () { + return this.getTypedRuleContext(TimeUnitKeywordsContext, 0); +}; + +NegRelativeTimeExprContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; + +NegRelativeTimeExprContext.prototype.LIT_NUMBER = function () { + return this.getToken(ScrollQLParser.LIT_NUMBER, 0); +}; +NegRelativeTimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterNegRelativeTimeExpr(this); + } +}; + +NegRelativeTimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitNegRelativeTimeExpr(this); + } +}; + +ScrollQLParser.RelativeTimeExprContext = RelativeTimeExprContext; + +ScrollQLParser.prototype.relativeTimeExpr = function () { + var localctx = new RelativeTimeExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 20, ScrollQLParser.RULE_relativeTimeExpr); + var _la = 0; // Token type + try { + this.state = 210; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SYM_MINUS: + localctx = new NegRelativeTimeExprContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 198; + this.match(ScrollQLParser.SYM_MINUS); + this.state = 200; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.LIT_INTEGER || _la === ScrollQLParser.LIT_NUMBER) { + this.state = 199; + localctx.num = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.LIT_INTEGER || _la === ScrollQLParser.LIT_NUMBER)) { + localctx.num = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } + + this.state = 202; + this.timeUnitKeywords(); + break; + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + case ScrollQLParser.LIT_INTEGER: + case ScrollQLParser.LIT_NUMBER: + case ScrollQLParser.SYM_PLUS: + localctx = new PosRelativeTimeExprContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 204; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SYM_PLUS) { + this.state = 203; + this.match(ScrollQLParser.SYM_PLUS); + } + + this.state = 207; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.LIT_INTEGER || _la === ScrollQLParser.LIT_NUMBER) { + this.state = 206; + localctx.num = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.LIT_INTEGER || _la === ScrollQLParser.LIT_NUMBER)) { + localctx.num = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } + + this.state = 209; + this.timeUnitKeywords(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function Iso8601TimeExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_iso8601TimeExpr; + this.datetime = null; // StringOrBareStringContext + this.uqdatetime = null; // BareSpaceDelimitedContext + return this; +} + +Iso8601TimeExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +Iso8601TimeExprContext.prototype.constructor = Iso8601TimeExprContext; + +Iso8601TimeExprContext.prototype.stringOrBareString = function () { + return this.getTypedRuleContext(StringOrBareStringContext, 0); +}; + +Iso8601TimeExprContext.prototype.bareSpaceDelimited = function () { + return this.getTypedRuleContext(BareSpaceDelimitedContext, 0); +}; + +Iso8601TimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterIso8601TimeExpr(this); + } +}; + +Iso8601TimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitIso8601TimeExpr(this); + } +}; + +ScrollQLParser.Iso8601TimeExprContext = Iso8601TimeExprContext; + +ScrollQLParser.prototype.iso8601TimeExpr = function () { + var localctx = new Iso8601TimeExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 22, ScrollQLParser.RULE_iso8601TimeExpr); + try { + this.state = 214; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 15, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 212; + localctx.datetime = this.stringOrBareString(); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 213; + localctx.uqdatetime = this.bareSpaceDelimited(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function EpochTimeExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_epochTimeExpr; + this.num = null; // Token + return this; +} + +EpochTimeExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +EpochTimeExprContext.prototype.constructor = EpochTimeExprContext; + +EpochTimeExprContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; + +EpochTimeExprContext.prototype.LIT_NUMBER = function () { + return this.getToken(ScrollQLParser.LIT_NUMBER, 0); +}; + +EpochTimeExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterEpochTimeExpr(this); + } +}; + +EpochTimeExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitEpochTimeExpr(this); + } +}; + +ScrollQLParser.EpochTimeExprContext = EpochTimeExprContext; + +ScrollQLParser.prototype.epochTimeExpr = function () { + var localctx = new EpochTimeExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 24, ScrollQLParser.RULE_epochTimeExpr); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 216; + localctx.num = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.LIT_INTEGER || _la === ScrollQLParser.LIT_NUMBER)) { + localctx.num = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function BareSpaceDelimitedContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_bareSpaceDelimited; + return this; +} + +BareSpaceDelimitedContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +BareSpaceDelimitedContext.prototype.constructor = BareSpaceDelimitedContext; + +BareSpaceDelimitedContext.prototype.SYM_MINUS = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_MINUS); + } else { + return this.getToken(ScrollQLParser.SYM_MINUS, i); + } +}; + +BareSpaceDelimitedContext.prototype.LIT_INTEGER = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.LIT_INTEGER); + } else { + return this.getToken(ScrollQLParser.LIT_INTEGER, i); + } +}; + +BareSpaceDelimitedContext.prototype.LIT_NUMBER = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.LIT_NUMBER); + } else { + return this.getToken(ScrollQLParser.LIT_NUMBER, i); + } +}; + +BareSpaceDelimitedContext.prototype.SYM_COLON = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COLON); + } else { + return this.getToken(ScrollQLParser.SYM_COLON, i); + } +}; + +BareSpaceDelimitedContext.prototype.RAW_ID = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.RAW_ID); + } else { + return this.getToken(ScrollQLParser.RAW_ID, i); + } +}; + +BareSpaceDelimitedContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterBareSpaceDelimited(this); + } +}; + +BareSpaceDelimitedContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitBareSpaceDelimited(this); + } +}; + +ScrollQLParser.BareSpaceDelimitedContext = BareSpaceDelimitedContext; + +ScrollQLParser.prototype.bareSpaceDelimited = function () { + var localctx = new BareSpaceDelimitedContext(this, this._ctx, this.state); + this.enterRule(localctx, 26, ScrollQLParser.RULE_bareSpaceDelimited); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 221; + this._errHandler.sync(this); + _la = this._input.LA(1); + while ( + ((_la - 56) & ~0x1f) == 0 && + ((1 << (_la - 56)) & + ((1 << (ScrollQLParser.RAW_ID - 56)) | + (1 << (ScrollQLParser.LIT_INTEGER - 56)) | + (1 << (ScrollQLParser.LIT_NUMBER - 56)) | + (1 << (ScrollQLParser.SYM_COLON - 56)) | + (1 << (ScrollQLParser.SYM_MINUS - 56)))) !== + 0 + ) { + this.state = 218; + _la = this._input.LA(1); + if ( + !( + ((_la - 56) & ~0x1f) == 0 && + ((1 << (_la - 56)) & + ((1 << (ScrollQLParser.RAW_ID - 56)) | + (1 << (ScrollQLParser.LIT_INTEGER - 56)) | + (1 << (ScrollQLParser.LIT_NUMBER - 56)) | + (1 << (ScrollQLParser.SYM_COLON - 56)) | + (1 << (ScrollQLParser.SYM_MINUS - 56)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 223; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogStatsContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logStats; + this._statsExpr = null; // StatsExprContext + this.expr = []; // of StatsExprContexts + this._statsGroupField = null; // StatsGroupFieldContext + this.groups = []; // of StatsGroupFieldContexts + return this; +} + +LogStatsContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogStatsContext.prototype.constructor = LogStatsContext; + +LogStatsContext.prototype.K_STATS = function () { + return this.getToken(ScrollQLParser.K_STATS, 0); +}; + +LogStatsContext.prototype.statsExpr = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(StatsExprContext); + } else { + return this.getTypedRuleContext(StatsExprContext, i); + } +}; + +LogStatsContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogStatsContext.prototype.K_BY = function () { + return this.getToken(ScrollQLParser.K_BY, 0); +}; + +LogStatsContext.prototype.statsGroupField = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(StatsGroupFieldContext); + } else { + return this.getTypedRuleContext(StatsGroupFieldContext, i); + } +}; + +LogStatsContext.prototype.K_GROUP = function () { + return this.getToken(ScrollQLParser.K_GROUP, 0); +}; + +LogStatsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogStats(this); + } +}; + +LogStatsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogStats(this); + } +}; + +ScrollQLParser.LogStatsContext = LogStatsContext; + +ScrollQLParser.prototype.logStats = function () { + var localctx = new LogStatsContext(this, this._ctx, this.state); + this.enterRule(localctx, 28, ScrollQLParser.RULE_logStats); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 224; + this.match(ScrollQLParser.K_STATS); + this.state = 225; + localctx._statsExpr = this.statsExpr(); + localctx.expr.push(localctx._statsExpr); + this.state = 230; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 226; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 227; + localctx._statsExpr = this.statsExpr(); + localctx.expr.push(localctx._statsExpr); + this.state = 232; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + this.state = 245; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_GROUP || _la === ScrollQLParser.K_BY) { + this.state = 234; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_GROUP) { + this.state = 233; + this.match(ScrollQLParser.K_GROUP); + } + + this.state = 236; + this.match(ScrollQLParser.K_BY); + this.state = 237; + localctx._statsGroupField = this.statsGroupField(); + localctx.groups.push(localctx._statsGroupField); + this.state = 242; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 238; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 239; + localctx._statsGroupField = this.statsGroupField(); + localctx.groups.push(localctx._statsGroupField); + this.state = 244; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function StatsExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_statsExpr; + this.expr = null; // ExpressionRootContext + this.proj = null; // AliasIdContext + return this; +} + +StatsExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +StatsExprContext.prototype.constructor = StatsExprContext; + +StatsExprContext.prototype.expressionRoot = function () { + return this.getTypedRuleContext(ExpressionRootContext, 0); +}; + +StatsExprContext.prototype.K_AS = function () { + return this.getToken(ScrollQLParser.K_AS, 0); +}; + +StatsExprContext.prototype.aliasId = function () { + return this.getTypedRuleContext(AliasIdContext, 0); +}; + +StatsExprContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterStatsExpr(this); + } +}; + +StatsExprContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitStatsExpr(this); + } +}; + +ScrollQLParser.StatsExprContext = StatsExprContext; + +ScrollQLParser.prototype.statsExpr = function () { + var localctx = new StatsExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 30, ScrollQLParser.RULE_statsExpr); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 247; + localctx.expr = this.expressionRoot(); + this.state = 250; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_AS) { + this.state = 248; + this.match(ScrollQLParser.K_AS); + this.state = 249; + localctx.proj = this.aliasId(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function StatsGroupFieldContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_statsGroupField; + return this; +} + +StatsGroupFieldContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +StatsGroupFieldContext.prototype.constructor = StatsGroupFieldContext; + +StatsGroupFieldContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function StatsGroupFieldIdContext(parser, ctx) { + StatsGroupFieldContext.call(this, parser); + StatsGroupFieldContext.prototype.copyFrom.call(this, ctx); + return this; +} + +StatsGroupFieldIdContext.prototype = Object.create(StatsGroupFieldContext.prototype); +StatsGroupFieldIdContext.prototype.constructor = StatsGroupFieldIdContext; + +ScrollQLParser.StatsGroupFieldIdContext = StatsGroupFieldIdContext; + +StatsGroupFieldIdContext.prototype.fieldId = function () { + return this.getTypedRuleContext(FieldIdContext, 0); +}; +StatsGroupFieldIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterStatsGroupFieldId(this); + } +}; + +StatsGroupFieldIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitStatsGroupFieldId(this); + } +}; + +function StatsGroupFieldProjectionContext(parser, ctx) { + StatsGroupFieldContext.call(this, parser); + StatsGroupFieldContext.prototype.copyFrom.call(this, ctx); + return this; +} + +StatsGroupFieldProjectionContext.prototype = Object.create(StatsGroupFieldContext.prototype); +StatsGroupFieldProjectionContext.prototype.constructor = StatsGroupFieldProjectionContext; + +ScrollQLParser.StatsGroupFieldProjectionContext = StatsGroupFieldProjectionContext; + +StatsGroupFieldProjectionContext.prototype.fieldSpec = function () { + return this.getTypedRuleContext(FieldSpecContext, 0); +}; +StatsGroupFieldProjectionContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterStatsGroupFieldProjection(this); + } +}; + +StatsGroupFieldProjectionContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitStatsGroupFieldProjection(this); + } +}; + +ScrollQLParser.StatsGroupFieldContext = StatsGroupFieldContext; + +ScrollQLParser.prototype.statsGroupField = function () { + var localctx = new StatsGroupFieldContext(this, this._ctx, this.state); + this.enterRule(localctx, 32, ScrollQLParser.RULE_statsGroupField); + try { + this.state = 254; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 22, this._ctx); + switch (la_) { + case 1: + localctx = new StatsGroupFieldIdContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 252; + this.fieldId(); + break; + + case 2: + localctx = new StatsGroupFieldProjectionContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 253; + this.fieldSpec(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpFieldsContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpFields; + return this; +} + +LogOpFieldsContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpFieldsContext.prototype.constructor = LogOpFieldsContext; + +LogOpFieldsContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function LogOpFieldsDisplayContext(parser, ctx) { + LogOpFieldsContext.call(this, parser); + this._fieldSpec = null; // FieldSpecContext; + this.fields = []; // of FieldSpecContexts; + LogOpFieldsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +LogOpFieldsDisplayContext.prototype = Object.create(LogOpFieldsContext.prototype); +LogOpFieldsDisplayContext.prototype.constructor = LogOpFieldsDisplayContext; + +ScrollQLParser.LogOpFieldsDisplayContext = LogOpFieldsDisplayContext; + +LogOpFieldsDisplayContext.prototype.K_DISPLAY = function () { + return this.getToken(ScrollQLParser.K_DISPLAY, 0); +}; + +LogOpFieldsDisplayContext.prototype.fieldSpec = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(FieldSpecContext); + } else { + return this.getTypedRuleContext(FieldSpecContext, i); + } +}; + +LogOpFieldsDisplayContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogOpFieldsDisplayContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpFieldsDisplay(this); + } +}; + +LogOpFieldsDisplayContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpFieldsDisplay(this); + } +}; + +function LogOpFieldsFieldsContext(parser, ctx) { + LogOpFieldsContext.call(this, parser); + this._fieldSpec = null; // FieldSpecContext; + this.fields = []; // of FieldSpecContexts; + LogOpFieldsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +LogOpFieldsFieldsContext.prototype = Object.create(LogOpFieldsContext.prototype); +LogOpFieldsFieldsContext.prototype.constructor = LogOpFieldsFieldsContext; + +ScrollQLParser.LogOpFieldsFieldsContext = LogOpFieldsFieldsContext; + +LogOpFieldsFieldsContext.prototype.K_FIELDS = function () { + return this.getToken(ScrollQLParser.K_FIELDS, 0); +}; + +LogOpFieldsFieldsContext.prototype.fieldSpec = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(FieldSpecContext); + } else { + return this.getTypedRuleContext(FieldSpecContext, i); + } +}; + +LogOpFieldsFieldsContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogOpFieldsFieldsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpFieldsFields(this); + } +}; + +LogOpFieldsFieldsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpFieldsFields(this); + } +}; + +ScrollQLParser.LogOpFieldsContext = LogOpFieldsContext; + +ScrollQLParser.prototype.logOpFields = function () { + var localctx = new LogOpFieldsContext(this, this._ctx, this.state); + this.enterRule(localctx, 34, ScrollQLParser.RULE_logOpFields); + var _la = 0; // Token type + try { + this.state = 274; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_FIELDS: + localctx = new LogOpFieldsFieldsContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 256; + this.match(ScrollQLParser.K_FIELDS); + this.state = 257; + localctx._fieldSpec = this.fieldSpec(); + localctx.fields.push(localctx._fieldSpec); + this.state = 262; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 258; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 259; + localctx._fieldSpec = this.fieldSpec(); + localctx.fields.push(localctx._fieldSpec); + this.state = 264; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + break; + case ScrollQLParser.K_DISPLAY: + localctx = new LogOpFieldsDisplayContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 265; + this.match(ScrollQLParser.K_DISPLAY); + this.state = 266; + localctx._fieldSpec = this.fieldSpec(); + localctx.fields.push(localctx._fieldSpec); + this.state = 271; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 267; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 268; + localctx._fieldSpec = this.fieldSpec(); + localctx.fields.push(localctx._fieldSpec); + this.state = 273; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function FieldSpecContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_fieldSpec; + this.proj = null; // AliasIdContext + return this; +} + +FieldSpecContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +FieldSpecContext.prototype.constructor = FieldSpecContext; + +FieldSpecContext.prototype.expressionRoot = function () { + return this.getTypedRuleContext(ExpressionRootContext, 0); +}; + +FieldSpecContext.prototype.K_AS = function () { + return this.getToken(ScrollQLParser.K_AS, 0); +}; + +FieldSpecContext.prototype.aliasId = function () { + return this.getTypedRuleContext(AliasIdContext, 0); +}; + +FieldSpecContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFieldSpec(this); + } +}; + +FieldSpecContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFieldSpec(this); + } +}; + +ScrollQLParser.FieldSpecContext = FieldSpecContext; + +ScrollQLParser.prototype.fieldSpec = function () { + var localctx = new FieldSpecContext(this, this._ctx, this.state); + this.enterRule(localctx, 36, ScrollQLParser.RULE_fieldSpec); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 276; + this.expressionRoot(); + this.state = 279; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_AS) { + this.state = 277; + this.match(ScrollQLParser.K_AS); + this.state = 278; + localctx.proj = this.aliasId(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpParseContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpParse; + this.field = null; // FieldIdContext + this.anchor = null; // StringContext + this._aliasId = null; // AliasIdContext + this.proj = []; // of AliasIdContexts + this.re = null; // RegexContext + return this; +} + +LogOpParseContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpParseContext.prototype.constructor = LogOpParseContext; + +LogOpParseContext.prototype.K_PARSE = function () { + return this.getToken(ScrollQLParser.K_PARSE, 0); +}; + +LogOpParseContext.prototype.K_AS = function () { + return this.getToken(ScrollQLParser.K_AS, 0); +}; + +LogOpParseContext.prototype.string = function () { + return this.getTypedRuleContext(StringContext, 0); +}; + +LogOpParseContext.prototype.aliasId = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(AliasIdContext); + } else { + return this.getTypedRuleContext(AliasIdContext, i); + } +}; + +LogOpParseContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogOpParseContext.prototype.fieldId = function () { + return this.getTypedRuleContext(FieldIdContext, 0); +}; + +LogOpParseContext.prototype.regex = function () { + return this.getTypedRuleContext(RegexContext, 0); +}; + +LogOpParseContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpParse(this); + } +}; + +LogOpParseContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpParse(this); + } +}; + +ScrollQLParser.LogOpParseContext = LogOpParseContext; + +ScrollQLParser.prototype.logOpParse = function () { + var localctx = new LogOpParseContext(this, this._ctx, this.state); + this.enterRule(localctx, 38, ScrollQLParser.RULE_logOpParse); + var _la = 0; // Token type + try { + this.state = 300; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 30, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 281; + this.match(ScrollQLParser.K_PARSE); + this.state = 283; + this._errHandler.sync(this); + _la = this._input.LA(1); + if ( + (((_la - 3) & ~0x1f) == 0 && + ((1 << (_la - 3)) & + ((1 << (ScrollQLParser.K_SOURCE - 3)) | + (1 << (ScrollQLParser.K_START - 3)) | + (1 << (ScrollQLParser.K_END - 3)) | + (1 << (ScrollQLParser.K_NOW - 3)) | + (1 << (ScrollQLParser.K_LIVE - 3)) | + (1 << (ScrollQLParser.K_PARSE - 3)) | + (1 << (ScrollQLParser.K_SEARCH - 3)) | + (1 << (ScrollQLParser.K_FIELDS - 3)) | + (1 << (ScrollQLParser.K_DISPLAY - 3)) | + (1 << (ScrollQLParser.K_FILTER - 3)) | + (1 << (ScrollQLParser.K_STATS - 3)) | + (1 << (ScrollQLParser.K_SORT - 3)) | + (1 << (ScrollQLParser.K_ORDER - 3)) | + (1 << (ScrollQLParser.K_ASC - 3)) | + (1 << (ScrollQLParser.K_DESC - 3)) | + (1 << (ScrollQLParser.K_HEAD - 3)) | + (1 << (ScrollQLParser.K_LIMIT - 3)) | + (1 << (ScrollQLParser.K_TAIL - 3)) | + (1 << (ScrollQLParser.K_REGEX - 3)) | + (1 << (ScrollQLParser.K_IN - 3)) | + (1 << (ScrollQLParser.K_GROUP - 3)) | + (1 << (ScrollQLParser.K_BY - 3)) | + (1 << (ScrollQLParser.K_AS - 3)) | + (1 << (ScrollQLParser.K_AND - 3)) | + (1 << (ScrollQLParser.K_OR - 3)) | + (1 << (ScrollQLParser.K_NOT - 3)) | + (1 << (ScrollQLParser.K_LIKE - 3)) | + (1 << (ScrollQLParser.K_MATCHES - 3)) | + (1 << (ScrollQLParser.K_TU_MS - 3)) | + (1 << (ScrollQLParser.K_TU_MSEC - 3)) | + (1 << (ScrollQLParser.K_TU_MSECOND - 3)) | + (1 << (ScrollQLParser.K_TU_S - 3)))) !== + 0) || + (((_la - 35) & ~0x1f) == 0 && + ((1 << (_la - 35)) & + ((1 << (ScrollQLParser.K_TU_SEC - 35)) | + (1 << (ScrollQLParser.K_TU_SECOND - 35)) | + (1 << (ScrollQLParser.K_TU_M - 35)) | + (1 << (ScrollQLParser.K_TU_MIN - 35)) | + (1 << (ScrollQLParser.K_TU_MINUTE - 35)) | + (1 << (ScrollQLParser.K_TU_H - 35)) | + (1 << (ScrollQLParser.K_TU_HR - 35)) | + (1 << (ScrollQLParser.K_TU_HOUR - 35)) | + (1 << (ScrollQLParser.K_TU_D - 35)) | + (1 << (ScrollQLParser.K_TU_DAY - 35)) | + (1 << (ScrollQLParser.K_TU_W - 35)) | + (1 << (ScrollQLParser.K_TU_WEEK - 35)) | + (1 << (ScrollQLParser.K_TU_MO - 35)) | + (1 << (ScrollQLParser.K_TU_MON - 35)) | + (1 << (ScrollQLParser.K_TU_MONTH - 35)) | + (1 << (ScrollQLParser.K_TU_Q - 35)) | + (1 << (ScrollQLParser.K_TU_QTR - 35)) | + (1 << (ScrollQLParser.K_TU_QUARTER - 35)) | + (1 << (ScrollQLParser.K_TU_Y - 35)) | + (1 << (ScrollQLParser.K_TU_YR - 35)) | + (1 << (ScrollQLParser.K_TU_YEAR - 35)) | + (1 << (ScrollQLParser.RAW_ID - 35)) | + (1 << (ScrollQLParser.QUOTED_IDENT - 35)) | + (1 << (ScrollQLParser.SYM_AT - 35)))) !== + 0) || + (((_la - 96) & ~0x1f) == 0 && + ((1 << (_la - 96)) & + ((1 << (ScrollQLParser.RE_RAW_ID - 96)) | + (1 << (ScrollQLParser.RE_SYM_AT - 96)) | + (1 << (ScrollQLParser.RE_QUOTED_IDENT - 96)))) !== + 0) + ) { + this.state = 282; + localctx.field = this.fieldId(); + } + + this.state = 285; + localctx.anchor = this.string(); + this.state = 286; + this.match(ScrollQLParser.K_AS); + this.state = 287; + localctx._aliasId = this.aliasId(); + localctx.proj.push(localctx._aliasId); + this.state = 292; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 288; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 289; + localctx._aliasId = this.aliasId(); + localctx.proj.push(localctx._aliasId); + this.state = 294; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 295; + this.match(ScrollQLParser.K_PARSE); + this.state = 297; + this._errHandler.sync(this); + _la = this._input.LA(1); + if ( + (((_la - 3) & ~0x1f) == 0 && + ((1 << (_la - 3)) & + ((1 << (ScrollQLParser.K_SOURCE - 3)) | + (1 << (ScrollQLParser.K_START - 3)) | + (1 << (ScrollQLParser.K_END - 3)) | + (1 << (ScrollQLParser.K_NOW - 3)) | + (1 << (ScrollQLParser.K_LIVE - 3)) | + (1 << (ScrollQLParser.K_PARSE - 3)) | + (1 << (ScrollQLParser.K_SEARCH - 3)) | + (1 << (ScrollQLParser.K_FIELDS - 3)) | + (1 << (ScrollQLParser.K_DISPLAY - 3)) | + (1 << (ScrollQLParser.K_FILTER - 3)) | + (1 << (ScrollQLParser.K_STATS - 3)) | + (1 << (ScrollQLParser.K_SORT - 3)) | + (1 << (ScrollQLParser.K_ORDER - 3)) | + (1 << (ScrollQLParser.K_ASC - 3)) | + (1 << (ScrollQLParser.K_DESC - 3)) | + (1 << (ScrollQLParser.K_HEAD - 3)) | + (1 << (ScrollQLParser.K_LIMIT - 3)) | + (1 << (ScrollQLParser.K_TAIL - 3)) | + (1 << (ScrollQLParser.K_REGEX - 3)) | + (1 << (ScrollQLParser.K_IN - 3)) | + (1 << (ScrollQLParser.K_GROUP - 3)) | + (1 << (ScrollQLParser.K_BY - 3)) | + (1 << (ScrollQLParser.K_AS - 3)) | + (1 << (ScrollQLParser.K_AND - 3)) | + (1 << (ScrollQLParser.K_OR - 3)) | + (1 << (ScrollQLParser.K_NOT - 3)) | + (1 << (ScrollQLParser.K_LIKE - 3)) | + (1 << (ScrollQLParser.K_MATCHES - 3)) | + (1 << (ScrollQLParser.K_TU_MS - 3)) | + (1 << (ScrollQLParser.K_TU_MSEC - 3)) | + (1 << (ScrollQLParser.K_TU_MSECOND - 3)) | + (1 << (ScrollQLParser.K_TU_S - 3)))) !== + 0) || + (((_la - 35) & ~0x1f) == 0 && + ((1 << (_la - 35)) & + ((1 << (ScrollQLParser.K_TU_SEC - 35)) | + (1 << (ScrollQLParser.K_TU_SECOND - 35)) | + (1 << (ScrollQLParser.K_TU_M - 35)) | + (1 << (ScrollQLParser.K_TU_MIN - 35)) | + (1 << (ScrollQLParser.K_TU_MINUTE - 35)) | + (1 << (ScrollQLParser.K_TU_H - 35)) | + (1 << (ScrollQLParser.K_TU_HR - 35)) | + (1 << (ScrollQLParser.K_TU_HOUR - 35)) | + (1 << (ScrollQLParser.K_TU_D - 35)) | + (1 << (ScrollQLParser.K_TU_DAY - 35)) | + (1 << (ScrollQLParser.K_TU_W - 35)) | + (1 << (ScrollQLParser.K_TU_WEEK - 35)) | + (1 << (ScrollQLParser.K_TU_MO - 35)) | + (1 << (ScrollQLParser.K_TU_MON - 35)) | + (1 << (ScrollQLParser.K_TU_MONTH - 35)) | + (1 << (ScrollQLParser.K_TU_Q - 35)) | + (1 << (ScrollQLParser.K_TU_QTR - 35)) | + (1 << (ScrollQLParser.K_TU_QUARTER - 35)) | + (1 << (ScrollQLParser.K_TU_Y - 35)) | + (1 << (ScrollQLParser.K_TU_YR - 35)) | + (1 << (ScrollQLParser.K_TU_YEAR - 35)) | + (1 << (ScrollQLParser.RAW_ID - 35)) | + (1 << (ScrollQLParser.QUOTED_IDENT - 35)) | + (1 << (ScrollQLParser.SYM_AT - 35)))) !== + 0) || + (((_la - 96) & ~0x1f) == 0 && + ((1 << (_la - 96)) & + ((1 << (ScrollQLParser.RE_RAW_ID - 96)) | + (1 << (ScrollQLParser.RE_SYM_AT - 96)) | + (1 << (ScrollQLParser.RE_QUOTED_IDENT - 96)))) !== + 0) + ) { + this.state = 296; + localctx.field = this.fieldId(); + } + + this.state = 299; + localctx.re = this.regex(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpSearchContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpSearch; + return this; +} + +LogOpSearchContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpSearchContext.prototype.constructor = LogOpSearchContext; + +LogOpSearchContext.prototype.K_SEARCH = function () { + return this.getToken(ScrollQLParser.K_SEARCH, 0); +}; + +LogOpSearchContext.prototype.searchExpr = function () { + return this.getTypedRuleContext(SearchExprContext, 0); +}; + +LogOpSearchContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpSearch(this); + } +}; + +LogOpSearchContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpSearch(this); + } +}; + +ScrollQLParser.LogOpSearchContext = LogOpSearchContext; + +ScrollQLParser.prototype.logOpSearch = function () { + var localctx = new LogOpSearchContext(this, this._ctx, this.state); + this.enterRule(localctx, 40, ScrollQLParser.RULE_logOpSearch); + try { + this.enterOuterAlt(localctx, 1); + this.state = 302; + this.match(ScrollQLParser.K_SEARCH); + this.state = 303; + this.searchExpr(0); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ImplicitLogOpSearchContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_implicitLogOpSearch; + return this; +} + +ImplicitLogOpSearchContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ImplicitLogOpSearchContext.prototype.constructor = ImplicitLogOpSearchContext; + +ImplicitLogOpSearchContext.prototype.searchExpr = function () { + return this.getTypedRuleContext(SearchExprContext, 0); +}; + +ImplicitLogOpSearchContext.prototype.SE_K_SEARCH = function () { + return this.getToken(ScrollQLParser.SE_K_SEARCH, 0); +}; + +ImplicitLogOpSearchContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterImplicitLogOpSearch(this); + } +}; + +ImplicitLogOpSearchContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitImplicitLogOpSearch(this); + } +}; + +ScrollQLParser.ImplicitLogOpSearchContext = ImplicitLogOpSearchContext; + +ScrollQLParser.prototype.implicitLogOpSearch = function () { + var localctx = new ImplicitLogOpSearchContext(this, this._ctx, this.state); + this.enterRule(localctx, 42, ScrollQLParser.RULE_implicitLogOpSearch); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 306; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SE_K_SEARCH) { + this.state = 305; + this.match(ScrollQLParser.SE_K_SEARCH); + } + + this.state = 308; + this.searchExpr(0); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function SearchExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_searchExpr; + return this; +} + +SearchExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +SearchExprContext.prototype.constructor = SearchExprContext; + +SearchExprContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function SearchExprTermContext(parser, ctx) { + SearchExprContext.call(this, parser); + this.t = null; // SearchTermContext; + SearchExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SearchExprTermContext.prototype = Object.create(SearchExprContext.prototype); +SearchExprTermContext.prototype.constructor = SearchExprTermContext; + +ScrollQLParser.SearchExprTermContext = SearchExprTermContext; + +SearchExprTermContext.prototype.searchTerm = function () { + return this.getTypedRuleContext(SearchTermContext, 0); +}; +SearchExprTermContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchExprTerm(this); + } +}; + +SearchExprTermContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchExprTerm(this); + } +}; + +function SearchExprNotContext(parser, ctx) { + SearchExprContext.call(this, parser); + SearchExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SearchExprNotContext.prototype = Object.create(SearchExprContext.prototype); +SearchExprNotContext.prototype.constructor = SearchExprNotContext; + +ScrollQLParser.SearchExprNotContext = SearchExprNotContext; + +SearchExprNotContext.prototype.SE_K_NOT = function () { + return this.getToken(ScrollQLParser.SE_K_NOT, 0); +}; + +SearchExprNotContext.prototype.searchExpr = function () { + return this.getTypedRuleContext(SearchExprContext, 0); +}; +SearchExprNotContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchExprNot(this); + } +}; + +SearchExprNotContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchExprNot(this); + } +}; + +function SearchExprAndContext(parser, ctx) { + SearchExprContext.call(this, parser); + SearchExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SearchExprAndContext.prototype = Object.create(SearchExprContext.prototype); +SearchExprAndContext.prototype.constructor = SearchExprAndContext; + +ScrollQLParser.SearchExprAndContext = SearchExprAndContext; + +SearchExprAndContext.prototype.searchExpr = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(SearchExprContext); + } else { + return this.getTypedRuleContext(SearchExprContext, i); + } +}; + +SearchExprAndContext.prototype.SE_K_AND = function () { + return this.getToken(ScrollQLParser.SE_K_AND, 0); +}; +SearchExprAndContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchExprAnd(this); + } +}; + +SearchExprAndContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchExprAnd(this); + } +}; + +function SearchExprNestedContext(parser, ctx) { + SearchExprContext.call(this, parser); + SearchExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SearchExprNestedContext.prototype = Object.create(SearchExprContext.prototype); +SearchExprNestedContext.prototype.constructor = SearchExprNestedContext; + +ScrollQLParser.SearchExprNestedContext = SearchExprNestedContext; + +SearchExprNestedContext.prototype.SE_SYM_LPAREN = function () { + return this.getToken(ScrollQLParser.SE_SYM_LPAREN, 0); +}; + +SearchExprNestedContext.prototype.searchExpr = function () { + return this.getTypedRuleContext(SearchExprContext, 0); +}; + +SearchExprNestedContext.prototype.SE_SYM_RPAREN = function () { + return this.getToken(ScrollQLParser.SE_SYM_RPAREN, 0); +}; +SearchExprNestedContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchExprNested(this); + } +}; + +SearchExprNestedContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchExprNested(this); + } +}; + +function SearchExprOrContext(parser, ctx) { + SearchExprContext.call(this, parser); + SearchExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SearchExprOrContext.prototype = Object.create(SearchExprContext.prototype); +SearchExprOrContext.prototype.constructor = SearchExprOrContext; + +ScrollQLParser.SearchExprOrContext = SearchExprOrContext; + +SearchExprOrContext.prototype.searchExpr = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(SearchExprContext); + } else { + return this.getTypedRuleContext(SearchExprContext, i); + } +}; + +SearchExprOrContext.prototype.SE_K_OR = function () { + return this.getToken(ScrollQLParser.SE_K_OR, 0); +}; +SearchExprOrContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchExprOr(this); + } +}; + +SearchExprOrContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchExprOr(this); + } +}; + +ScrollQLParser.prototype.searchExpr = function (_p) { + if (_p === undefined) { + _p = 0; + } + var _parentctx = this._ctx; + var _parentState = this.state; + var localctx = new SearchExprContext(this, this._ctx, _parentState); + var _prevctx = localctx; + var _startState = 44; + this.enterRecursionRule(localctx, 44, ScrollQLParser.RULE_searchExpr, _p); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 318; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SE_K_NOT: + localctx = new SearchExprNotContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + + this.state = 311; + this.match(ScrollQLParser.SE_K_NOT); + this.state = 312; + this.searchExpr(5); + break; + case ScrollQLParser.SE_SYM_LPAREN: + localctx = new SearchExprNestedContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 313; + this.match(ScrollQLParser.SE_SYM_LPAREN); + this.state = 314; + this.searchExpr(0); + this.state = 315; + this.match(ScrollQLParser.SE_SYM_RPAREN); + break; + case ScrollQLParser.SE_SDQUOTED_STRING: + case ScrollQLParser.SE_SSQUOTED_STRING: + case ScrollQLParser.SE_CDQUOTED_STRING: + case ScrollQLParser.SE_CSQUOTED_STRING: + case ScrollQLParser.SE_UNQUOTED_STRING: + localctx = new SearchExprTermContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 317; + localctx.t = this.searchTerm(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + this._ctx.stop = this._input.LT(-1); + this.state = 330; + this._errHandler.sync(this); + var _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); + while (_alt != 2 && _alt != antlr4.atn.ATN.INVALID_ALT_NUMBER) { + if (_alt === 1) { + if (this._parseListeners !== null) { + this.triggerExitRuleEvent(); + } + _prevctx = localctx; + this.state = 328; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 34, this._ctx); + switch (la_) { + case 1: + localctx = new SearchExprAndContext(this, new SearchExprContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_searchExpr); + this.state = 320; + if (!this.precpred(this._ctx, 3)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 3)'); + } + this.state = 322; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SE_K_AND) { + this.state = 321; + this.match(ScrollQLParser.SE_K_AND); + } + + this.state = 324; + this.searchExpr(4); + break; + + case 2: + localctx = new SearchExprOrContext(this, new SearchExprContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_searchExpr); + this.state = 325; + if (!this.precpred(this._ctx, 2)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 2)'); + } + this.state = 326; + this.match(ScrollQLParser.SE_K_OR); + this.state = 327; + this.searchExpr(3); + break; + } + } + this.state = 332; + this._errHandler.sync(this); + _alt = this._interp.adaptivePredict(this._input, 35, this._ctx); + } + } catch (error) { + if (error instanceof antlr4.error.RecognitionException) { + localctx.exception = error; + this._errHandler.reportError(this, error); + this._errHandler.recover(this, error); + } else { + throw error; + } + } finally { + this.unrollRecursionContexts(_parentctx); + } + return localctx; +}; + +function SearchTermContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_searchTerm; + this.bstr = null; // Token + this.sdqstr = null; // Token + this.ssqstr = null; // Token + this.cdqstr = null; // Token + this.csqstr = null; // Token + return this; +} + +SearchTermContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +SearchTermContext.prototype.constructor = SearchTermContext; + +SearchTermContext.prototype.SE_UNQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SE_UNQUOTED_STRING, 0); +}; + +SearchTermContext.prototype.SE_SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SE_SDQUOTED_STRING, 0); +}; + +SearchTermContext.prototype.SE_SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SE_SSQUOTED_STRING, 0); +}; + +SearchTermContext.prototype.SE_CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SE_CDQUOTED_STRING, 0); +}; + +SearchTermContext.prototype.SE_CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SE_CSQUOTED_STRING, 0); +}; + +SearchTermContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSearchTerm(this); + } +}; + +SearchTermContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSearchTerm(this); + } +}; + +ScrollQLParser.SearchTermContext = SearchTermContext; + +ScrollQLParser.prototype.searchTerm = function () { + var localctx = new SearchTermContext(this, this._ctx, this.state); + this.enterRule(localctx, 46, ScrollQLParser.RULE_searchTerm); + try { + this.state = 338; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SE_UNQUOTED_STRING: + this.enterOuterAlt(localctx, 1); + this.state = 333; + localctx.bstr = this.match(ScrollQLParser.SE_UNQUOTED_STRING); + break; + case ScrollQLParser.SE_SDQUOTED_STRING: + this.enterOuterAlt(localctx, 2); + this.state = 334; + localctx.sdqstr = this.match(ScrollQLParser.SE_SDQUOTED_STRING); + break; + case ScrollQLParser.SE_SSQUOTED_STRING: + this.enterOuterAlt(localctx, 3); + this.state = 335; + localctx.ssqstr = this.match(ScrollQLParser.SE_SSQUOTED_STRING); + break; + case ScrollQLParser.SE_CDQUOTED_STRING: + this.enterOuterAlt(localctx, 4); + this.state = 336; + localctx.cdqstr = this.match(ScrollQLParser.SE_CDQUOTED_STRING); + break; + case ScrollQLParser.SE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 5); + this.state = 337; + localctx.csqstr = this.match(ScrollQLParser.SE_CSQUOTED_STRING); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpFilterContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpFilter; + return this; +} + +LogOpFilterContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpFilterContext.prototype.constructor = LogOpFilterContext; + +LogOpFilterContext.prototype.K_FILTER = function () { + return this.getToken(ScrollQLParser.K_FILTER, 0); +}; + +LogOpFilterContext.prototype.expressionRoot = function () { + return this.getTypedRuleContext(ExpressionRootContext, 0); +}; + +LogOpFilterContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpFilter(this); + } +}; + +LogOpFilterContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpFilter(this); + } +}; + +ScrollQLParser.LogOpFilterContext = LogOpFilterContext; + +ScrollQLParser.prototype.logOpFilter = function () { + var localctx = new LogOpFilterContext(this, this._ctx, this.state); + this.enterRule(localctx, 48, ScrollQLParser.RULE_logOpFilter); + try { + this.enterOuterAlt(localctx, 1); + this.state = 340; + this.match(ScrollQLParser.K_FILTER); + this.state = 341; + this.expressionRoot(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpSortContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpSort; + return this; +} + +LogOpSortContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpSortContext.prototype.constructor = LogOpSortContext; + +LogOpSortContext.prototype.sortExpr = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(SortExprContext); + } else { + return this.getTypedRuleContext(SortExprContext, i); + } +}; + +LogOpSortContext.prototype.K_SORT = function () { + return this.getToken(ScrollQLParser.K_SORT, 0); +}; + +LogOpSortContext.prototype.K_ORDER = function () { + return this.getToken(ScrollQLParser.K_ORDER, 0); +}; + +LogOpSortContext.prototype.K_BY = function () { + return this.getToken(ScrollQLParser.K_BY, 0); +}; + +LogOpSortContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +LogOpSortContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpSort(this); + } +}; + +LogOpSortContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpSort(this); + } +}; + +ScrollQLParser.LogOpSortContext = LogOpSortContext; + +ScrollQLParser.prototype.logOpSort = function () { + var localctx = new LogOpSortContext(this, this._ctx, this.state); + this.enterRule(localctx, 50, ScrollQLParser.RULE_logOpSort); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 343; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.K_SORT || _la === ScrollQLParser.K_ORDER)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 345; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 37, this._ctx); + if (la_ === 1) { + this.state = 344; + this.match(ScrollQLParser.K_BY); + } + this.state = 347; + this.sortExpr(); + this.state = 352; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 348; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 349; + this.sortExpr(); + this.state = 354; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function SortExprContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_sortExpr; + return this; +} + +SortExprContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +SortExprContext.prototype.constructor = SortExprContext; + +SortExprContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function SortExprDescContext(parser, ctx) { + SortExprContext.call(this, parser); + this.did = null; // FieldIdContext; + SortExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SortExprDescContext.prototype = Object.create(SortExprContext.prototype); +SortExprDescContext.prototype.constructor = SortExprDescContext; + +ScrollQLParser.SortExprDescContext = SortExprDescContext; + +SortExprDescContext.prototype.K_DESC = function () { + return this.getToken(ScrollQLParser.K_DESC, 0); +}; + +SortExprDescContext.prototype.fieldId = function () { + return this.getTypedRuleContext(FieldIdContext, 0); +}; +SortExprDescContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSortExprDesc(this); + } +}; + +SortExprDescContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSortExprDesc(this); + } +}; + +function SortExprAscContext(parser, ctx) { + SortExprContext.call(this, parser); + this.aid = null; // FieldIdContext; + SortExprContext.prototype.copyFrom.call(this, ctx); + return this; +} + +SortExprAscContext.prototype = Object.create(SortExprContext.prototype); +SortExprAscContext.prototype.constructor = SortExprAscContext; + +ScrollQLParser.SortExprAscContext = SortExprAscContext; + +SortExprAscContext.prototype.fieldId = function () { + return this.getTypedRuleContext(FieldIdContext, 0); +}; + +SortExprAscContext.prototype.K_ASC = function () { + return this.getToken(ScrollQLParser.K_ASC, 0); +}; +SortExprAscContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSortExprAsc(this); + } +}; + +SortExprAscContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSortExprAsc(this); + } +}; + +ScrollQLParser.SortExprContext = SortExprContext; + +ScrollQLParser.prototype.sortExpr = function () { + var localctx = new SortExprContext(this, this._ctx, this.state); + this.enterRule(localctx, 52, ScrollQLParser.RULE_sortExpr); + var _la = 0; // Token type + try { + this.state = 362; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 40, this._ctx); + switch (la_) { + case 1: + localctx = new SortExprDescContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 355; + localctx.did = this.fieldId(); + this.state = 356; + this.match(ScrollQLParser.K_DESC); + break; + + case 2: + localctx = new SortExprAscContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 358; + localctx.aid = this.fieldId(); + this.state = 360; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_ASC) { + this.state = 359; + this.match(ScrollQLParser.K_ASC); + } + + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogOpLimitContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logOpLimit; + return this; +} + +LogOpLimitContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogOpLimitContext.prototype.constructor = LogOpLimitContext; + +LogOpLimitContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function LogOpLimitHeadContext(parser, ctx) { + LogOpLimitContext.call(this, parser); + this.n = null; // Token; + LogOpLimitContext.prototype.copyFrom.call(this, ctx); + return this; +} + +LogOpLimitHeadContext.prototype = Object.create(LogOpLimitContext.prototype); +LogOpLimitHeadContext.prototype.constructor = LogOpLimitHeadContext; + +ScrollQLParser.LogOpLimitHeadContext = LogOpLimitHeadContext; + +LogOpLimitHeadContext.prototype.K_LIMIT = function () { + return this.getToken(ScrollQLParser.K_LIMIT, 0); +}; + +LogOpLimitHeadContext.prototype.K_HEAD = function () { + return this.getToken(ScrollQLParser.K_HEAD, 0); +}; + +LogOpLimitHeadContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; +LogOpLimitHeadContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpLimitHead(this); + } +}; + +LogOpLimitHeadContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpLimitHead(this); + } +}; + +function LogOpLimitTailContext(parser, ctx) { + LogOpLimitContext.call(this, parser); + this.n = null; // Token; + LogOpLimitContext.prototype.copyFrom.call(this, ctx); + return this; +} + +LogOpLimitTailContext.prototype = Object.create(LogOpLimitContext.prototype); +LogOpLimitTailContext.prototype.constructor = LogOpLimitTailContext; + +ScrollQLParser.LogOpLimitTailContext = LogOpLimitTailContext; + +LogOpLimitTailContext.prototype.K_TAIL = function () { + return this.getToken(ScrollQLParser.K_TAIL, 0); +}; + +LogOpLimitTailContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; +LogOpLimitTailContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogOpLimitTail(this); + } +}; + +LogOpLimitTailContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogOpLimitTail(this); + } +}; + +ScrollQLParser.LogOpLimitContext = LogOpLimitContext; + +ScrollQLParser.prototype.logOpLimit = function () { + var localctx = new LogOpLimitContext(this, this._ctx, this.state); + this.enterRule(localctx, 54, ScrollQLParser.RULE_logOpLimit); + var _la = 0; // Token type + try { + this.state = 368; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + localctx = new LogOpLimitHeadContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 364; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.K_HEAD || _la === ScrollQLParser.K_LIMIT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 365; + localctx.n = this.match(ScrollQLParser.LIT_INTEGER); + break; + case ScrollQLParser.K_TAIL: + localctx = new LogOpLimitTailContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 366; + this.match(ScrollQLParser.K_TAIL); + this.state = 367; + localctx.n = this.match(ScrollQLParser.LIT_INTEGER); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ExpressionRootContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_expressionRoot; + return this; +} + +ExpressionRootContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ExpressionRootContext.prototype.constructor = ExpressionRootContext; + +ExpressionRootContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; + +ExpressionRootContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionRoot(this); + } +}; + +ExpressionRootContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionRoot(this); + } +}; + +ScrollQLParser.ExpressionRootContext = ExpressionRootContext; + +ScrollQLParser.prototype.expressionRoot = function () { + var localctx = new ExpressionRootContext(this, this._ctx, this.state); + this.enterRule(localctx, 56, ScrollQLParser.RULE_expressionRoot); + try { + this.enterOuterAlt(localctx, 1); + this.state = 370; + this.expression(0); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ExpressionContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_expression; + return this; +} + +ExpressionContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ExpressionContext.prototype.constructor = ExpressionContext; + +ExpressionContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function ExpressionAddSubContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.bop = null; // Token; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionAddSubContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionAddSubContext.prototype.constructor = ExpressionAddSubContext; + +ScrollQLParser.ExpressionAddSubContext = ExpressionAddSubContext; + +ExpressionAddSubContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; + +ExpressionAddSubContext.prototype.SYM_PLUS = function () { + return this.getToken(ScrollQLParser.SYM_PLUS, 0); +}; + +ExpressionAddSubContext.prototype.SYM_MINUS = function () { + return this.getToken(ScrollQLParser.SYM_MINUS, 0); +}; +ExpressionAddSubContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionAddSub(this); + } +}; + +ExpressionAddSubContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionAddSub(this); + } +}; + +function ExpressionEqContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.bop = null; // Token; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionEqContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionEqContext.prototype.constructor = ExpressionEqContext; + +ScrollQLParser.ExpressionEqContext = ExpressionEqContext; + +ExpressionEqContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; + +ExpressionEqContext.prototype.SYM_EQ = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_EQ); + } else { + return this.getToken(ScrollQLParser.SYM_EQ, i); + } +}; + +ExpressionEqContext.prototype.SYM_NEQ = function () { + return this.getToken(ScrollQLParser.SYM_NEQ, 0); +}; +ExpressionEqContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionEq(this); + } +}; + +ExpressionEqContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionEq(this); + } +}; + +function ExpressionCompContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.bop = null; // Token; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionCompContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionCompContext.prototype.constructor = ExpressionCompContext; + +ScrollQLParser.ExpressionCompContext = ExpressionCompContext; + +ExpressionCompContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; + +ExpressionCompContext.prototype.SYM_LTEQ = function () { + return this.getToken(ScrollQLParser.SYM_LTEQ, 0); +}; + +ExpressionCompContext.prototype.SYM_GTEQ = function () { + return this.getToken(ScrollQLParser.SYM_GTEQ, 0); +}; + +ExpressionCompContext.prototype.SYM_GT = function () { + return this.getToken(ScrollQLParser.SYM_GT, 0); +}; + +ExpressionCompContext.prototype.SYM_LT = function () { + return this.getToken(ScrollQLParser.SYM_LT, 0); +}; +ExpressionCompContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionComp(this); + } +}; + +ExpressionCompContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionComp(this); + } +}; + +function ExpressionExpoContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionExpoContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionExpoContext.prototype.constructor = ExpressionExpoContext; + +ScrollQLParser.ExpressionExpoContext = ExpressionExpoContext; + +ExpressionExpoContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; + +ExpressionExpoContext.prototype.SYM_CARET = function () { + return this.getToken(ScrollQLParser.SYM_CARET, 0); +}; +ExpressionExpoContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionExpo(this); + } +}; + +ExpressionExpoContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionExpo(this); + } +}; + +function ExpressionLikeContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.not = null; // Token; + this.rhs = null; // LikeTermContext; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionLikeContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionLikeContext.prototype.constructor = ExpressionLikeContext; + +ScrollQLParser.ExpressionLikeContext = ExpressionLikeContext; + +ExpressionLikeContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; + +ExpressionLikeContext.prototype.K_LIKE = function () { + return this.getToken(ScrollQLParser.K_LIKE, 0); +}; + +ExpressionLikeContext.prototype.SYM_EQTILDE = function () { + return this.getToken(ScrollQLParser.SYM_EQTILDE, 0); +}; + +ExpressionLikeContext.prototype.SYM_TILDEEQ = function () { + return this.getToken(ScrollQLParser.SYM_TILDEEQ, 0); +}; + +ExpressionLikeContext.prototype.likeTerm = function () { + return this.getTypedRuleContext(LikeTermContext, 0); +}; + +ExpressionLikeContext.prototype.K_NOT = function () { + return this.getToken(ScrollQLParser.K_NOT, 0); +}; +ExpressionLikeContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionLike(this); + } +}; + +ExpressionLikeContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionLike(this); + } +}; + +function ExpressionTermContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionTermContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionTermContext.prototype.constructor = ExpressionTermContext; + +ScrollQLParser.ExpressionTermContext = ExpressionTermContext; + +ExpressionTermContext.prototype.term = function () { + return this.getTypedRuleContext(TermContext, 0); +}; +ExpressionTermContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionTerm(this); + } +}; + +ExpressionTermContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionTerm(this); + } +}; + +function ExpressionNegContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionNegContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionNegContext.prototype.constructor = ExpressionNegContext; + +ScrollQLParser.ExpressionNegContext = ExpressionNegContext; + +ExpressionNegContext.prototype.SYM_MINUS = function () { + return this.getToken(ScrollQLParser.SYM_MINUS, 0); +}; + +ExpressionNegContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; +ExpressionNegContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionNeg(this); + } +}; + +ExpressionNegContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionNeg(this); + } +}; + +function ExpressionNotContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionNotContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionNotContext.prototype.constructor = ExpressionNotContext; + +ScrollQLParser.ExpressionNotContext = ExpressionNotContext; + +ExpressionNotContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; + +ExpressionNotContext.prototype.SYM_NOT = function () { + return this.getToken(ScrollQLParser.SYM_NOT, 0); +}; + +ExpressionNotContext.prototype.K_NOT = function () { + return this.getToken(ScrollQLParser.K_NOT, 0); +}; +ExpressionNotContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionNot(this); + } +}; + +ExpressionNotContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionNot(this); + } +}; + +function ExpressionPosContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionPosContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionPosContext.prototype.constructor = ExpressionPosContext; + +ScrollQLParser.ExpressionPosContext = ExpressionPosContext; + +ExpressionPosContext.prototype.SYM_PLUS = function () { + return this.getToken(ScrollQLParser.SYM_PLUS, 0); +}; + +ExpressionPosContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; +ExpressionPosContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionPos(this); + } +}; + +ExpressionPosContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionPos(this); + } +}; + +function ExpressionMulDivModContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.bop = null; // Token; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionMulDivModContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionMulDivModContext.prototype.constructor = ExpressionMulDivModContext; + +ScrollQLParser.ExpressionMulDivModContext = ExpressionMulDivModContext; + +ExpressionMulDivModContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; + +ExpressionMulDivModContext.prototype.SYM_MUL = function () { + return this.getToken(ScrollQLParser.SYM_MUL, 0); +}; + +ExpressionMulDivModContext.prototype.SYM_DIV = function () { + return this.getToken(ScrollQLParser.SYM_DIV, 0); +}; + +ExpressionMulDivModContext.prototype.SYM_MOD = function () { + return this.getToken(ScrollQLParser.SYM_MOD, 0); +}; +ExpressionMulDivModContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionMulDivMod(this); + } +}; + +ExpressionMulDivModContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionMulDivMod(this); + } +}; + +function ExpressionAndContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.lhs = null; // ExpressionContext; + this.rhs = null; // ExpressionContext; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionAndContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionAndContext.prototype.constructor = ExpressionAndContext; + +ScrollQLParser.ExpressionAndContext = ExpressionAndContext; + +ExpressionAndContext.prototype.K_AND = function () { + return this.getToken(ScrollQLParser.K_AND, 0); +}; + +ExpressionAndContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; +ExpressionAndContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionAnd(this); + } +}; + +ExpressionAndContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionAnd(this); + } +}; + +function ExpressionNestedContext(parser, ctx) { + ExpressionContext.call(this, parser); + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionNestedContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionNestedContext.prototype.constructor = ExpressionNestedContext; + +ScrollQLParser.ExpressionNestedContext = ExpressionNestedContext; + +ExpressionNestedContext.prototype.SYM_LPAREN = function () { + return this.getToken(ScrollQLParser.SYM_LPAREN, 0); +}; + +ExpressionNestedContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; + +ExpressionNestedContext.prototype.SYM_RPAREN = function () { + return this.getToken(ScrollQLParser.SYM_RPAREN, 0); +}; +ExpressionNestedContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionNested(this); + } +}; + +ExpressionNestedContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionNested(this); + } +}; + +function ExpressionOrContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.lhs = null; // ExpressionContext; + this.rhs = null; // ExpressionContext; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionOrContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionOrContext.prototype.constructor = ExpressionOrContext; + +ScrollQLParser.ExpressionOrContext = ExpressionOrContext; + +ExpressionOrContext.prototype.K_OR = function () { + return this.getToken(ScrollQLParser.K_OR, 0); +}; + +ExpressionOrContext.prototype.expression = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ExpressionContext); + } else { + return this.getTypedRuleContext(ExpressionContext, i); + } +}; +ExpressionOrContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionOr(this); + } +}; + +ExpressionOrContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionOr(this); + } +}; + +function ExpressionInContext(parser, ctx) { + ExpressionContext.call(this, parser); + this.lhs = null; // ExpressionContext; + this.not = null; // Token; + this.rhs = null; // ArrayContext; + ExpressionContext.prototype.copyFrom.call(this, ctx); + return this; +} + +ExpressionInContext.prototype = Object.create(ExpressionContext.prototype); +ExpressionInContext.prototype.constructor = ExpressionInContext; + +ScrollQLParser.ExpressionInContext = ExpressionInContext; + +ExpressionInContext.prototype.K_IN = function () { + return this.getToken(ScrollQLParser.K_IN, 0); +}; + +ExpressionInContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; + +ExpressionInContext.prototype.array = function () { + return this.getTypedRuleContext(ArrayContext, 0); +}; + +ExpressionInContext.prototype.K_NOT = function () { + return this.getToken(ScrollQLParser.K_NOT, 0); +}; +ExpressionInContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterExpressionIn(this); + } +}; + +ExpressionInContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitExpressionIn(this); + } +}; + +ScrollQLParser.prototype.expression = function (_p) { + if (_p === undefined) { + _p = 0; + } + var _parentctx = this._ctx; + var _parentState = this.state; + var localctx = new ExpressionContext(this, this._ctx, _parentState); + var _prevctx = localctx; + var _startState = 58; + this.enterRecursionRule(localctx, 58, ScrollQLParser.RULE_expression, _p); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 384; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 42, this._ctx); + switch (la_) { + case 1: + localctx = new ExpressionNestedContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + + this.state = 373; + this.match(ScrollQLParser.SYM_LPAREN); + this.state = 374; + this.expression(0); + this.state = 375; + this.match(ScrollQLParser.SYM_RPAREN); + break; + + case 2: + localctx = new ExpressionNotContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 377; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.K_NOT || _la === ScrollQLParser.SYM_NOT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 378; + this.expression(12); + break; + + case 3: + localctx = new ExpressionNegContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 379; + this.match(ScrollQLParser.SYM_MINUS); + this.state = 380; + this.expression(11); + break; + + case 4: + localctx = new ExpressionPosContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 381; + this.match(ScrollQLParser.SYM_PLUS); + this.state = 382; + this.expression(10); + break; + + case 5: + localctx = new ExpressionTermContext(this, localctx); + this._ctx = localctx; + _prevctx = localctx; + this.state = 383; + this.term(); + break; + } + this._ctx.stop = this._input.LT(-1); + this.state = 426; + this._errHandler.sync(this); + var _alt = this._interp.adaptivePredict(this._input, 47, this._ctx); + while (_alt != 2 && _alt != antlr4.atn.ATN.INVALID_ALT_NUMBER) { + if (_alt === 1) { + if (this._parseListeners !== null) { + this.triggerExitRuleEvent(); + } + _prevctx = localctx; + this.state = 424; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 46, this._ctx); + switch (la_) { + case 1: + localctx = new ExpressionExpoContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 386; + if (!this.precpred(this._ctx, 13)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 13)'); + } + this.state = 387; + this.match(ScrollQLParser.SYM_CARET); + this.state = 388; + this.expression(13); + break; + + case 2: + localctx = new ExpressionMulDivModContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 389; + if (!this.precpred(this._ctx, 9)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 9)'); + } + this.state = 390; + localctx.bop = this._input.LT(1); + _la = this._input.LA(1); + if ( + !( + ((_la - 75) & ~0x1f) == 0 && + ((1 << (_la - 75)) & + ((1 << (ScrollQLParser.SYM_MUL - 75)) | + (1 << (ScrollQLParser.SYM_DIV - 75)) | + (1 << (ScrollQLParser.SYM_MOD - 75)))) !== + 0 + ) + ) { + localctx.bop = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 391; + this.expression(10); + break; + + case 3: + localctx = new ExpressionAddSubContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 392; + if (!this.precpred(this._ctx, 8)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 8)'); + } + this.state = 393; + localctx.bop = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SYM_PLUS || _la === ScrollQLParser.SYM_MINUS)) { + localctx.bop = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 394; + this.expression(9); + break; + + case 4: + localctx = new ExpressionCompContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 395; + if (!this.precpred(this._ctx, 7)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 7)'); + } + this.state = 396; + localctx.bop = this._input.LT(1); + _la = this._input.LA(1); + if ( + !( + ((_la - 81) & ~0x1f) == 0 && + ((1 << (_la - 81)) & + ((1 << (ScrollQLParser.SYM_LT - 81)) | + (1 << (ScrollQLParser.SYM_GT - 81)) | + (1 << (ScrollQLParser.SYM_LTEQ - 81)) | + (1 << (ScrollQLParser.SYM_GTEQ - 81)))) !== + 0 + ) + ) { + localctx.bop = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 397; + this.expression(8); + break; + + case 5: + localctx = new ExpressionEqContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 398; + if (!this.precpred(this._ctx, 6)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 6)'); + } + this.state = 403; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 43, this._ctx); + switch (la_) { + case 1: + this.state = 399; + localctx.bop = this.match(ScrollQLParser.SYM_EQ); + this.state = 400; + this.match(ScrollQLParser.SYM_EQ); + break; + + case 2: + this.state = 401; + localctx.bop = this.match(ScrollQLParser.SYM_EQ); + break; + + case 3: + this.state = 402; + localctx.bop = this.match(ScrollQLParser.SYM_NEQ); + break; + } + this.state = 405; + this.expression(7); + break; + + case 6: + localctx = new ExpressionAndContext(this, new ExpressionContext(this, _parentctx, _parentState)); + localctx.lhs = _prevctx; + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 406; + if (!this.precpred(this._ctx, 3)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 3)'); + } + this.state = 407; + this.match(ScrollQLParser.K_AND); + this.state = 408; + localctx.rhs = this.expression(4); + break; + + case 7: + localctx = new ExpressionOrContext(this, new ExpressionContext(this, _parentctx, _parentState)); + localctx.lhs = _prevctx; + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 409; + if (!this.precpred(this._ctx, 2)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 2)'); + } + this.state = 410; + this.match(ScrollQLParser.K_OR); + this.state = 411; + localctx.rhs = this.expression(3); + break; + + case 8: + localctx = new ExpressionLikeContext(this, new ExpressionContext(this, _parentctx, _parentState)); + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 412; + if (!this.precpred(this._ctx, 5)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 5)'); + } + this.state = 414; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_NOT) { + this.state = 413; + localctx.not = this.match(ScrollQLParser.K_NOT); + } + + this.state = 416; + _la = this._input.LA(1); + if ( + !( + _la === ScrollQLParser.K_LIKE || + _la === ScrollQLParser.SYM_EQTILDE || + _la === ScrollQLParser.SYM_TILDEEQ + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 417; + localctx.rhs = this.likeTerm(); + break; + + case 9: + localctx = new ExpressionInContext(this, new ExpressionContext(this, _parentctx, _parentState)); + localctx.lhs = _prevctx; + this.pushNewRecursionContext(localctx, _startState, ScrollQLParser.RULE_expression); + this.state = 418; + if (!this.precpred(this._ctx, 4)) { + throw new antlr4.error.FailedPredicateException(this, 'this.precpred(this._ctx, 4)'); + } + this.state = 420; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.K_NOT) { + this.state = 419; + localctx.not = this.match(ScrollQLParser.K_NOT); + } + + this.state = 422; + this.match(ScrollQLParser.K_IN); + this.state = 423; + localctx.rhs = this.array(); + break; + } + } + this.state = 428; + this._errHandler.sync(this); + _alt = this._interp.adaptivePredict(this._input, 47, this._ctx); + } + } catch (error) { + if (error instanceof antlr4.error.RecognitionException) { + localctx.exception = error; + this._errHandler.reportError(this, error); + this._errHandler.recover(this, error); + } else { + throw error; + } + } finally { + this.unrollRecursionContexts(_parentctx); + } + return localctx; +}; + +function TermContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_term; + return this; +} + +TermContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +TermContext.prototype.constructor = TermContext; + +TermContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function TermStrContext(parser, ctx) { + TermContext.call(this, parser); + this.str = null; // StringOrBareStringContext; + TermContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TermStrContext.prototype = Object.create(TermContext.prototype); +TermStrContext.prototype.constructor = TermStrContext; + +ScrollQLParser.TermStrContext = TermStrContext; + +TermStrContext.prototype.stringOrBareString = function () { + return this.getTypedRuleContext(StringOrBareStringContext, 0); +}; +TermStrContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTermStr(this); + } +}; + +TermStrContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTermStr(this); + } +}; + +function TermIdContext(parser, ctx) { + TermContext.call(this, parser); + this.fid = null; // FieldIdContext; + TermContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TermIdContext.prototype = Object.create(TermContext.prototype); +TermIdContext.prototype.constructor = TermIdContext; + +ScrollQLParser.TermIdContext = TermIdContext; + +TermIdContext.prototype.fieldId = function () { + return this.getTypedRuleContext(FieldIdContext, 0); +}; +TermIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTermId(this); + } +}; + +TermIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTermId(this); + } +}; + +function TermFnContext(parser, ctx) { + TermContext.call(this, parser); + this.fn = null; // FuncContext; + TermContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TermFnContext.prototype = Object.create(TermContext.prototype); +TermFnContext.prototype.constructor = TermFnContext; + +ScrollQLParser.TermFnContext = TermFnContext; + +TermFnContext.prototype.func = function () { + return this.getTypedRuleContext(FuncContext, 0); +}; +TermFnContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTermFn(this); + } +}; + +TermFnContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTermFn(this); + } +}; + +function TermNumContext(parser, ctx) { + TermContext.call(this, parser); + this.num = null; // NumberContext; + TermContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TermNumContext.prototype = Object.create(TermContext.prototype); +TermNumContext.prototype.constructor = TermNumContext; + +ScrollQLParser.TermNumContext = TermNumContext; + +TermNumContext.prototype.number = function () { + return this.getTypedRuleContext(NumberContext, 0); +}; +TermNumContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTermNum(this); + } +}; + +TermNumContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTermNum(this); + } +}; + +ScrollQLParser.TermContext = TermContext; + +ScrollQLParser.prototype.term = function () { + var localctx = new TermContext(this, this._ctx, this.state); + this.enterRule(localctx, 60, ScrollQLParser.RULE_term); + try { + this.state = 433; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 48, this._ctx); + switch (la_) { + case 1: + localctx = new TermIdContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 429; + localctx.fid = this.fieldId(); + break; + + case 2: + localctx = new TermNumContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 430; + localctx.num = this.number(); + break; + + case 3: + localctx = new TermStrContext(this, localctx); + this.enterOuterAlt(localctx, 3); + this.state = 431; + localctx.str = this.stringOrBareString(); + break; + + case 4: + localctx = new TermFnContext(this, localctx); + this.enterOuterAlt(localctx, 4); + this.state = 432; + localctx.fn = this.func(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LikeTermContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_likeTerm; + this.re = null; // RegexContext + this.str = null; // RegexStringContext + return this; +} + +LikeTermContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LikeTermContext.prototype.constructor = LikeTermContext; + +LikeTermContext.prototype.regex = function () { + return this.getTypedRuleContext(RegexContext, 0); +}; + +LikeTermContext.prototype.regexString = function () { + return this.getTypedRuleContext(RegexStringContext, 0); +}; + +LikeTermContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLikeTerm(this); + } +}; + +LikeTermContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLikeTerm(this); + } +}; + +ScrollQLParser.LikeTermContext = LikeTermContext; + +ScrollQLParser.prototype.likeTerm = function () { + var localctx = new LikeTermContext(this, this._ctx, this.state); + this.enterRule(localctx, 62, ScrollQLParser.RULE_likeTerm); + try { + this.state = 437; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.REGEX: + this.enterOuterAlt(localctx, 1); + this.state = 435; + localctx.re = this.regex(); + break; + case ScrollQLParser.RE_SDQUOTED_STRING: + case ScrollQLParser.RE_SSQUOTED_STRING: + case ScrollQLParser.RE_CDQUOTED_STRING: + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 2); + this.state = 436; + localctx.str = this.regexString(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function FuncContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_func; + return this; +} + +FuncContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +FuncContext.prototype.constructor = FuncContext; + +FuncContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function FunctionWithArgsContext(parser, ctx) { + FuncContext.call(this, parser); + this.fun = null; // FunctionIdContext; + this._functionArg = null; // FunctionArgContext; + this.Args = []; // of FunctionArgContexts; + FuncContext.prototype.copyFrom.call(this, ctx); + return this; +} + +FunctionWithArgsContext.prototype = Object.create(FuncContext.prototype); +FunctionWithArgsContext.prototype.constructor = FunctionWithArgsContext; + +ScrollQLParser.FunctionWithArgsContext = FunctionWithArgsContext; + +FunctionWithArgsContext.prototype.SYM_LPAREN = function () { + return this.getToken(ScrollQLParser.SYM_LPAREN, 0); +}; + +FunctionWithArgsContext.prototype.SYM_RPAREN = function () { + return this.getToken(ScrollQLParser.SYM_RPAREN, 0); +}; + +FunctionWithArgsContext.prototype.functionId = function () { + return this.getTypedRuleContext(FunctionIdContext, 0); +}; + +FunctionWithArgsContext.prototype.functionArg = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(FunctionArgContext); + } else { + return this.getTypedRuleContext(FunctionArgContext, i); + } +}; + +FunctionWithArgsContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +FunctionWithArgsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFunctionWithArgs(this); + } +}; + +FunctionWithArgsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFunctionWithArgs(this); + } +}; + +function FunctionWithNoArgsContext(parser, ctx) { + FuncContext.call(this, parser); + this.fun = null; // FunctionIdContext; + FuncContext.prototype.copyFrom.call(this, ctx); + return this; +} + +FunctionWithNoArgsContext.prototype = Object.create(FuncContext.prototype); +FunctionWithNoArgsContext.prototype.constructor = FunctionWithNoArgsContext; + +ScrollQLParser.FunctionWithNoArgsContext = FunctionWithNoArgsContext; + +FunctionWithNoArgsContext.prototype.SYM_LPAREN = function () { + return this.getToken(ScrollQLParser.SYM_LPAREN, 0); +}; + +FunctionWithNoArgsContext.prototype.SYM_RPAREN = function () { + return this.getToken(ScrollQLParser.SYM_RPAREN, 0); +}; + +FunctionWithNoArgsContext.prototype.functionId = function () { + return this.getTypedRuleContext(FunctionIdContext, 0); +}; + +FunctionWithNoArgsContext.prototype.SYM_MUL = function () { + return this.getToken(ScrollQLParser.SYM_MUL, 0); +}; +FunctionWithNoArgsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFunctionWithNoArgs(this); + } +}; + +FunctionWithNoArgsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFunctionWithNoArgs(this); + } +}; + +ScrollQLParser.FuncContext = FuncContext; + +ScrollQLParser.prototype.func = function () { + var localctx = new FuncContext(this, this._ctx, this.state); + this.enterRule(localctx, 64, ScrollQLParser.RULE_func); + var _la = 0; // Token type + try { + this.state = 458; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 52, this._ctx); + switch (la_) { + case 1: + localctx = new FunctionWithArgsContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 439; + localctx.fun = this.functionId(); + this.state = 440; + this.match(ScrollQLParser.SYM_LPAREN); + this.state = 441; + localctx._functionArg = this.functionArg(); + localctx.Args.push(localctx._functionArg); + this.state = 446; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 442; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 443; + localctx._functionArg = this.functionArg(); + localctx.Args.push(localctx._functionArg); + this.state = 448; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + this.state = 449; + this.match(ScrollQLParser.SYM_RPAREN); + break; + + case 2: + localctx = new FunctionWithNoArgsContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 451; + localctx.fun = this.functionId(); + this.state = 452; + this.match(ScrollQLParser.SYM_LPAREN); + this.state = 454; + this._errHandler.sync(this); + _la = this._input.LA(1); + if (_la === ScrollQLParser.SYM_MUL) { + this.state = 453; + this.match(ScrollQLParser.SYM_MUL); + } + + this.state = 456; + this.match(ScrollQLParser.SYM_RPAREN); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function FunctionArgContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_functionArg; + return this; +} + +FunctionArgContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +FunctionArgContext.prototype.constructor = FunctionArgContext; + +FunctionArgContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function FunctionArgFieldClauseContext(parser, ctx) { + FunctionArgContext.call(this, parser); + FunctionArgContext.prototype.copyFrom.call(this, ctx); + return this; +} + +FunctionArgFieldClauseContext.prototype = Object.create(FunctionArgContext.prototype); +FunctionArgFieldClauseContext.prototype.constructor = FunctionArgFieldClauseContext; + +ScrollQLParser.FunctionArgFieldClauseContext = FunctionArgFieldClauseContext; + +FunctionArgFieldClauseContext.prototype.expression = function () { + return this.getTypedRuleContext(ExpressionContext, 0); +}; +FunctionArgFieldClauseContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFunctionArgFieldClause(this); + } +}; + +FunctionArgFieldClauseContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFunctionArgFieldClause(this); + } +}; + +function FunctionArgTimePeriodContext(parser, ctx) { + FunctionArgContext.call(this, parser); + FunctionArgContext.prototype.copyFrom.call(this, ctx); + return this; +} + +FunctionArgTimePeriodContext.prototype = Object.create(FunctionArgContext.prototype); +FunctionArgTimePeriodContext.prototype.constructor = FunctionArgTimePeriodContext; + +ScrollQLParser.FunctionArgTimePeriodContext = FunctionArgTimePeriodContext; + +FunctionArgTimePeriodContext.prototype.relativeTimeExpr = function () { + return this.getTypedRuleContext(RelativeTimeExprContext, 0); +}; +FunctionArgTimePeriodContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFunctionArgTimePeriod(this); + } +}; + +FunctionArgTimePeriodContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFunctionArgTimePeriod(this); + } +}; + +ScrollQLParser.FunctionArgContext = FunctionArgContext; + +ScrollQLParser.prototype.functionArg = function () { + var localctx = new FunctionArgContext(this, this._ctx, this.state); + this.enterRule(localctx, 66, ScrollQLParser.RULE_functionArg); + try { + this.state = 462; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 53, this._ctx); + switch (la_) { + case 1: + localctx = new FunctionArgTimePeriodContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 460; + this.relativeTimeExpr(); + break; + + case 2: + localctx = new FunctionArgFieldClauseContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 461; + this.expression(0); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ArrayContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_array; + return this; +} + +ArrayContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ArrayContext.prototype.constructor = ArrayContext; + +ArrayContext.prototype.SYM_LBRACKET = function () { + return this.getToken(ScrollQLParser.SYM_LBRACKET, 0); +}; + +ArrayContext.prototype.arrayElem = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTypedRuleContexts(ArrayElemContext); + } else { + return this.getTypedRuleContext(ArrayElemContext, i); + } +}; + +ArrayContext.prototype.SYM_RBRACKET = function () { + return this.getToken(ScrollQLParser.SYM_RBRACKET, 0); +}; + +ArrayContext.prototype.SYM_COMMA = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_COMMA); + } else { + return this.getToken(ScrollQLParser.SYM_COMMA, i); + } +}; + +ArrayContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterArray(this); + } +}; + +ArrayContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitArray(this); + } +}; + +ScrollQLParser.ArrayContext = ArrayContext; + +ScrollQLParser.prototype.array = function () { + var localctx = new ArrayContext(this, this._ctx, this.state); + this.enterRule(localctx, 68, ScrollQLParser.RULE_array); + var _la = 0; // Token type + try { + this.state = 477; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 55, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 464; + this.match(ScrollQLParser.SYM_LBRACKET); + this.state = 465; + this.arrayElem(); + this.state = 470; + this._errHandler.sync(this); + _la = this._input.LA(1); + while (_la === ScrollQLParser.SYM_COMMA) { + this.state = 466; + this.match(ScrollQLParser.SYM_COMMA); + this.state = 467; + this.arrayElem(); + this.state = 472; + this._errHandler.sync(this); + _la = this._input.LA(1); + } + this.state = 473; + this.match(ScrollQLParser.SYM_RBRACKET); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 475; + this.match(ScrollQLParser.SYM_LBRACKET); + this.state = 476; + this.match(ScrollQLParser.SYM_RBRACKET); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ArrayElemContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_arrayElem; + this.arrayElement = null; // StringContext + this.num = null; // NumberContext + return this; +} + +ArrayElemContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ArrayElemContext.prototype.constructor = ArrayElemContext; + +ArrayElemContext.prototype.string = function () { + return this.getTypedRuleContext(StringContext, 0); +}; + +ArrayElemContext.prototype.number = function () { + return this.getTypedRuleContext(NumberContext, 0); +}; + +ArrayElemContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterArrayElem(this); + } +}; + +ArrayElemContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitArrayElem(this); + } +}; + +ScrollQLParser.ArrayElemContext = ArrayElemContext; + +ScrollQLParser.prototype.arrayElem = function () { + var localctx = new ArrayElemContext(this, this._ctx, this.state); + this.enterRule(localctx, 70, ScrollQLParser.RULE_arrayElem); + try { + this.state = 481; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SDQUOTED_STRING: + case ScrollQLParser.SSQUOTED_STRING: + case ScrollQLParser.CDQUOTED_STRING: + case ScrollQLParser.CSQUOTED_STRING: + case ScrollQLParser.RE_SDQUOTED_STRING: + case ScrollQLParser.RE_SSQUOTED_STRING: + case ScrollQLParser.RE_CDQUOTED_STRING: + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 1); + this.state = 479; + localctx.arrayElement = this.string(); + break; + case ScrollQLParser.LIT_INTEGER: + case ScrollQLParser.LIT_NUMBER: + this.enterOuterAlt(localctx, 2); + this.state = 480; + localctx.num = this.number(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function NumberContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_number; + this.numF = null; // Token + this.numI = null; // Token + return this; +} + +NumberContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +NumberContext.prototype.constructor = NumberContext; + +NumberContext.prototype.LIT_NUMBER = function () { + return this.getToken(ScrollQLParser.LIT_NUMBER, 0); +}; + +NumberContext.prototype.LIT_INTEGER = function () { + return this.getToken(ScrollQLParser.LIT_INTEGER, 0); +}; + +NumberContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterNumber(this); + } +}; + +NumberContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitNumber(this); + } +}; + +ScrollQLParser.NumberContext = NumberContext; + +ScrollQLParser.prototype.number = function () { + var localctx = new NumberContext(this, this._ctx, this.state); + this.enterRule(localctx, 72, ScrollQLParser.RULE_number); + try { + this.state = 485; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.LIT_NUMBER: + this.enterOuterAlt(localctx, 1); + this.state = 483; + localctx.numF = this.match(ScrollQLParser.LIT_NUMBER); + break; + case ScrollQLParser.LIT_INTEGER: + this.enterOuterAlt(localctx, 2); + this.state = 484; + localctx.numI = this.match(ScrollQLParser.LIT_INTEGER); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function StringContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_string; + this.sdqstr = null; // Token + this.ssqstr = null; // Token + this.cdqstr = null; // Token + this.csqstr = null; // Token + return this; +} + +StringContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +StringContext.prototype.constructor = StringContext; + +StringContext.prototype.SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SDQUOTED_STRING, 0); +}; + +StringContext.prototype.RE_SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SDQUOTED_STRING, 0); +}; + +StringContext.prototype.SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SSQUOTED_STRING, 0); +}; + +StringContext.prototype.RE_SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SSQUOTED_STRING, 0); +}; + +StringContext.prototype.CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CDQUOTED_STRING, 0); +}; + +StringContext.prototype.RE_CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CDQUOTED_STRING, 0); +}; + +StringContext.prototype.CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CSQUOTED_STRING, 0); +}; + +StringContext.prototype.RE_CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CSQUOTED_STRING, 0); +}; + +StringContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterString(this); + } +}; + +StringContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitString(this); + } +}; + +ScrollQLParser.StringContext = StringContext; + +ScrollQLParser.prototype.string = function () { + var localctx = new StringContext(this, this._ctx, this.state); + this.enterRule(localctx, 74, ScrollQLParser.RULE_string); + var _la = 0; // Token type + try { + this.state = 491; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SDQUOTED_STRING: + case ScrollQLParser.RE_SDQUOTED_STRING: + this.enterOuterAlt(localctx, 1); + this.state = 487; + localctx.sdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SDQUOTED_STRING || _la === ScrollQLParser.RE_SDQUOTED_STRING)) { + localctx.sdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.SSQUOTED_STRING: + case ScrollQLParser.RE_SSQUOTED_STRING: + this.enterOuterAlt(localctx, 2); + this.state = 488; + localctx.ssqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SSQUOTED_STRING || _la === ScrollQLParser.RE_SSQUOTED_STRING)) { + localctx.ssqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CDQUOTED_STRING: + case ScrollQLParser.RE_CDQUOTED_STRING: + this.enterOuterAlt(localctx, 3); + this.state = 489; + localctx.cdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CDQUOTED_STRING || _la === ScrollQLParser.RE_CDQUOTED_STRING)) { + localctx.cdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CSQUOTED_STRING: + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 4); + this.state = 490; + localctx.csqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CSQUOTED_STRING || _la === ScrollQLParser.RE_CSQUOTED_STRING)) { + localctx.csqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function StringOrBareStringContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_stringOrBareString; + this.sdqstr = null; // Token + this.ssqstr = null; // Token + this.cdqstr = null; // Token + this.csqstr = null; // Token + this.bstr = null; // Token + return this; +} + +StringOrBareStringContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +StringOrBareStringContext.prototype.constructor = StringOrBareStringContext; + +StringOrBareStringContext.prototype.SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SDQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.RE_SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SDQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SSQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.RE_SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SSQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CDQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.RE_CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CDQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CSQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.RE_CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CSQUOTED_STRING, 0); +}; + +StringOrBareStringContext.prototype.RAW_ID = function () { + return this.getToken(ScrollQLParser.RAW_ID, 0); +}; + +StringOrBareStringContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterStringOrBareString(this); + } +}; + +StringOrBareStringContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitStringOrBareString(this); + } +}; + +ScrollQLParser.StringOrBareStringContext = StringOrBareStringContext; + +ScrollQLParser.prototype.stringOrBareString = function () { + var localctx = new StringOrBareStringContext(this, this._ctx, this.state); + this.enterRule(localctx, 76, ScrollQLParser.RULE_stringOrBareString); + var _la = 0; // Token type + try { + this.state = 498; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.SDQUOTED_STRING: + case ScrollQLParser.RE_SDQUOTED_STRING: + this.enterOuterAlt(localctx, 1); + this.state = 493; + localctx.sdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SDQUOTED_STRING || _la === ScrollQLParser.RE_SDQUOTED_STRING)) { + localctx.sdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.SSQUOTED_STRING: + case ScrollQLParser.RE_SSQUOTED_STRING: + this.enterOuterAlt(localctx, 2); + this.state = 494; + localctx.ssqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SSQUOTED_STRING || _la === ScrollQLParser.RE_SSQUOTED_STRING)) { + localctx.ssqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CDQUOTED_STRING: + case ScrollQLParser.RE_CDQUOTED_STRING: + this.enterOuterAlt(localctx, 3); + this.state = 495; + localctx.cdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CDQUOTED_STRING || _la === ScrollQLParser.RE_CDQUOTED_STRING)) { + localctx.cdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CSQUOTED_STRING: + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 4); + this.state = 496; + localctx.csqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CSQUOTED_STRING || _la === ScrollQLParser.RE_CSQUOTED_STRING)) { + localctx.csqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.RAW_ID: + this.enterOuterAlt(localctx, 5); + this.state = 497; + localctx.bstr = this.match(ScrollQLParser.RAW_ID); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function RegexContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_regex; + return this; +} + +RegexContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +RegexContext.prototype.constructor = RegexContext; + +RegexContext.prototype.REGEX = function () { + return this.getToken(ScrollQLParser.REGEX, 0); +}; + +RegexContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterRegex(this); + } +}; + +RegexContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitRegex(this); + } +}; + +ScrollQLParser.RegexContext = RegexContext; + +ScrollQLParser.prototype.regex = function () { + var localctx = new RegexContext(this, this._ctx, this.state); + this.enterRule(localctx, 78, ScrollQLParser.RULE_regex); + try { + this.enterOuterAlt(localctx, 1); + this.state = 500; + this.match(ScrollQLParser.REGEX); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function RegexStringContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_regexString; + this.sdqstr = null; // Token + this.ssqstr = null; // Token + this.cdqstr = null; // Token + this.csqstr = null; // Token + return this; +} + +RegexStringContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +RegexStringContext.prototype.constructor = RegexStringContext; + +RegexStringContext.prototype.RE_SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SDQUOTED_STRING, 0); +}; + +RegexStringContext.prototype.RE_SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SSQUOTED_STRING, 0); +}; + +RegexStringContext.prototype.RE_CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CDQUOTED_STRING, 0); +}; + +RegexStringContext.prototype.RE_CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CSQUOTED_STRING, 0); +}; + +RegexStringContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterRegexString(this); + } +}; + +RegexStringContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitRegexString(this); + } +}; + +ScrollQLParser.RegexStringContext = RegexStringContext; + +ScrollQLParser.prototype.regexString = function () { + var localctx = new RegexStringContext(this, this._ctx, this.state); + this.enterRule(localctx, 80, ScrollQLParser.RULE_regexString); + try { + this.state = 506; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.RE_SDQUOTED_STRING: + this.enterOuterAlt(localctx, 1); + this.state = 502; + localctx.sdqstr = this.match(ScrollQLParser.RE_SDQUOTED_STRING); + break; + case ScrollQLParser.RE_SSQUOTED_STRING: + this.enterOuterAlt(localctx, 2); + this.state = 503; + localctx.ssqstr = this.match(ScrollQLParser.RE_SSQUOTED_STRING); + break; + case ScrollQLParser.RE_CDQUOTED_STRING: + this.enterOuterAlt(localctx, 3); + this.state = 504; + localctx.cdqstr = this.match(ScrollQLParser.RE_CDQUOTED_STRING); + break; + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 4); + this.state = 505; + localctx.csqstr = this.match(ScrollQLParser.RE_CSQUOTED_STRING); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function LogIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_logId; + this.raw = null; // Token + this.keyw = null; // KeywordsContext + this.keywt = null; // TimeUnitKeywordsContext + this.sdqstr = null; // Token + this.ssqstr = null; // Token + this.cdqstr = null; // Token + this.csqstr = null; // Token + return this; +} + +LogIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +LogIdContext.prototype.constructor = LogIdContext; + +LogIdContext.prototype.RAW_ID = function () { + return this.getToken(ScrollQLParser.RAW_ID, 0); +}; + +LogIdContext.prototype.keywords = function () { + return this.getTypedRuleContext(KeywordsContext, 0); +}; + +LogIdContext.prototype.timeUnitKeywords = function () { + return this.getTypedRuleContext(TimeUnitKeywordsContext, 0); +}; + +LogIdContext.prototype.SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SDQUOTED_STRING, 0); +}; + +LogIdContext.prototype.RE_SDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SDQUOTED_STRING, 0); +}; + +LogIdContext.prototype.SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.SSQUOTED_STRING, 0); +}; + +LogIdContext.prototype.RE_SSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_SSQUOTED_STRING, 0); +}; + +LogIdContext.prototype.CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CDQUOTED_STRING, 0); +}; + +LogIdContext.prototype.RE_CDQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CDQUOTED_STRING, 0); +}; + +LogIdContext.prototype.CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.CSQUOTED_STRING, 0); +}; + +LogIdContext.prototype.RE_CSQUOTED_STRING = function () { + return this.getToken(ScrollQLParser.RE_CSQUOTED_STRING, 0); +}; + +LogIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterLogId(this); + } +}; + +LogIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitLogId(this); + } +}; + +ScrollQLParser.LogIdContext = LogIdContext; + +ScrollQLParser.prototype.logId = function () { + var localctx = new LogIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 82, ScrollQLParser.RULE_logId); + var _la = 0; // Token type + try { + this.state = 515; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.RAW_ID: + this.enterOuterAlt(localctx, 1); + this.state = 508; + localctx.raw = this.match(ScrollQLParser.RAW_ID); + break; + case ScrollQLParser.K_SOURCE: + case ScrollQLParser.K_START: + case ScrollQLParser.K_END: + case ScrollQLParser.K_NOW: + case ScrollQLParser.K_LIVE: + case ScrollQLParser.K_PARSE: + case ScrollQLParser.K_SEARCH: + case ScrollQLParser.K_FIELDS: + case ScrollQLParser.K_DISPLAY: + case ScrollQLParser.K_FILTER: + case ScrollQLParser.K_STATS: + case ScrollQLParser.K_SORT: + case ScrollQLParser.K_ORDER: + case ScrollQLParser.K_ASC: + case ScrollQLParser.K_DESC: + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + case ScrollQLParser.K_TAIL: + case ScrollQLParser.K_REGEX: + case ScrollQLParser.K_IN: + case ScrollQLParser.K_GROUP: + case ScrollQLParser.K_BY: + case ScrollQLParser.K_AS: + case ScrollQLParser.K_AND: + case ScrollQLParser.K_OR: + case ScrollQLParser.K_NOT: + case ScrollQLParser.K_LIKE: + case ScrollQLParser.K_MATCHES: + this.enterOuterAlt(localctx, 2); + this.state = 509; + localctx.keyw = this.keywords(); + break; + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + this.enterOuterAlt(localctx, 3); + this.state = 510; + localctx.keywt = this.timeUnitKeywords(); + break; + case ScrollQLParser.SDQUOTED_STRING: + case ScrollQLParser.RE_SDQUOTED_STRING: + this.enterOuterAlt(localctx, 4); + this.state = 511; + localctx.sdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SDQUOTED_STRING || _la === ScrollQLParser.RE_SDQUOTED_STRING)) { + localctx.sdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.SSQUOTED_STRING: + case ScrollQLParser.RE_SSQUOTED_STRING: + this.enterOuterAlt(localctx, 5); + this.state = 512; + localctx.ssqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SSQUOTED_STRING || _la === ScrollQLParser.RE_SSQUOTED_STRING)) { + localctx.ssqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CDQUOTED_STRING: + case ScrollQLParser.RE_CDQUOTED_STRING: + this.enterOuterAlt(localctx, 6); + this.state = 513; + localctx.cdqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CDQUOTED_STRING || _la === ScrollQLParser.RE_CDQUOTED_STRING)) { + localctx.cdqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.CSQUOTED_STRING: + case ScrollQLParser.RE_CSQUOTED_STRING: + this.enterOuterAlt(localctx, 7); + this.state = 514; + localctx.csqstr = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.CSQUOTED_STRING || _la === ScrollQLParser.RE_CSQUOTED_STRING)) { + localctx.csqstr = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function FieldIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_fieldId; + this.uid = null; // UserIdContext + this.sid = null; // SystemIdContext + return this; +} + +FieldIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +FieldIdContext.prototype.constructor = FieldIdContext; + +FieldIdContext.prototype.userId = function () { + return this.getTypedRuleContext(UserIdContext, 0); +}; + +FieldIdContext.prototype.systemId = function () { + return this.getTypedRuleContext(SystemIdContext, 0); +}; + +FieldIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFieldId(this); + } +}; + +FieldIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFieldId(this); + } +}; + +ScrollQLParser.FieldIdContext = FieldIdContext; + +ScrollQLParser.prototype.fieldId = function () { + var localctx = new FieldIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 84, ScrollQLParser.RULE_fieldId); + try { + this.state = 519; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 62, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 517; + localctx.uid = this.userId(); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 518; + localctx.sid = this.systemId(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function AliasIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_aliasId; + this.uid = null; // UserIdContext + this.sid = null; // SystemIdContext + return this; +} + +AliasIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +AliasIdContext.prototype.constructor = AliasIdContext; + +AliasIdContext.prototype.userId = function () { + return this.getTypedRuleContext(UserIdContext, 0); +}; + +AliasIdContext.prototype.systemId = function () { + return this.getTypedRuleContext(SystemIdContext, 0); +}; + +AliasIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterAliasId(this); + } +}; + +AliasIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitAliasId(this); + } +}; + +ScrollQLParser.AliasIdContext = AliasIdContext; + +ScrollQLParser.prototype.aliasId = function () { + var localctx = new AliasIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 86, ScrollQLParser.RULE_aliasId); + try { + this.state = 523; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 63, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 521; + localctx.uid = this.userId(); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 522; + localctx.sid = this.systemId(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function UserIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_userId; + this.usid = null; // UnquotedUserIdContext + this.qsid = null; // QuotedUserIdContext + return this; +} + +UserIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +UserIdContext.prototype.constructor = UserIdContext; + +UserIdContext.prototype.unquotedUserId = function () { + return this.getTypedRuleContext(UnquotedUserIdContext, 0); +}; + +UserIdContext.prototype.quotedUserId = function () { + return this.getTypedRuleContext(QuotedUserIdContext, 0); +}; + +UserIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterUserId(this); + } +}; + +UserIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitUserId(this); + } +}; + +ScrollQLParser.UserIdContext = UserIdContext; + +ScrollQLParser.prototype.userId = function () { + var localctx = new UserIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 88, ScrollQLParser.RULE_userId); + try { + this.state = 527; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_SOURCE: + case ScrollQLParser.K_START: + case ScrollQLParser.K_END: + case ScrollQLParser.K_NOW: + case ScrollQLParser.K_LIVE: + case ScrollQLParser.K_PARSE: + case ScrollQLParser.K_SEARCH: + case ScrollQLParser.K_FIELDS: + case ScrollQLParser.K_DISPLAY: + case ScrollQLParser.K_FILTER: + case ScrollQLParser.K_STATS: + case ScrollQLParser.K_SORT: + case ScrollQLParser.K_ORDER: + case ScrollQLParser.K_ASC: + case ScrollQLParser.K_DESC: + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + case ScrollQLParser.K_TAIL: + case ScrollQLParser.K_REGEX: + case ScrollQLParser.K_IN: + case ScrollQLParser.K_GROUP: + case ScrollQLParser.K_BY: + case ScrollQLParser.K_AS: + case ScrollQLParser.K_AND: + case ScrollQLParser.K_OR: + case ScrollQLParser.K_NOT: + case ScrollQLParser.K_LIKE: + case ScrollQLParser.K_MATCHES: + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + case ScrollQLParser.RAW_ID: + case ScrollQLParser.SYM_AT: + case ScrollQLParser.RE_RAW_ID: + case ScrollQLParser.RE_SYM_AT: + this.enterOuterAlt(localctx, 1); + this.state = 525; + localctx.usid = this.unquotedUserId(); + break; + case ScrollQLParser.QUOTED_IDENT: + case ScrollQLParser.RE_QUOTED_IDENT: + this.enterOuterAlt(localctx, 2); + this.state = 526; + localctx.qsid = this.quotedUserId(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function UnquotedUserIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_unquotedUserId; + this.uubid = null; // UnquotedUserBareIdContext + this.uuaid = null; // UnquotedUserAtIdContext + return this; +} + +UnquotedUserIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +UnquotedUserIdContext.prototype.constructor = UnquotedUserIdContext; + +UnquotedUserIdContext.prototype.unquotedUserBareId = function () { + return this.getTypedRuleContext(UnquotedUserBareIdContext, 0); +}; + +UnquotedUserIdContext.prototype.unquotedUserAtId = function () { + return this.getTypedRuleContext(UnquotedUserAtIdContext, 0); +}; + +UnquotedUserIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterUnquotedUserId(this); + } +}; + +UnquotedUserIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitUnquotedUserId(this); + } +}; + +ScrollQLParser.UnquotedUserIdContext = UnquotedUserIdContext; + +ScrollQLParser.prototype.unquotedUserId = function () { + var localctx = new UnquotedUserIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 90, ScrollQLParser.RULE_unquotedUserId); + try { + this.state = 531; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_SOURCE: + case ScrollQLParser.K_START: + case ScrollQLParser.K_END: + case ScrollQLParser.K_NOW: + case ScrollQLParser.K_LIVE: + case ScrollQLParser.K_PARSE: + case ScrollQLParser.K_SEARCH: + case ScrollQLParser.K_FIELDS: + case ScrollQLParser.K_DISPLAY: + case ScrollQLParser.K_FILTER: + case ScrollQLParser.K_STATS: + case ScrollQLParser.K_SORT: + case ScrollQLParser.K_ORDER: + case ScrollQLParser.K_ASC: + case ScrollQLParser.K_DESC: + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + case ScrollQLParser.K_TAIL: + case ScrollQLParser.K_REGEX: + case ScrollQLParser.K_IN: + case ScrollQLParser.K_GROUP: + case ScrollQLParser.K_BY: + case ScrollQLParser.K_AS: + case ScrollQLParser.K_AND: + case ScrollQLParser.K_OR: + case ScrollQLParser.K_NOT: + case ScrollQLParser.K_LIKE: + case ScrollQLParser.K_MATCHES: + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + case ScrollQLParser.RAW_ID: + case ScrollQLParser.RE_RAW_ID: + this.enterOuterAlt(localctx, 1); + this.state = 529; + localctx.uubid = this.unquotedUserBareId(); + break; + case ScrollQLParser.SYM_AT: + case ScrollQLParser.RE_SYM_AT: + this.enterOuterAlt(localctx, 2); + this.state = 530; + localctx.uuaid = this.unquotedUserAtId(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function UnquotedUserAtIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_unquotedUserAtId; + return this; +} + +UnquotedUserAtIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +UnquotedUserAtIdContext.prototype.constructor = UnquotedUserAtIdContext; + +UnquotedUserAtIdContext.prototype.rawId = function () { + return this.getTypedRuleContext(RawIdContext, 0); +}; + +UnquotedUserAtIdContext.prototype.SYM_AT = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.SYM_AT); + } else { + return this.getToken(ScrollQLParser.SYM_AT, i); + } +}; + +UnquotedUserAtIdContext.prototype.RE_SYM_AT = function (i) { + if (i === undefined) { + i = null; + } + if (i === null) { + return this.getTokens(ScrollQLParser.RE_SYM_AT); + } else { + return this.getToken(ScrollQLParser.RE_SYM_AT, i); + } +}; + +UnquotedUserAtIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterUnquotedUserAtId(this); + } +}; + +UnquotedUserAtIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitUnquotedUserAtId(this); + } +}; + +ScrollQLParser.UnquotedUserAtIdContext = UnquotedUserAtIdContext; + +ScrollQLParser.prototype.unquotedUserAtId = function () { + var localctx = new UnquotedUserAtIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 92, ScrollQLParser.RULE_unquotedUserAtId); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 533; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SYM_AT || _la === ScrollQLParser.RE_SYM_AT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 535; + this._errHandler.sync(this); + _la = this._input.LA(1); + do { + this.state = 534; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SYM_AT || _la === ScrollQLParser.RE_SYM_AT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 537; + this._errHandler.sync(this); + _la = this._input.LA(1); + } while (_la === ScrollQLParser.SYM_AT || _la === ScrollQLParser.RE_SYM_AT); + this.state = 539; + this.rawId(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function UnquotedUserBareIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_unquotedUserBareId; + this.uid = null; // RawIdContext + return this; +} + +UnquotedUserBareIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +UnquotedUserBareIdContext.prototype.constructor = UnquotedUserBareIdContext; + +UnquotedUserBareIdContext.prototype.rawId = function () { + return this.getTypedRuleContext(RawIdContext, 0); +}; + +UnquotedUserBareIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterUnquotedUserBareId(this); + } +}; + +UnquotedUserBareIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitUnquotedUserBareId(this); + } +}; + +ScrollQLParser.UnquotedUserBareIdContext = UnquotedUserBareIdContext; + +ScrollQLParser.prototype.unquotedUserBareId = function () { + var localctx = new UnquotedUserBareIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 94, ScrollQLParser.RULE_unquotedUserBareId); + try { + this.enterOuterAlt(localctx, 1); + this.state = 541; + localctx.uid = this.rawId(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function QuotedUserIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_quotedUserId; + this.qid = null; // Token + return this; +} + +QuotedUserIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +QuotedUserIdContext.prototype.constructor = QuotedUserIdContext; + +QuotedUserIdContext.prototype.QUOTED_IDENT = function () { + return this.getToken(ScrollQLParser.QUOTED_IDENT, 0); +}; + +QuotedUserIdContext.prototype.RE_QUOTED_IDENT = function () { + return this.getToken(ScrollQLParser.RE_QUOTED_IDENT, 0); +}; + +QuotedUserIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterQuotedUserId(this); + } +}; + +QuotedUserIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitQuotedUserId(this); + } +}; + +ScrollQLParser.QuotedUserIdContext = QuotedUserIdContext; + +ScrollQLParser.prototype.quotedUserId = function () { + var localctx = new QuotedUserIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 96, ScrollQLParser.RULE_quotedUserId); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 543; + localctx.qid = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.QUOTED_IDENT || _la === ScrollQLParser.RE_QUOTED_IDENT)) { + localctx.qid = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function SystemIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_systemId; + this.usid = null; // UnquotedSystemIdContext + this.qsid = null; // QuotedSystemIdContext + return this; +} + +SystemIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +SystemIdContext.prototype.constructor = SystemIdContext; + +SystemIdContext.prototype.unquotedSystemId = function () { + return this.getTypedRuleContext(UnquotedSystemIdContext, 0); +}; + +SystemIdContext.prototype.quotedSystemId = function () { + return this.getTypedRuleContext(QuotedSystemIdContext, 0); +}; + +SystemIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterSystemId(this); + } +}; + +SystemIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitSystemId(this); + } +}; + +ScrollQLParser.SystemIdContext = SystemIdContext; + +ScrollQLParser.prototype.systemId = function () { + var localctx = new SystemIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 98, ScrollQLParser.RULE_systemId); + try { + this.state = 547; + this._errHandler.sync(this); + var la_ = this._interp.adaptivePredict(this._input, 67, this._ctx); + switch (la_) { + case 1: + this.enterOuterAlt(localctx, 1); + this.state = 545; + localctx.usid = this.unquotedSystemId(); + break; + + case 2: + this.enterOuterAlt(localctx, 2); + this.state = 546; + localctx.qsid = this.quotedSystemId(); + break; + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function UnquotedSystemIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_unquotedSystemId; + this.udid = null; // RawIdContext + return this; +} + +UnquotedSystemIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +UnquotedSystemIdContext.prototype.constructor = UnquotedSystemIdContext; + +UnquotedSystemIdContext.prototype.SYM_AT = function () { + return this.getToken(ScrollQLParser.SYM_AT, 0); +}; + +UnquotedSystemIdContext.prototype.RE_SYM_AT = function () { + return this.getToken(ScrollQLParser.RE_SYM_AT, 0); +}; + +UnquotedSystemIdContext.prototype.rawId = function () { + return this.getTypedRuleContext(RawIdContext, 0); +}; + +UnquotedSystemIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterUnquotedSystemId(this); + } +}; + +UnquotedSystemIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitUnquotedSystemId(this); + } +}; + +ScrollQLParser.UnquotedSystemIdContext = UnquotedSystemIdContext; + +ScrollQLParser.prototype.unquotedSystemId = function () { + var localctx = new UnquotedSystemIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 100, ScrollQLParser.RULE_unquotedSystemId); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 549; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SYM_AT || _la === ScrollQLParser.RE_SYM_AT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 550; + localctx.udid = this.rawId(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function QuotedSystemIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_quotedSystemId; + this.qid = null; // Token + return this; +} + +QuotedSystemIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +QuotedSystemIdContext.prototype.constructor = QuotedSystemIdContext; + +QuotedSystemIdContext.prototype.SYM_AT = function () { + return this.getToken(ScrollQLParser.SYM_AT, 0); +}; + +QuotedSystemIdContext.prototype.RE_SYM_AT = function () { + return this.getToken(ScrollQLParser.RE_SYM_AT, 0); +}; + +QuotedSystemIdContext.prototype.QUOTED_IDENT = function () { + return this.getToken(ScrollQLParser.QUOTED_IDENT, 0); +}; + +QuotedSystemIdContext.prototype.RE_QUOTED_IDENT = function () { + return this.getToken(ScrollQLParser.RE_QUOTED_IDENT, 0); +}; + +QuotedSystemIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterQuotedSystemId(this); + } +}; + +QuotedSystemIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitQuotedSystemId(this); + } +}; + +ScrollQLParser.QuotedSystemIdContext = QuotedSystemIdContext; + +ScrollQLParser.prototype.quotedSystemId = function () { + var localctx = new QuotedSystemIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 102, ScrollQLParser.RULE_quotedSystemId); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 552; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.SYM_AT || _la === ScrollQLParser.RE_SYM_AT)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + this.state = 553; + localctx.qid = this._input.LT(1); + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.QUOTED_IDENT || _la === ScrollQLParser.RE_QUOTED_IDENT)) { + localctx.qid = this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function ResultIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_resultId; + return this; +} + +ResultIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +ResultIdContext.prototype.constructor = ResultIdContext; + +ResultIdContext.prototype.rawId = function () { + return this.getTypedRuleContext(RawIdContext, 0); +}; + +ResultIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterResultId(this); + } +}; + +ResultIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitResultId(this); + } +}; + +ScrollQLParser.ResultIdContext = ResultIdContext; + +ScrollQLParser.prototype.resultId = function () { + var localctx = new ResultIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 104, ScrollQLParser.RULE_resultId); + try { + this.enterOuterAlt(localctx, 1); + this.state = 555; + this.rawId(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function FunctionIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_functionId; + return this; +} + +FunctionIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +FunctionIdContext.prototype.constructor = FunctionIdContext; + +FunctionIdContext.prototype.rawId = function () { + return this.getTypedRuleContext(RawIdContext, 0); +}; + +FunctionIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterFunctionId(this); + } +}; + +FunctionIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitFunctionId(this); + } +}; + +ScrollQLParser.FunctionIdContext = FunctionIdContext; + +ScrollQLParser.prototype.functionId = function () { + var localctx = new FunctionIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 106, ScrollQLParser.RULE_functionId); + try { + this.enterOuterAlt(localctx, 1); + this.state = 557; + this.rawId(); + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function RawIdContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_rawId; + return this; +} + +RawIdContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +RawIdContext.prototype.constructor = RawIdContext; + +RawIdContext.prototype.RAW_ID = function () { + return this.getToken(ScrollQLParser.RAW_ID, 0); +}; + +RawIdContext.prototype.RE_RAW_ID = function () { + return this.getToken(ScrollQLParser.RE_RAW_ID, 0); +}; + +RawIdContext.prototype.keywords = function () { + return this.getTypedRuleContext(KeywordsContext, 0); +}; + +RawIdContext.prototype.timeUnitKeywords = function () { + return this.getTypedRuleContext(TimeUnitKeywordsContext, 0); +}; + +RawIdContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterRawId(this); + } +}; + +RawIdContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitRawId(this); + } +}; + +ScrollQLParser.RawIdContext = RawIdContext; + +ScrollQLParser.prototype.rawId = function () { + var localctx = new RawIdContext(this, this._ctx, this.state); + this.enterRule(localctx, 108, ScrollQLParser.RULE_rawId); + var _la = 0; // Token type + try { + this.state = 562; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.RAW_ID: + case ScrollQLParser.RE_RAW_ID: + this.enterOuterAlt(localctx, 1); + this.state = 559; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.RAW_ID || _la === ScrollQLParser.RE_RAW_ID)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_SOURCE: + case ScrollQLParser.K_START: + case ScrollQLParser.K_END: + case ScrollQLParser.K_NOW: + case ScrollQLParser.K_LIVE: + case ScrollQLParser.K_PARSE: + case ScrollQLParser.K_SEARCH: + case ScrollQLParser.K_FIELDS: + case ScrollQLParser.K_DISPLAY: + case ScrollQLParser.K_FILTER: + case ScrollQLParser.K_STATS: + case ScrollQLParser.K_SORT: + case ScrollQLParser.K_ORDER: + case ScrollQLParser.K_ASC: + case ScrollQLParser.K_DESC: + case ScrollQLParser.K_HEAD: + case ScrollQLParser.K_LIMIT: + case ScrollQLParser.K_TAIL: + case ScrollQLParser.K_REGEX: + case ScrollQLParser.K_IN: + case ScrollQLParser.K_GROUP: + case ScrollQLParser.K_BY: + case ScrollQLParser.K_AS: + case ScrollQLParser.K_AND: + case ScrollQLParser.K_OR: + case ScrollQLParser.K_NOT: + case ScrollQLParser.K_LIKE: + case ScrollQLParser.K_MATCHES: + this.enterOuterAlt(localctx, 2); + this.state = 560; + this.keywords(); + break; + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + this.enterOuterAlt(localctx, 3); + this.state = 561; + this.timeUnitKeywords(); + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function KeywordsContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_keywords; + return this; +} + +KeywordsContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +KeywordsContext.prototype.constructor = KeywordsContext; + +KeywordsContext.prototype.K_SOURCE = function () { + return this.getToken(ScrollQLParser.K_SOURCE, 0); +}; + +KeywordsContext.prototype.K_START = function () { + return this.getToken(ScrollQLParser.K_START, 0); +}; + +KeywordsContext.prototype.K_END = function () { + return this.getToken(ScrollQLParser.K_END, 0); +}; + +KeywordsContext.prototype.K_NOW = function () { + return this.getToken(ScrollQLParser.K_NOW, 0); +}; + +KeywordsContext.prototype.K_LIVE = function () { + return this.getToken(ScrollQLParser.K_LIVE, 0); +}; + +KeywordsContext.prototype.K_PARSE = function () { + return this.getToken(ScrollQLParser.K_PARSE, 0); +}; + +KeywordsContext.prototype.K_SEARCH = function () { + return this.getToken(ScrollQLParser.K_SEARCH, 0); +}; + +KeywordsContext.prototype.K_FIELDS = function () { + return this.getToken(ScrollQLParser.K_FIELDS, 0); +}; + +KeywordsContext.prototype.K_DISPLAY = function () { + return this.getToken(ScrollQLParser.K_DISPLAY, 0); +}; + +KeywordsContext.prototype.K_FILTER = function () { + return this.getToken(ScrollQLParser.K_FILTER, 0); +}; + +KeywordsContext.prototype.K_STATS = function () { + return this.getToken(ScrollQLParser.K_STATS, 0); +}; + +KeywordsContext.prototype.K_GROUP = function () { + return this.getToken(ScrollQLParser.K_GROUP, 0); +}; + +KeywordsContext.prototype.K_BY = function () { + return this.getToken(ScrollQLParser.K_BY, 0); +}; + +KeywordsContext.prototype.K_AS = function () { + return this.getToken(ScrollQLParser.K_AS, 0); +}; + +KeywordsContext.prototype.K_SORT = function () { + return this.getToken(ScrollQLParser.K_SORT, 0); +}; + +KeywordsContext.prototype.K_ORDER = function () { + return this.getToken(ScrollQLParser.K_ORDER, 0); +}; + +KeywordsContext.prototype.K_ASC = function () { + return this.getToken(ScrollQLParser.K_ASC, 0); +}; + +KeywordsContext.prototype.K_DESC = function () { + return this.getToken(ScrollQLParser.K_DESC, 0); +}; + +KeywordsContext.prototype.K_HEAD = function () { + return this.getToken(ScrollQLParser.K_HEAD, 0); +}; + +KeywordsContext.prototype.K_TAIL = function () { + return this.getToken(ScrollQLParser.K_TAIL, 0); +}; + +KeywordsContext.prototype.K_LIMIT = function () { + return this.getToken(ScrollQLParser.K_LIMIT, 0); +}; + +KeywordsContext.prototype.K_AND = function () { + return this.getToken(ScrollQLParser.K_AND, 0); +}; + +KeywordsContext.prototype.K_OR = function () { + return this.getToken(ScrollQLParser.K_OR, 0); +}; + +KeywordsContext.prototype.K_NOT = function () { + return this.getToken(ScrollQLParser.K_NOT, 0); +}; + +KeywordsContext.prototype.K_LIKE = function () { + return this.getToken(ScrollQLParser.K_LIKE, 0); +}; + +KeywordsContext.prototype.K_MATCHES = function () { + return this.getToken(ScrollQLParser.K_MATCHES, 0); +}; + +KeywordsContext.prototype.K_REGEX = function () { + return this.getToken(ScrollQLParser.K_REGEX, 0); +}; + +KeywordsContext.prototype.K_IN = function () { + return this.getToken(ScrollQLParser.K_IN, 0); +}; + +KeywordsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterKeywords(this); + } +}; + +KeywordsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitKeywords(this); + } +}; + +ScrollQLParser.KeywordsContext = KeywordsContext; + +ScrollQLParser.prototype.keywords = function () { + var localctx = new KeywordsContext(this, this._ctx, this.state); + this.enterRule(localctx, 110, ScrollQLParser.RULE_keywords); + var _la = 0; // Token type + try { + this.enterOuterAlt(localctx, 1); + this.state = 564; + _la = this._input.LA(1); + if ( + !( + (_la & ~0x1f) == 0 && + ((1 << _la) & + ((1 << ScrollQLParser.K_SOURCE) | + (1 << ScrollQLParser.K_START) | + (1 << ScrollQLParser.K_END) | + (1 << ScrollQLParser.K_NOW) | + (1 << ScrollQLParser.K_LIVE) | + (1 << ScrollQLParser.K_PARSE) | + (1 << ScrollQLParser.K_SEARCH) | + (1 << ScrollQLParser.K_FIELDS) | + (1 << ScrollQLParser.K_DISPLAY) | + (1 << ScrollQLParser.K_FILTER) | + (1 << ScrollQLParser.K_STATS) | + (1 << ScrollQLParser.K_SORT) | + (1 << ScrollQLParser.K_ORDER) | + (1 << ScrollQLParser.K_ASC) | + (1 << ScrollQLParser.K_DESC) | + (1 << ScrollQLParser.K_HEAD) | + (1 << ScrollQLParser.K_LIMIT) | + (1 << ScrollQLParser.K_TAIL) | + (1 << ScrollQLParser.K_REGEX) | + (1 << ScrollQLParser.K_IN) | + (1 << ScrollQLParser.K_GROUP) | + (1 << ScrollQLParser.K_BY) | + (1 << ScrollQLParser.K_AS) | + (1 << ScrollQLParser.K_AND) | + (1 << ScrollQLParser.K_OR) | + (1 << ScrollQLParser.K_NOT) | + (1 << ScrollQLParser.K_LIKE) | + (1 << ScrollQLParser.K_MATCHES))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +function TimeUnitKeywordsContext(parser, parent, invokingState) { + if (parent === undefined) { + parent = null; + } + if (invokingState === undefined || invokingState === null) { + invokingState = -1; + } + antlr4.ParserRuleContext.call(this, parent, invokingState); + this.parser = parser; + this.ruleIndex = ScrollQLParser.RULE_timeUnitKeywords; + return this; +} + +TimeUnitKeywordsContext.prototype = Object.create(antlr4.ParserRuleContext.prototype); +TimeUnitKeywordsContext.prototype.constructor = TimeUnitKeywordsContext; + +TimeUnitKeywordsContext.prototype.copyFrom = function (ctx) { + antlr4.ParserRuleContext.prototype.copyFrom.call(this, ctx); +}; + +function TimeUnitYearsContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitYearsContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitYearsContext.prototype.constructor = TimeUnitYearsContext; + +ScrollQLParser.TimeUnitYearsContext = TimeUnitYearsContext; + +TimeUnitYearsContext.prototype.K_TU_Y = function () { + return this.getToken(ScrollQLParser.K_TU_Y, 0); +}; + +TimeUnitYearsContext.prototype.K_TU_YR = function () { + return this.getToken(ScrollQLParser.K_TU_YR, 0); +}; + +TimeUnitYearsContext.prototype.K_TU_YEAR = function () { + return this.getToken(ScrollQLParser.K_TU_YEAR, 0); +}; +TimeUnitYearsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitYears(this); + } +}; + +TimeUnitYearsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitYears(this); + } +}; + +function TimeUnitMinutesContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitMinutesContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitMinutesContext.prototype.constructor = TimeUnitMinutesContext; + +ScrollQLParser.TimeUnitMinutesContext = TimeUnitMinutesContext; + +TimeUnitMinutesContext.prototype.K_TU_M = function () { + return this.getToken(ScrollQLParser.K_TU_M, 0); +}; + +TimeUnitMinutesContext.prototype.K_TU_MIN = function () { + return this.getToken(ScrollQLParser.K_TU_MIN, 0); +}; + +TimeUnitMinutesContext.prototype.K_TU_MINUTE = function () { + return this.getToken(ScrollQLParser.K_TU_MINUTE, 0); +}; +TimeUnitMinutesContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitMinutes(this); + } +}; + +TimeUnitMinutesContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitMinutes(this); + } +}; + +function TimeUnitHoursContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitHoursContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitHoursContext.prototype.constructor = TimeUnitHoursContext; + +ScrollQLParser.TimeUnitHoursContext = TimeUnitHoursContext; + +TimeUnitHoursContext.prototype.K_TU_H = function () { + return this.getToken(ScrollQLParser.K_TU_H, 0); +}; + +TimeUnitHoursContext.prototype.K_TU_HR = function () { + return this.getToken(ScrollQLParser.K_TU_HR, 0); +}; + +TimeUnitHoursContext.prototype.K_TU_HOUR = function () { + return this.getToken(ScrollQLParser.K_TU_HOUR, 0); +}; +TimeUnitHoursContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitHours(this); + } +}; + +TimeUnitHoursContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitHours(this); + } +}; + +function TimeUnitWeeksContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitWeeksContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitWeeksContext.prototype.constructor = TimeUnitWeeksContext; + +ScrollQLParser.TimeUnitWeeksContext = TimeUnitWeeksContext; + +TimeUnitWeeksContext.prototype.K_TU_W = function () { + return this.getToken(ScrollQLParser.K_TU_W, 0); +}; + +TimeUnitWeeksContext.prototype.K_TU_WEEK = function () { + return this.getToken(ScrollQLParser.K_TU_WEEK, 0); +}; +TimeUnitWeeksContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitWeeks(this); + } +}; + +TimeUnitWeeksContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitWeeks(this); + } +}; + +function TimeUnitDaysContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitDaysContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitDaysContext.prototype.constructor = TimeUnitDaysContext; + +ScrollQLParser.TimeUnitDaysContext = TimeUnitDaysContext; + +TimeUnitDaysContext.prototype.K_TU_D = function () { + return this.getToken(ScrollQLParser.K_TU_D, 0); +}; + +TimeUnitDaysContext.prototype.K_TU_DAY = function () { + return this.getToken(ScrollQLParser.K_TU_DAY, 0); +}; +TimeUnitDaysContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitDays(this); + } +}; + +TimeUnitDaysContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitDays(this); + } +}; + +function TimeUnitMonthsContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitMonthsContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitMonthsContext.prototype.constructor = TimeUnitMonthsContext; + +ScrollQLParser.TimeUnitMonthsContext = TimeUnitMonthsContext; + +TimeUnitMonthsContext.prototype.K_TU_MO = function () { + return this.getToken(ScrollQLParser.K_TU_MO, 0); +}; + +TimeUnitMonthsContext.prototype.K_TU_MON = function () { + return this.getToken(ScrollQLParser.K_TU_MON, 0); +}; + +TimeUnitMonthsContext.prototype.K_TU_MONTH = function () { + return this.getToken(ScrollQLParser.K_TU_MONTH, 0); +}; +TimeUnitMonthsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitMonths(this); + } +}; + +TimeUnitMonthsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitMonths(this); + } +}; + +function TimeUnitQuartersContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitQuartersContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitQuartersContext.prototype.constructor = TimeUnitQuartersContext; + +ScrollQLParser.TimeUnitQuartersContext = TimeUnitQuartersContext; + +TimeUnitQuartersContext.prototype.K_TU_Q = function () { + return this.getToken(ScrollQLParser.K_TU_Q, 0); +}; + +TimeUnitQuartersContext.prototype.K_TU_QTR = function () { + return this.getToken(ScrollQLParser.K_TU_QTR, 0); +}; + +TimeUnitQuartersContext.prototype.K_TU_QUARTER = function () { + return this.getToken(ScrollQLParser.K_TU_QUARTER, 0); +}; +TimeUnitQuartersContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitQuarters(this); + } +}; + +TimeUnitQuartersContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitQuarters(this); + } +}; + +function TimeUnitMilliSecondsContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitMilliSecondsContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitMilliSecondsContext.prototype.constructor = TimeUnitMilliSecondsContext; + +ScrollQLParser.TimeUnitMilliSecondsContext = TimeUnitMilliSecondsContext; + +TimeUnitMilliSecondsContext.prototype.K_TU_MS = function () { + return this.getToken(ScrollQLParser.K_TU_MS, 0); +}; + +TimeUnitMilliSecondsContext.prototype.K_TU_MSEC = function () { + return this.getToken(ScrollQLParser.K_TU_MSEC, 0); +}; + +TimeUnitMilliSecondsContext.prototype.K_TU_MSECOND = function () { + return this.getToken(ScrollQLParser.K_TU_MSECOND, 0); +}; +TimeUnitMilliSecondsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitMilliSeconds(this); + } +}; + +TimeUnitMilliSecondsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitMilliSeconds(this); + } +}; + +function TimeUnitSecondsContext(parser, ctx) { + TimeUnitKeywordsContext.call(this, parser); + TimeUnitKeywordsContext.prototype.copyFrom.call(this, ctx); + return this; +} + +TimeUnitSecondsContext.prototype = Object.create(TimeUnitKeywordsContext.prototype); +TimeUnitSecondsContext.prototype.constructor = TimeUnitSecondsContext; + +ScrollQLParser.TimeUnitSecondsContext = TimeUnitSecondsContext; + +TimeUnitSecondsContext.prototype.K_TU_S = function () { + return this.getToken(ScrollQLParser.K_TU_S, 0); +}; + +TimeUnitSecondsContext.prototype.K_TU_SEC = function () { + return this.getToken(ScrollQLParser.K_TU_SEC, 0); +}; + +TimeUnitSecondsContext.prototype.K_TU_SECOND = function () { + return this.getToken(ScrollQLParser.K_TU_SECOND, 0); +}; +TimeUnitSecondsContext.prototype.enterRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.enterTimeUnitSeconds(this); + } +}; + +TimeUnitSecondsContext.prototype.exitRule = function (listener) { + if (listener instanceof ScrollQLParserListener) { + listener.exitTimeUnitSeconds(this); + } +}; + +ScrollQLParser.TimeUnitKeywordsContext = TimeUnitKeywordsContext; + +ScrollQLParser.prototype.timeUnitKeywords = function () { + var localctx = new TimeUnitKeywordsContext(this, this._ctx, this.state); + this.enterRule(localctx, 112, ScrollQLParser.RULE_timeUnitKeywords); + var _la = 0; // Token type + try { + this.state = 575; + this._errHandler.sync(this); + switch (this._input.LA(1)) { + case ScrollQLParser.K_TU_MS: + case ScrollQLParser.K_TU_MSEC: + case ScrollQLParser.K_TU_MSECOND: + localctx = new TimeUnitMilliSecondsContext(this, localctx); + this.enterOuterAlt(localctx, 1); + this.state = 566; + _la = this._input.LA(1); + if ( + !( + ((_la - 31) & ~0x1f) == 0 && + ((1 << (_la - 31)) & + ((1 << (ScrollQLParser.K_TU_MS - 31)) | + (1 << (ScrollQLParser.K_TU_MSEC - 31)) | + (1 << (ScrollQLParser.K_TU_MSECOND - 31)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_S: + case ScrollQLParser.K_TU_SEC: + case ScrollQLParser.K_TU_SECOND: + localctx = new TimeUnitSecondsContext(this, localctx); + this.enterOuterAlt(localctx, 2); + this.state = 567; + _la = this._input.LA(1); + if ( + !( + ((_la - 34) & ~0x1f) == 0 && + ((1 << (_la - 34)) & + ((1 << (ScrollQLParser.K_TU_S - 34)) | + (1 << (ScrollQLParser.K_TU_SEC - 34)) | + (1 << (ScrollQLParser.K_TU_SECOND - 34)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_M: + case ScrollQLParser.K_TU_MIN: + case ScrollQLParser.K_TU_MINUTE: + localctx = new TimeUnitMinutesContext(this, localctx); + this.enterOuterAlt(localctx, 3); + this.state = 568; + _la = this._input.LA(1); + if ( + !( + ((_la - 37) & ~0x1f) == 0 && + ((1 << (_la - 37)) & + ((1 << (ScrollQLParser.K_TU_M - 37)) | + (1 << (ScrollQLParser.K_TU_MIN - 37)) | + (1 << (ScrollQLParser.K_TU_MINUTE - 37)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_H: + case ScrollQLParser.K_TU_HR: + case ScrollQLParser.K_TU_HOUR: + localctx = new TimeUnitHoursContext(this, localctx); + this.enterOuterAlt(localctx, 4); + this.state = 569; + _la = this._input.LA(1); + if ( + !( + ((_la - 40) & ~0x1f) == 0 && + ((1 << (_la - 40)) & + ((1 << (ScrollQLParser.K_TU_H - 40)) | + (1 << (ScrollQLParser.K_TU_HR - 40)) | + (1 << (ScrollQLParser.K_TU_HOUR - 40)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_D: + case ScrollQLParser.K_TU_DAY: + localctx = new TimeUnitDaysContext(this, localctx); + this.enterOuterAlt(localctx, 5); + this.state = 570; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.K_TU_D || _la === ScrollQLParser.K_TU_DAY)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_W: + case ScrollQLParser.K_TU_WEEK: + localctx = new TimeUnitWeeksContext(this, localctx); + this.enterOuterAlt(localctx, 6); + this.state = 571; + _la = this._input.LA(1); + if (!(_la === ScrollQLParser.K_TU_W || _la === ScrollQLParser.K_TU_WEEK)) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_MO: + case ScrollQLParser.K_TU_MON: + case ScrollQLParser.K_TU_MONTH: + localctx = new TimeUnitMonthsContext(this, localctx); + this.enterOuterAlt(localctx, 7); + this.state = 572; + _la = this._input.LA(1); + if ( + !( + ((_la - 47) & ~0x1f) == 0 && + ((1 << (_la - 47)) & + ((1 << (ScrollQLParser.K_TU_MO - 47)) | + (1 << (ScrollQLParser.K_TU_MON - 47)) | + (1 << (ScrollQLParser.K_TU_MONTH - 47)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_Q: + case ScrollQLParser.K_TU_QTR: + case ScrollQLParser.K_TU_QUARTER: + localctx = new TimeUnitQuartersContext(this, localctx); + this.enterOuterAlt(localctx, 8); + this.state = 573; + _la = this._input.LA(1); + if ( + !( + ((_la - 50) & ~0x1f) == 0 && + ((1 << (_la - 50)) & + ((1 << (ScrollQLParser.K_TU_Q - 50)) | + (1 << (ScrollQLParser.K_TU_QTR - 50)) | + (1 << (ScrollQLParser.K_TU_QUARTER - 50)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + case ScrollQLParser.K_TU_Y: + case ScrollQLParser.K_TU_YR: + case ScrollQLParser.K_TU_YEAR: + localctx = new TimeUnitYearsContext(this, localctx); + this.enterOuterAlt(localctx, 9); + this.state = 574; + _la = this._input.LA(1); + if ( + !( + ((_la - 53) & ~0x1f) == 0 && + ((1 << (_la - 53)) & + ((1 << (ScrollQLParser.K_TU_Y - 53)) | + (1 << (ScrollQLParser.K_TU_YR - 53)) | + (1 << (ScrollQLParser.K_TU_YEAR - 53)))) !== + 0 + ) + ) { + this._errHandler.recoverInline(this); + } else { + this._errHandler.reportMatch(this); + this.consume(); + } + break; + default: + throw new antlr4.error.NoViableAltException(this); + } + } catch (re) { + if (re instanceof antlr4.error.RecognitionException) { + localctx.exception = re; + this._errHandler.reportError(this, re); + this._errHandler.recover(this, re); + } else { + throw re; + } + } finally { + this.exitRule(); + } + return localctx; +}; + +ScrollQLParser.prototype.sempred = function (localctx, ruleIndex, predIndex) { + switch (ruleIndex) { + case 22: + return this.searchExpr_sempred(localctx, predIndex); + case 29: + return this.expression_sempred(localctx, predIndex); + default: + throw 'No predicate with index:' + ruleIndex; + } +}; + +ScrollQLParser.prototype.searchExpr_sempred = function (localctx, predIndex) { + switch (predIndex) { + case 0: + return this.precpred(this._ctx, 3); + case 1: + return this.precpred(this._ctx, 2); + default: + throw 'No predicate with index:' + predIndex; + } +}; + +ScrollQLParser.prototype.expression_sempred = function (localctx, predIndex) { + switch (predIndex) { + case 2: + return this.precpred(this._ctx, 13); + case 3: + return this.precpred(this._ctx, 9); + case 4: + return this.precpred(this._ctx, 8); + case 5: + return this.precpred(this._ctx, 7); + case 6: + return this.precpred(this._ctx, 6); + case 7: + return this.precpred(this._ctx, 3); + case 8: + return this.precpred(this._ctx, 2); + case 9: + return this.precpred(this._ctx, 5); + case 10: + return this.precpred(this._ctx, 4); + default: + throw 'No predicate with index:' + predIndex; + } +}; + +exports.ScrollQLParser = ScrollQLParser; diff --git a/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParserListener.js b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParserListener.js new file mode 100644 index 0000000..4e70c91 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/query/ScrollQLParserListener.js @@ -0,0 +1,566 @@ +// Generated from ScrollQLParser.g4 by ANTLR 4.8 +// jshint ignore: start +var antlr4 = require('antlr4/index'); + +// This class defines a complete listener for a parse tree produced by ScrollQLParser. +function ScrollQLParserListener() { + antlr4.tree.ParseTreeListener.call(this); + return this; +} + +ScrollQLParserListener.prototype = Object.create(antlr4.tree.ParseTreeListener.prototype); +ScrollQLParserListener.prototype.constructor = ScrollQLParserListener; + +// Enter a parse tree produced by ScrollQLParser#query. +ScrollQLParserListener.prototype.enterQuery = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#query. +ScrollQLParserListener.prototype.exitQuery = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logQuery. +ScrollQLParserListener.prototype.enterLogQuery = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logQuery. +ScrollQLParserListener.prototype.exitLogQuery = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logAesthetic. +ScrollQLParserListener.prototype.enterLogAesthetic = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logAesthetic. +ScrollQLParserListener.prototype.exitLogAesthetic = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logSourceStage. +ScrollQLParserListener.prototype.enterLogSourceStage = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logSourceStage. +ScrollQLParserListener.prototype.exitLogSourceStage = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logStatsStage. +ScrollQLParserListener.prototype.enterLogStatsStage = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logStatsStage. +ScrollQLParserListener.prototype.exitLogStatsStage = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOp. +ScrollQLParserListener.prototype.enterLogOp = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOp. +ScrollQLParserListener.prototype.exitLogOp = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logAestheticOp. +ScrollQLParserListener.prototype.enterLogAestheticOp = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logAestheticOp. +ScrollQLParserListener.prototype.exitLogAestheticOp = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logSource. +ScrollQLParserListener.prototype.enterLogSource = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logSource. +ScrollQLParserListener.prototype.exitLogSource = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeExpr. +ScrollQLParserListener.prototype.enterTimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeExpr. +ScrollQLParserListener.prototype.exitTimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#nowTimeExpr. +ScrollQLParserListener.prototype.enterNowTimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#nowTimeExpr. +ScrollQLParserListener.prototype.exitNowTimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#negRelativeTimeExpr. +ScrollQLParserListener.prototype.enterNegRelativeTimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#negRelativeTimeExpr. +ScrollQLParserListener.prototype.exitNegRelativeTimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#posRelativeTimeExpr. +ScrollQLParserListener.prototype.enterPosRelativeTimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#posRelativeTimeExpr. +ScrollQLParserListener.prototype.exitPosRelativeTimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#iso8601TimeExpr. +ScrollQLParserListener.prototype.enterIso8601TimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#iso8601TimeExpr. +ScrollQLParserListener.prototype.exitIso8601TimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#epochTimeExpr. +ScrollQLParserListener.prototype.enterEpochTimeExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#epochTimeExpr. +ScrollQLParserListener.prototype.exitEpochTimeExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#bareSpaceDelimited. +ScrollQLParserListener.prototype.enterBareSpaceDelimited = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#bareSpaceDelimited. +ScrollQLParserListener.prototype.exitBareSpaceDelimited = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logStats. +ScrollQLParserListener.prototype.enterLogStats = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logStats. +ScrollQLParserListener.prototype.exitLogStats = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#statsExpr. +ScrollQLParserListener.prototype.enterStatsExpr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#statsExpr. +ScrollQLParserListener.prototype.exitStatsExpr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#statsGroupFieldId. +ScrollQLParserListener.prototype.enterStatsGroupFieldId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#statsGroupFieldId. +ScrollQLParserListener.prototype.exitStatsGroupFieldId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#statsGroupFieldProjection. +ScrollQLParserListener.prototype.enterStatsGroupFieldProjection = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#statsGroupFieldProjection. +ScrollQLParserListener.prototype.exitStatsGroupFieldProjection = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpFieldsFields. +ScrollQLParserListener.prototype.enterLogOpFieldsFields = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpFieldsFields. +ScrollQLParserListener.prototype.exitLogOpFieldsFields = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpFieldsDisplay. +ScrollQLParserListener.prototype.enterLogOpFieldsDisplay = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpFieldsDisplay. +ScrollQLParserListener.prototype.exitLogOpFieldsDisplay = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#fieldSpec. +ScrollQLParserListener.prototype.enterFieldSpec = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#fieldSpec. +ScrollQLParserListener.prototype.exitFieldSpec = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpParse. +ScrollQLParserListener.prototype.enterLogOpParse = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpParse. +ScrollQLParserListener.prototype.exitLogOpParse = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpSearch. +ScrollQLParserListener.prototype.enterLogOpSearch = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpSearch. +ScrollQLParserListener.prototype.exitLogOpSearch = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#implicitLogOpSearch. +ScrollQLParserListener.prototype.enterImplicitLogOpSearch = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#implicitLogOpSearch. +ScrollQLParserListener.prototype.exitImplicitLogOpSearch = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchExprTerm. +ScrollQLParserListener.prototype.enterSearchExprTerm = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchExprTerm. +ScrollQLParserListener.prototype.exitSearchExprTerm = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchExprNot. +ScrollQLParserListener.prototype.enterSearchExprNot = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchExprNot. +ScrollQLParserListener.prototype.exitSearchExprNot = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchExprAnd. +ScrollQLParserListener.prototype.enterSearchExprAnd = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchExprAnd. +ScrollQLParserListener.prototype.exitSearchExprAnd = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchExprNested. +ScrollQLParserListener.prototype.enterSearchExprNested = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchExprNested. +ScrollQLParserListener.prototype.exitSearchExprNested = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchExprOr. +ScrollQLParserListener.prototype.enterSearchExprOr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchExprOr. +ScrollQLParserListener.prototype.exitSearchExprOr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#searchTerm. +ScrollQLParserListener.prototype.enterSearchTerm = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#searchTerm. +ScrollQLParserListener.prototype.exitSearchTerm = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpFilter. +ScrollQLParserListener.prototype.enterLogOpFilter = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpFilter. +ScrollQLParserListener.prototype.exitLogOpFilter = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpSort. +ScrollQLParserListener.prototype.enterLogOpSort = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpSort. +ScrollQLParserListener.prototype.exitLogOpSort = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#sortExprDesc. +ScrollQLParserListener.prototype.enterSortExprDesc = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#sortExprDesc. +ScrollQLParserListener.prototype.exitSortExprDesc = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#sortExprAsc. +ScrollQLParserListener.prototype.enterSortExprAsc = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#sortExprAsc. +ScrollQLParserListener.prototype.exitSortExprAsc = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpLimitHead. +ScrollQLParserListener.prototype.enterLogOpLimitHead = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpLimitHead. +ScrollQLParserListener.prototype.exitLogOpLimitHead = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logOpLimitTail. +ScrollQLParserListener.prototype.enterLogOpLimitTail = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logOpLimitTail. +ScrollQLParserListener.prototype.exitLogOpLimitTail = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionRoot. +ScrollQLParserListener.prototype.enterExpressionRoot = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionRoot. +ScrollQLParserListener.prototype.exitExpressionRoot = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionAddSub. +ScrollQLParserListener.prototype.enterExpressionAddSub = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionAddSub. +ScrollQLParserListener.prototype.exitExpressionAddSub = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionEq. +ScrollQLParserListener.prototype.enterExpressionEq = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionEq. +ScrollQLParserListener.prototype.exitExpressionEq = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionComp. +ScrollQLParserListener.prototype.enterExpressionComp = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionComp. +ScrollQLParserListener.prototype.exitExpressionComp = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionExpo. +ScrollQLParserListener.prototype.enterExpressionExpo = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionExpo. +ScrollQLParserListener.prototype.exitExpressionExpo = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionLike. +ScrollQLParserListener.prototype.enterExpressionLike = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionLike. +ScrollQLParserListener.prototype.exitExpressionLike = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionTerm. +ScrollQLParserListener.prototype.enterExpressionTerm = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionTerm. +ScrollQLParserListener.prototype.exitExpressionTerm = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionNeg. +ScrollQLParserListener.prototype.enterExpressionNeg = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionNeg. +ScrollQLParserListener.prototype.exitExpressionNeg = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionNot. +ScrollQLParserListener.prototype.enterExpressionNot = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionNot. +ScrollQLParserListener.prototype.exitExpressionNot = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionPos. +ScrollQLParserListener.prototype.enterExpressionPos = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionPos. +ScrollQLParserListener.prototype.exitExpressionPos = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionMulDivMod. +ScrollQLParserListener.prototype.enterExpressionMulDivMod = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionMulDivMod. +ScrollQLParserListener.prototype.exitExpressionMulDivMod = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionAnd. +ScrollQLParserListener.prototype.enterExpressionAnd = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionAnd. +ScrollQLParserListener.prototype.exitExpressionAnd = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionNested. +ScrollQLParserListener.prototype.enterExpressionNested = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionNested. +ScrollQLParserListener.prototype.exitExpressionNested = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionOr. +ScrollQLParserListener.prototype.enterExpressionOr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionOr. +ScrollQLParserListener.prototype.exitExpressionOr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#expressionIn. +ScrollQLParserListener.prototype.enterExpressionIn = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#expressionIn. +ScrollQLParserListener.prototype.exitExpressionIn = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#termId. +ScrollQLParserListener.prototype.enterTermId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#termId. +ScrollQLParserListener.prototype.exitTermId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#termNum. +ScrollQLParserListener.prototype.enterTermNum = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#termNum. +ScrollQLParserListener.prototype.exitTermNum = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#termStr. +ScrollQLParserListener.prototype.enterTermStr = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#termStr. +ScrollQLParserListener.prototype.exitTermStr = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#termFn. +ScrollQLParserListener.prototype.enterTermFn = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#termFn. +ScrollQLParserListener.prototype.exitTermFn = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#likeTerm. +ScrollQLParserListener.prototype.enterLikeTerm = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#likeTerm. +ScrollQLParserListener.prototype.exitLikeTerm = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#functionWithArgs. +ScrollQLParserListener.prototype.enterFunctionWithArgs = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#functionWithArgs. +ScrollQLParserListener.prototype.exitFunctionWithArgs = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#functionWithNoArgs. +ScrollQLParserListener.prototype.enterFunctionWithNoArgs = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#functionWithNoArgs. +ScrollQLParserListener.prototype.exitFunctionWithNoArgs = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#functionArgTimePeriod. +ScrollQLParserListener.prototype.enterFunctionArgTimePeriod = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#functionArgTimePeriod. +ScrollQLParserListener.prototype.exitFunctionArgTimePeriod = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#functionArgFieldClause. +ScrollQLParserListener.prototype.enterFunctionArgFieldClause = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#functionArgFieldClause. +ScrollQLParserListener.prototype.exitFunctionArgFieldClause = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#array. +ScrollQLParserListener.prototype.enterArray = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#array. +ScrollQLParserListener.prototype.exitArray = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#arrayElem. +ScrollQLParserListener.prototype.enterArrayElem = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#arrayElem. +ScrollQLParserListener.prototype.exitArrayElem = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#number. +ScrollQLParserListener.prototype.enterNumber = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#number. +ScrollQLParserListener.prototype.exitNumber = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#string. +ScrollQLParserListener.prototype.enterString = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#string. +ScrollQLParserListener.prototype.exitString = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#stringOrBareString. +ScrollQLParserListener.prototype.enterStringOrBareString = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#stringOrBareString. +ScrollQLParserListener.prototype.exitStringOrBareString = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#regex. +ScrollQLParserListener.prototype.enterRegex = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#regex. +ScrollQLParserListener.prototype.exitRegex = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#regexString. +ScrollQLParserListener.prototype.enterRegexString = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#regexString. +ScrollQLParserListener.prototype.exitRegexString = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#logId. +ScrollQLParserListener.prototype.enterLogId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#logId. +ScrollQLParserListener.prototype.exitLogId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#fieldId. +ScrollQLParserListener.prototype.enterFieldId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#fieldId. +ScrollQLParserListener.prototype.exitFieldId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#aliasId. +ScrollQLParserListener.prototype.enterAliasId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#aliasId. +ScrollQLParserListener.prototype.exitAliasId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#userId. +ScrollQLParserListener.prototype.enterUserId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#userId. +ScrollQLParserListener.prototype.exitUserId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#unquotedUserId. +ScrollQLParserListener.prototype.enterUnquotedUserId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#unquotedUserId. +ScrollQLParserListener.prototype.exitUnquotedUserId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#unquotedUserAtId. +ScrollQLParserListener.prototype.enterUnquotedUserAtId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#unquotedUserAtId. +ScrollQLParserListener.prototype.exitUnquotedUserAtId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#unquotedUserBareId. +ScrollQLParserListener.prototype.enterUnquotedUserBareId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#unquotedUserBareId. +ScrollQLParserListener.prototype.exitUnquotedUserBareId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#quotedUserId. +ScrollQLParserListener.prototype.enterQuotedUserId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#quotedUserId. +ScrollQLParserListener.prototype.exitQuotedUserId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#systemId. +ScrollQLParserListener.prototype.enterSystemId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#systemId. +ScrollQLParserListener.prototype.exitSystemId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#unquotedSystemId. +ScrollQLParserListener.prototype.enterUnquotedSystemId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#unquotedSystemId. +ScrollQLParserListener.prototype.exitUnquotedSystemId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#quotedSystemId. +ScrollQLParserListener.prototype.enterQuotedSystemId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#quotedSystemId. +ScrollQLParserListener.prototype.exitQuotedSystemId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#resultId. +ScrollQLParserListener.prototype.enterResultId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#resultId. +ScrollQLParserListener.prototype.exitResultId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#functionId. +ScrollQLParserListener.prototype.enterFunctionId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#functionId. +ScrollQLParserListener.prototype.exitFunctionId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#rawId. +ScrollQLParserListener.prototype.enterRawId = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#rawId. +ScrollQLParserListener.prototype.exitRawId = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#keywords. +ScrollQLParserListener.prototype.enterKeywords = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#keywords. +ScrollQLParserListener.prototype.exitKeywords = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitMilliSeconds. +ScrollQLParserListener.prototype.enterTimeUnitMilliSeconds = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitMilliSeconds. +ScrollQLParserListener.prototype.exitTimeUnitMilliSeconds = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitSeconds. +ScrollQLParserListener.prototype.enterTimeUnitSeconds = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitSeconds. +ScrollQLParserListener.prototype.exitTimeUnitSeconds = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitMinutes. +ScrollQLParserListener.prototype.enterTimeUnitMinutes = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitMinutes. +ScrollQLParserListener.prototype.exitTimeUnitMinutes = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitHours. +ScrollQLParserListener.prototype.enterTimeUnitHours = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitHours. +ScrollQLParserListener.prototype.exitTimeUnitHours = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitDays. +ScrollQLParserListener.prototype.enterTimeUnitDays = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitDays. +ScrollQLParserListener.prototype.exitTimeUnitDays = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitWeeks. +ScrollQLParserListener.prototype.enterTimeUnitWeeks = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitWeeks. +ScrollQLParserListener.prototype.exitTimeUnitWeeks = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitMonths. +ScrollQLParserListener.prototype.enterTimeUnitMonths = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitMonths. +ScrollQLParserListener.prototype.exitTimeUnitMonths = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitQuarters. +ScrollQLParserListener.prototype.enterTimeUnitQuarters = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitQuarters. +ScrollQLParserListener.prototype.exitTimeUnitQuarters = function (ctx) {}; + +// Enter a parse tree produced by ScrollQLParser#timeUnitYears. +ScrollQLParserListener.prototype.enterTimeUnitYears = function (ctx) {}; + +// Exit a parse tree produced by ScrollQLParser#timeUnitYears. +ScrollQLParserListener.prototype.exitTimeUnitYears = function (ctx) {}; + +exports.ScrollQLParserListener = ScrollQLParserListener; diff --git a/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.test.ts b/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.test.ts new file mode 100644 index 0000000..7cd7801 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.test.ts @@ -0,0 +1,69 @@ +import { getStatsGroups } from './getStatsGroups'; + +describe('GroupListener', () => { + it('should correctly parse groups in stats query', () => { + const testQueries = [ + { + query: + 'filter @message like /Exception/ | stats count(*) as exceptionCount by bin(1h) | sort exceptionCount desc', + expected: ['bin(1h)'], + }, + { + query: `filter @type = "REPORT" + | stats max(@memorySize / 1024 / 1024) as provisonedMemoryMB, + min(@maxMemoryUsed / 1024 / 1024) as smallestMemoryRequestMB, + avg(@maxMemoryUsed / 1024 / 1024) as avgMemoryUsedMB, + max(@maxMemoryUsed / 1024 / 1024) as maxMemoryUsedMB, + provisonedMemoryMB - maxMemoryUsedMB as overProvisionedMB`, + expected: [], + }, + { + query: `stats count(@message) by bin(1h), @log, @logStream as fieldAlias`, + expected: ['bin(1h)', '@log', 'fieldAlias'], + }, + + { + query: `stats sum(packets) as packetsTransferred by srcAddr, dstAddr + | sort packetsTransferred desc + | limit 15`, + expected: ['srcAddr', 'dstAddr'], + }, + { + query: `filter isIpv4InSubnet(srcAddr, "192.0.2.0/24") + | stats sum(bytes) as bytesTransferred by dstAddr + | sort bytesTransferred desc + | limit 15`, + expected: ['dstAddr'], + }, + { + query: `filter logStatus="SKIPDATA" + | stats count(*) by bin(1h) as t + | sort t + `, + expected: ['t'], + }, + { + query: `stats count(*) by queryType, bin(1h)`, + expected: ['queryType', 'bin(1h)'], + }, + { + query: `parse @message "user=*, method:*, latency := *" as @user, + @method, @latency | stats avg(@latency) by @method, + @user`, + expected: ['@method', '@user'], + }, + { + query: 'fields @timestamp, @message | sort @timestamp desc | limit 25', + expected: [], + }, + { + query: `stats count(*)`, + expected: [], + }, + ]; + + for (const { query, expected } of testQueries) { + expect(getStatsGroups(query)).toStrictEqual(expected); + } + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.ts b/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.ts new file mode 100644 index 0000000..fe15e3e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/query/getStatsGroups.ts @@ -0,0 +1,41 @@ +const antlr4 = require('antlr4'); +const ScrollQLLexer = require('./ScrollQLLexer').ScrollQLLexer; +const ScrollQLParser = require('./ScrollQLParser').ScrollQLParser; +const ScrollQLParserListener = require('./ScrollQLParserListener').ScrollQLParserListener; + +class GroupListener extends ScrollQLParserListener { + groupNames: string[] = []; + + enterLogStats(ctx: any) { + this.groupNames = []; + if (ctx.groups && ctx.groups.length > 0) { + const groups = ctx.groups; + + groups.forEach((group: any) => { + // This code is for handling the case where a field specifier is aliased, with the alias available via + // the proj property. Otherwise we can just take the group text as it is. + const proj = group.fieldSpec?.().proj; + if (proj) { + this.groupNames.push(proj.getText()); + } else { + this.groupNames.push(group.getText()); + } + }); + } + } +} + +export function getStatsGroups(text: string): string[] { + // Dummy prefix needed here for parser to function correctly + const dummyPrefix = 'source test start=0 end=1|'; + const queryText = dummyPrefix + text; + const chars = new antlr4.InputStream(queryText); + const lexer = new ScrollQLLexer(chars); + const tokens = new antlr4.CommonTokenStream(lexer); + const parser = new ScrollQLParser(tokens); + parser.buildParseTrees = true; + const tree = parser.query(); + const groupListener = new GroupListener(); + antlr4.tree.ParseTreeWalker.DEFAULT.walk(groupListener, tree); + return groupListener.groupNames; +} diff --git a/public/app/plugins/datasource/cloudwatch/utils/rxjs/increasingInterval.ts b/public/app/plugins/datasource/cloudwatch/utils/rxjs/increasingInterval.ts new file mode 100644 index 0000000..7e109d3 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/utils/rxjs/increasingInterval.ts @@ -0,0 +1,32 @@ +import { SchedulerLike, Observable, SchedulerAction, Subscriber, asyncScheduler } from 'rxjs'; + +/** + * Creates an Observable that emits sequential numbers after increasing intervals of time + * starting with `startPeriod`, ending with `endPeriod` and incrementing by `step`. + */ +export const increasingInterval = ( + { startPeriod = 0, endPeriod = 5000, step = 1000 }, + scheduler: SchedulerLike = asyncScheduler +): Observable => { + return new Observable((subscriber) => { + subscriber.add( + scheduler.schedule(dispatch, startPeriod, { subscriber, counter: 0, period: startPeriod, step, endPeriod }) + ); + return subscriber; + }); +}; + +function dispatch(this: SchedulerAction, state: IntervalState) { + const { subscriber, counter, period, step, endPeriod } = state; + subscriber.next(counter); + const newPeriod = Math.min(period + step, endPeriod); + this.schedule({ subscriber, counter: counter + 1, period: newPeriod, step, endPeriod }, newPeriod); +} + +interface IntervalState { + subscriber: Subscriber; + counter: number; + period: number; + endPeriod: number; + step: number; +} diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx new file mode 100644 index 0000000..7e5ea4d --- /dev/null +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -0,0 +1,183 @@ +import React, { PureComponent } from 'react'; +import { LegacyForms, VerticalGroup } from '@grafana/ui'; +import { DataQuery, PanelData, SelectableValue } from '@grafana/data'; +import { css } from 'emotion'; + +import { DashboardQuery, ResultInfo, SHARED_DASHBODARD_QUERY } from './types'; +import config from 'app/core/config'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { PanelModel } from 'app/features/dashboard/state'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { filterPanelDataToQuery } from 'app/features/query/components/QueryEditorRow'; +import { DashboardQueryRow } from './DashboardQueryRow'; + +const { Select } = LegacyForms; + +function getQueryDisplayText(query: DataQuery): string { + return JSON.stringify(query); +} + +interface Props { + queries: DataQuery[]; + panelData: PanelData; + onChange: (queries: DataQuery[]) => void; + onRunQueries: () => void; +} + +type State = { + defaultDatasource: string; + results: ResultInfo[]; +}; + +export class DashboardQueryEditor extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + defaultDatasource: '', + results: [], + }; + } + + getQuery(): DashboardQuery { + return this.props.queries[0] as DashboardQuery; + } + + async componentDidMount() { + await this.updateState(); + } + + async componentDidUpdate(prevProps: Props) { + const { panelData, queries } = this.props; + + if (prevProps.panelData !== panelData || prevProps.queries !== queries) { + await this.updateState(); + } + } + + async updateState() { + const { panelData, queries } = this.props; + + const query = queries[0] as DashboardQuery; + const defaultDS = await getDatasourceSrv().get(); + const dashboard = getDashboardSrv().getCurrent(); + const panel = dashboard.getPanelById(query.panelId ?? -124134); + + if (!panel) { + this.setState({ defaultDatasource: defaultDS.name }); + return; + } + + const mainDS = await getDatasourceSrv().get(panel.datasource); + const info: ResultInfo[] = []; + + for (const query of panel.targets) { + const ds = query.datasource ? await getDatasourceSrv().get(query.datasource) : mainDS; + const fmt = ds.getQueryDisplayText ? ds.getQueryDisplayText : getQueryDisplayText; + + const qData = filterPanelDataToQuery(panelData, query.refId); + const queryData = qData ? qData : panelData; + + info.push({ + refId: query.refId, + query: fmt(query), + img: ds.meta.info.logos.small, + data: queryData.series, + error: queryData.error, + }); + } + + this.setState({ defaultDatasource: defaultDS.name, results: info }); + } + + onPanelChanged = (id: number) => { + const query = this.getQuery(); + + this.props.onChange([ + { + ...query, + panelId: id, + } as DashboardQuery, + ]); + this.props.onRunQueries(); + }; + + renderQueryData(editURL: string) { + const { results } = this.state; + + return ( + + {results.map((target, index) => { + return ; + })} + + ); + } + + getPanelDescription = (panel: PanelModel): string => { + const { defaultDatasource } = this.state; + const dsname = panel.datasource ? panel.datasource : defaultDatasource; + + if (panel.targets.length === 1) { + return '1 query to ' + dsname; + } + + return panel.targets.length + ' queries to ' + dsname; + }; + + render() { + const dashboard = getDashboardSrv().getCurrent(); + const query = this.getQuery(); + + let selected: SelectableValue | undefined; + const panels: Array> = []; + + for (const panel of dashboard.panels) { + const plugin = config.panels[panel.type]; + if (!plugin) { + continue; + } + + if (panel.targets && panel.datasource !== SHARED_DASHBODARD_QUERY) { + const item = { + value: panel.id, + label: panel.title ? panel.title : 'Panel ' + panel.id, + description: this.getPanelDescription(panel), + imgUrl: plugin.info.logos.small, + }; + + panels.push(item); + + if (query.panelId === panel.id) { + selected = item; + } + } + } + + if (panels.length < 1) { + return ( +
    + This dashboard does not have other panels. Add queries to other panels and try again +
    + ); + } + + // Same as current URL, but different panelId + const editURL = `d/${dashboard.uid}/${dashboard.title}?&editPanel=${query.panelId}`; + + return ( +
    +
    +
    Use results from panel
    + dispatch(changeFilter(index, { ...filter, label: e.target.value }))} + defaultValue={filter.label} + /> + + dispatch(addFilter())} + onRemove={() => dispatch(removeFilter(index))} + /> +
    + ))} +
    + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts new file mode 100644 index 0000000..61da5fb --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts @@ -0,0 +1,16 @@ +import { Filter } from '../../../aggregations'; +import { FilterAction, ADD_FILTER, REMOVE_FILTER, CHANGE_FILTER } from './types'; + +export const addFilter = (): FilterAction => ({ + type: ADD_FILTER, +}); + +export const removeFilter = (index: number): FilterAction => ({ + type: REMOVE_FILTER, + payload: { index }, +}); + +export const changeFilter = (index: number, filter: Filter): FilterAction => ({ + type: CHANGE_FILTER, + payload: { index, filter }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts new file mode 100644 index 0000000..0d68450 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts @@ -0,0 +1,52 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { Filter } from '../../../aggregations'; +import { addFilter, changeFilter, removeFilter } from './actions'; +import { reducer } from './reducer'; + +describe('Filters Bucket Aggregation Settings Reducer', () => { + it('Should correctly add new filter', () => { + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addFilter()) + .thenStatePredicateShouldEqual((state: Filter[]) => state.length === 1); + }); + + it('Should correctly remove filters', () => { + const firstFilter: Filter = { + label: 'First', + query: '*', + }; + + const secondFilter: Filter = { + label: 'Second', + query: '*', + }; + + reducerTester() + .givenReducer(reducer, [firstFilter, secondFilter]) + .whenActionIsDispatched(removeFilter(0)) + .thenStateShouldEqual([secondFilter]); + }); + + it("Should correctly change filter's attributes", () => { + const firstFilter: Filter = { + label: 'First', + query: '*', + }; + + const secondFilter: Filter = { + label: 'Second', + query: '*', + }; + + const expectedSecondFilter: Filter = { + label: 'Changed label', + query: 'Changed query', + }; + + reducerTester() + .givenReducer(reducer, [firstFilter, secondFilter]) + .whenActionIsDispatched(changeFilter(1, expectedSecondFilter)) + .thenStateShouldEqual([firstFilter, expectedSecondFilter]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts new file mode 100644 index 0000000..5a4dcde --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts @@ -0,0 +1,21 @@ +import { Filter } from '../../../aggregations'; +import { defaultFilter } from '../utils'; +import { ADD_FILTER, CHANGE_FILTER, FilterAction, REMOVE_FILTER } from './types'; + +export const reducer = (state: Filter[] = [], action: FilterAction) => { + switch (action.type) { + case ADD_FILTER: + return [...state, defaultFilter()]; + case REMOVE_FILTER: + return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1)); + + case CHANGE_FILTER: + return state.map((filter, index) => { + if (index !== action.payload.index) { + return filter; + } + + return action.payload.filter; + }); + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts new file mode 100644 index 0000000..b9f5ff3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts @@ -0,0 +1,22 @@ +import { Action } from '../../../../../../hooks/useStatelessReducer'; +import { Filter } from '../../../aggregations'; + +export const ADD_FILTER = '@bucketAggregations/filter/add'; +export const REMOVE_FILTER = '@bucketAggregations/filter/remove'; +export const CHANGE_FILTER = '@bucketAggregations/filter/change'; + +export type AddFilterAction = Action; + +export interface RemoveFilterAction extends Action { + payload: { + index: number; + }; +} + +export interface ChangeFilterAction extends Action { + payload: { + index: number; + filter: Filter; + }; +} +export type FilterAction = AddFilterAction | RemoveFilterAction | ChangeFilterAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts new file mode 100644 index 0000000..ab35667 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts @@ -0,0 +1,3 @@ +import { Filter } from '../../aggregations'; + +export const defaultFilter = (): Filter => ({ label: '', query: '*' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx new file mode 100644 index 0000000..c815b08 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx @@ -0,0 +1,164 @@ +import { InlineField, Input, Select } from '@grafana/ui'; +import React, { ComponentProps, FunctionComponent } from 'react'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { SettingsEditorContainer } from '../../SettingsEditorContainer'; +import { changeBucketAggregationSetting } from '../state/actions'; +import { BucketAggregation } from '../aggregations'; +import { + bucketAggregationConfig, + createOrderByOptionsFromMetrics, + intervalOptions, + orderOptions, + sizeOptions, +} from '../utils'; +import { FiltersSettingsEditor } from './FiltersSettingsEditor'; +import { useDescription } from './useDescription'; +import { useQuery } from '../../ElasticsearchQueryContext'; + +const inlineFieldProps: Partial> = { + labelWidth: 16, +}; + +interface Props { + bucketAgg: BucketAggregation; +} + +export const SettingsEditor: FunctionComponent = ({ bucketAgg }) => { + const dispatch = useDispatch(); + const { metrics } = useQuery(); + const settingsDescription = useDescription(bucketAgg); + const orderBy = createOrderByOptionsFromMetrics(metrics); + + return ( + + {bucketAgg.type === 'terms' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'size', e.value!))} + options={sizeOptions} + value={bucketAgg.settings?.size || bucketAggregationConfig[bucketAgg.type].defaultSettings?.size} + allowCustomValue + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'missing', e.target.value!))} + defaultValue={ + bucketAgg.settings?.missing || bucketAggregationConfig[bucketAgg.type].defaultSettings?.missing + } + /> + + + )} + + {bucketAgg.type === 'geohash_grid' && ( + + dispatch(changeBucketAggregationSetting(bucketAgg, 'precision', e.target.value!))} + defaultValue={ + bucketAgg.settings?.precision || bucketAggregationConfig[bucketAgg.type].defaultSettings?.precision + } + /> + + )} + + {bucketAgg.type === 'date_histogram' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'trimEdges', e.target.value!))} + defaultValue={ + bucketAgg.settings?.trimEdges || bucketAggregationConfig[bucketAgg.type].defaultSettings?.trimEdges + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'offset', e.target.value!))} + defaultValue={ + bucketAgg.settings?.offset || bucketAggregationConfig[bucketAgg.type].defaultSettings?.offset + } + /> + + + )} + + {bucketAgg.type === 'histogram' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.target.value!))} + defaultValue={ + bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + )} + + {bucketAgg.type === 'filters' && } + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts new file mode 100644 index 0000000..18d2343 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -0,0 +1,89 @@ +import { describeMetric, convertOrderByToMetricId } from '../../../../utils'; +import { useQuery } from '../../ElasticsearchQueryContext'; +import { BucketAggregation } from '../aggregations'; +import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils'; + +const hasValue = (value: string) => (object: { value: string }) => object.value === value; + +// FIXME: We should apply the same defaults we have in bucketAggregationsConfig here instead of "custom" values +// as they might get out of sync. +// The reason we need them is that even though after the refactoring each setting is created with its default value, +// queries created with the old version might not have them. +export const useDescription = (bucketAgg: BucketAggregation): string => { + const { metrics } = useQuery(); + + switch (bucketAgg.type) { + case 'terms': { + const order = bucketAgg.settings?.order || 'desc'; + const size = bucketAgg.settings?.size || '10'; + const minDocCount = parseInt(bucketAgg.settings?.min_doc_count || '0', 10); + const orderBy = bucketAgg.settings?.orderBy || '_term'; + let description = ''; + + if (size !== '0') { + const orderLabel = orderOptions.find(hasValue(order))?.label!; + description = `${orderLabel} ${size}, `; + } + + if (minDocCount > 0) { + description += `Min Doc Count: ${minDocCount}, `; + } + + description += 'Order by: '; + const orderByOption = orderByOptions.find(hasValue(orderBy)); + if (orderByOption) { + description += orderByOption.label; + } else { + const metric = metrics?.find((m) => m.id === convertOrderByToMetricId(orderBy)); + if (metric) { + description += describeMetric(metric); + } else { + description += 'metric not found'; + } + } + + if (size === '0') { + description += ` (${order})`; + } + return description; + } + + case 'histogram': { + const interval = bucketAgg.settings?.interval || 1000; + const minDocCount = bucketAgg.settings?.min_doc_count || 1; + + return `Interval: ${interval}${minDocCount > 0 ? `, Min Doc Count: ${minDocCount}` : ''}`; + } + + case 'filters': { + const filters = bucketAgg.settings?.filters || bucketAggregationConfig['filters'].defaultSettings?.filters; + return `Filter Queries (${filters!.length})`; + } + + case 'geohash_grid': { + const precision = Math.max(Math.min(parseInt(bucketAgg.settings?.precision || '5', 10), 12), 1); + return `Precision: ${precision}`; + } + + case 'date_histogram': { + const interval = bucketAgg.settings?.interval || 'auto'; + const minDocCount = bucketAgg.settings?.min_doc_count || 0; + const trimEdges = bucketAgg.settings?.trimEdges || 0; + + let description = `Interval: ${interval}`; + + if (minDocCount > 0) { + description += `, Min Doc Count: ${minDocCount}`; + } + + if (trimEdges > 0) { + description += `, Trim edges: ${trimEdges}`; + } + + return description; + } + + default: + return 'Settings'; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts new file mode 100644 index 0000000..36f8e59 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts @@ -0,0 +1,68 @@ +import { bucketAggregationConfig } from './utils'; + +export type BucketAggregationType = 'terms' | 'filters' | 'geohash_grid' | 'date_histogram' | 'histogram'; + +interface BaseBucketAggregation { + id: string; + type: BucketAggregationType; + settings?: Record; +} + +export interface BucketAggregationWithField extends BaseBucketAggregation { + field?: string; +} + +export interface DateHistogram extends BucketAggregationWithField { + type: 'date_histogram'; + settings?: { + interval?: string; + min_doc_count?: string; + trimEdges?: string; + offset?: string; + }; +} + +export interface Histogram extends BucketAggregationWithField { + type: 'histogram'; + settings?: { + interval?: string; + min_doc_count?: string; + }; +} + +type TermsOrder = 'desc' | 'asc'; + +export interface Terms extends BucketAggregationWithField { + type: 'terms'; + settings?: { + order?: TermsOrder; + size?: string; + min_doc_count?: string; + orderBy?: string; + missing?: string; + }; +} + +export type Filter = { + query: string; + label: string; +}; +export interface Filters extends BaseBucketAggregation { + type: 'filters'; + settings?: { + filters?: Filter[]; + }; +} + +interface GeoHashGrid extends BucketAggregationWithField { + type: 'geohash_grid'; + settings?: { + precision?: string; + }; +} + +export type BucketAggregation = DateHistogram | Histogram | Terms | Filters | GeoHashGrid; + +export const isBucketAggregationWithField = ( + bucketAgg: BucketAggregation | BucketAggregationWithField +): bucketAgg is BucketAggregationWithField => bucketAggregationConfig[bucketAgg.type].requiresField; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx new file mode 100644 index 0000000..0f8dc08 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx @@ -0,0 +1,37 @@ +import React, { FunctionComponent } from 'react'; +import { BucketAggregationEditor } from './BucketAggregationEditor'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { addBucketAggregation, removeBucketAggregation } from './state/actions'; +import { BucketAggregationAction } from './state/types'; +import { BucketAggregation } from './aggregations'; +import { useQuery } from '../ElasticsearchQueryContext'; +import { QueryEditorRow } from '../QueryEditorRow'; +import { IconButton } from '../../IconButton'; + +interface Props { + nextId: BucketAggregation['id']; +} + +export const BucketAggregationsEditor: FunctionComponent = ({ nextId }) => { + const dispatch = useDispatch(); + const { bucketAggs } = useQuery(); + const totalBucketAggs = bucketAggs?.length || 0; + + return ( + <> + {bucketAggs!.map((bucketAgg, index) => ( + 1 && (() => dispatch(removeBucketAggregation(bucketAgg.id)))} + > + + + {index === 0 && ( + dispatch(addBucketAggregation(nextId))} label="add" /> + )} + + ))} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts new file mode 100644 index 0000000..31203ee --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts @@ -0,0 +1,61 @@ +import { SettingKeyOf } from '../../../types'; +import { BucketAggregation, BucketAggregationWithField } from '../aggregations'; +import { + ADD_BUCKET_AGG, + BucketAggregationAction, + REMOVE_BUCKET_AGG, + CHANGE_BUCKET_AGG_TYPE, + CHANGE_BUCKET_AGG_FIELD, + CHANGE_BUCKET_AGG_SETTING, + ChangeBucketAggregationSettingAction, +} from './types'; + +export const addBucketAggregation = (id: string): BucketAggregationAction => ({ + type: ADD_BUCKET_AGG, + payload: { + id, + }, +}); + +export const removeBucketAggregation = (id: BucketAggregation['id']): BucketAggregationAction => ({ + type: REMOVE_BUCKET_AGG, + payload: { + id, + }, +}); + +export const changeBucketAggregationType = ( + id: BucketAggregation['id'], + newType: BucketAggregation['type'] +): BucketAggregationAction => ({ + type: CHANGE_BUCKET_AGG_TYPE, + payload: { + id, + newType, + }, +}); + +export const changeBucketAggregationField = ( + id: BucketAggregationWithField['id'], + newField: BucketAggregationWithField['field'] +): BucketAggregationAction => ({ + type: CHANGE_BUCKET_AGG_FIELD, + payload: { + id, + newField, + }, +}); + +export const changeBucketAggregationSetting = >( + bucketAgg: T, + settingName: K, + // This could be inferred from T, but it's causing some troubles + newValue: string | string[] | any +): ChangeBucketAggregationSettingAction => ({ + type: CHANGE_BUCKET_AGG_SETTING, + payload: { + bucketAgg, + settingName, + newValue, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts new file mode 100644 index 0000000..34dcd0d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -0,0 +1,152 @@ +import { defaultBucketAgg } from 'app/plugins/datasource/elasticsearch/query_def'; +import { reducerTester } from 'test/core/redux/reducerTester'; +import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; +import { BucketAggregation, DateHistogram } from '../aggregations'; +import { bucketAggregationConfig } from '../utils'; +import { + addBucketAggregation, + changeBucketAggregationField, + changeBucketAggregationSetting, + changeBucketAggregationType, + removeBucketAggregation, +} from './actions'; +import { reducer } from './reducer'; +import { initQuery } from '../../state'; + +describe('Bucket Aggregations Reducer', () => { + it('Should correctly add new aggregations', () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + const secondAggregation: BucketAggregation = { + id: '1', + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addBucketAggregation(firstAggregation.id)) + .thenStateShouldEqual([firstAggregation]) + .whenActionIsDispatched(addBucketAggregation(secondAggregation.id)) + .thenStateShouldEqual([firstAggregation, secondAggregation]); + }); + + it('Should correctly remove aggregations', () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(removeBucketAggregation(firstAggregation.id)) + .thenStateShouldEqual([secondAggregation]); + }); + + it("Should correctly change aggregation's type", () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + const expectedSecondAggregation: BucketAggregation = { + ...secondAggregation, + type: 'histogram', + settings: bucketAggregationConfig['histogram'].defaultSettings, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeBucketAggregationType(secondAggregation.id, expectedSecondAggregation.type)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]); + }); + + it("Should correctly change aggregation's field", () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + const expectedSecondAggregation = { + ...secondAggregation, + field: 'new field', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeBucketAggregationField(secondAggregation.id, expectedSecondAggregation.field)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]); + }); + + describe("When changing a metric aggregation's type", () => { + it('Should remove and restore bucket aggregations correctly', () => { + const initialState: BucketAggregation[] = [ + { + id: '1', + type: 'date_histogram', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + // If the new metric aggregation is `isSingleMetric` we should remove all bucket aggregations. + .whenActionIsDispatched(changeMetricType('Some id', 'raw_data')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 0) + // Switching back to another aggregation that is NOT `isSingleMetric` should bring back a bucket aggregation + .whenActionIsDispatched(changeMetricType('Some id', 'max')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1) + // When none of the above is true state shouldn't change. + .whenActionIsDispatched(changeMetricType('Some id', 'min')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1); + }); + }); + + it("Should correctly change aggregation's settings", () => { + const firstAggregation: DateHistogram = { + id: '1', + type: 'date_histogram', + settings: { + min_doc_count: '0', + }, + }; + const secondAggregation: DateHistogram = { + id: '2', + type: 'date_histogram', + }; + + const expectedSettings: typeof firstAggregation['settings'] = { + min_doc_count: '1', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched( + changeBucketAggregationSetting(firstAggregation, 'min_doc_count', expectedSettings.min_doc_count!) + ) + .thenStateShouldEqual([{ ...firstAggregation, settings: expectedSettings }, secondAggregation]); + }); + + it('Should correctly initialize first Bucket Aggregation', () => { + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(initQuery()) + .thenStateShouldEqual([defaultBucketAgg('2')]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts new file mode 100644 index 0000000..a440b6e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -0,0 +1,113 @@ +import { defaultBucketAgg } from '../../../../query_def'; +import { ElasticsearchQuery } from '../../../../types'; +import { ChangeMetricTypeAction, CHANGE_METRIC_TYPE } from '../../MetricAggregationsEditor/state/types'; +import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils'; +import { BucketAggregation, Terms } from '../aggregations'; +import { INIT, InitAction } from '../../state'; +import { + ADD_BUCKET_AGG, + REMOVE_BUCKET_AGG, + CHANGE_BUCKET_AGG_TYPE, + CHANGE_BUCKET_AGG_FIELD, + CHANGE_BUCKET_AGG_SETTING, + BucketAggregationAction, +} from './types'; +import { bucketAggregationConfig } from '../utils'; +import { removeEmpty } from '../../../../utils'; + +export const reducer = ( + state: ElasticsearchQuery['bucketAggs'], + action: BucketAggregationAction | ChangeMetricTypeAction | InitAction +): ElasticsearchQuery['bucketAggs'] => { + switch (action.type) { + case ADD_BUCKET_AGG: + const newAgg: Terms = { + id: action.payload.id, + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + // If the last bucket aggregation is a `date_histogram` we add the new one before it. + const lastAgg = state![state!.length - 1]; + if (lastAgg?.type === 'date_histogram') { + return [...state!.slice(0, state!.length - 1), newAgg, lastAgg]; + } + + return [...state!, newAgg]; + + case REMOVE_BUCKET_AGG: + return state!.filter((bucketAgg) => bucketAgg.id !== action.payload.id); + + case CHANGE_BUCKET_AGG_TYPE: + return state!.map((bucketAgg) => { + if (bucketAgg.id !== action.payload.id) { + return bucketAgg; + } + + /* + TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + return { + id: bucketAgg.id, + type: action.payload.newType, + settings: bucketAggregationConfig[action.payload.newType].defaultSettings, + } as BucketAggregation; + }); + + case CHANGE_BUCKET_AGG_FIELD: + return state!.map((bucketAgg) => { + if (bucketAgg.id !== action.payload.id) { + return bucketAgg; + } + + return { + ...bucketAgg, + field: action.payload.newField, + }; + }); + + case CHANGE_METRIC_TYPE: + // If we are switching to a metric which requires the absence of bucket aggregations + // we remove all of them. + if (metricAggregationConfig[action.payload.type].isSingleMetric) { + return []; + } else if (state!.length === 0) { + // Else, if there are no bucket aggregations we restore a default one. + // This happens when switching from a metric that requires the absence of bucket aggregations to + // one that requires it. + return [defaultBucketAgg()]; + } + return state; + + case CHANGE_BUCKET_AGG_SETTING: + return state!.map((bucketAgg) => { + if (bucketAgg.id !== action.payload.bucketAgg.id) { + return bucketAgg; + } + + const newSettings = removeEmpty({ + ...bucketAgg.settings, + [action.payload.settingName]: action.payload.newValue, + }); + + return { + ...bucketAgg, + settings: { + ...newSettings, + }, + }; + }); + + case INIT: + if (state?.length || 0 > 0) { + return state; + } + return [defaultBucketAgg('2')]; + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts new file mode 100644 index 0000000..a0b0f6e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts @@ -0,0 +1,51 @@ +import { Action } from '../../../../hooks/useStatelessReducer'; +import { SettingKeyOf } from '../../../types'; +import { BucketAggregation, BucketAggregationWithField } from '../aggregations'; + +export const ADD_BUCKET_AGG = '@bucketAggs/add'; +export const REMOVE_BUCKET_AGG = '@bucketAggs/remove'; +export const CHANGE_BUCKET_AGG_TYPE = '@bucketAggs/change_type'; +export const CHANGE_BUCKET_AGG_FIELD = '@bucketAggs/change_field'; +export const CHANGE_BUCKET_AGG_SETTING = '@bucketAggs/change_setting'; + +export interface AddBucketAggregationAction extends Action { + payload: { + id: BucketAggregation['id']; + }; +} + +export interface RemoveBucketAggregationAction extends Action { + payload: { + id: BucketAggregation['id']; + }; +} + +export interface ChangeBucketAggregationTypeAction extends Action { + payload: { + id: BucketAggregation['id']; + newType: BucketAggregation['type']; + }; +} + +export interface ChangeBucketAggregationFieldAction extends Action { + payload: { + id: BucketAggregation['id']; + newField: BucketAggregationWithField['field']; + }; +} + +export interface ChangeBucketAggregationSettingAction + extends Action { + payload: { + bucketAgg: T; + settingName: SettingKeyOf; + newValue: unknown; + }; +} + +export type BucketAggregationAction = + | AddBucketAggregationAction + | RemoveBucketAggregationAction + | ChangeBucketAggregationTypeAction + | ChangeBucketAggregationFieldAction + | ChangeBucketAggregationSettingAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts new file mode 100644 index 0000000..45541c7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts @@ -0,0 +1,143 @@ +import { BucketsConfiguration } from '../../../types'; +import { defaultFilter } from './SettingsEditor/FiltersSettingsEditor/utils'; +import { describeMetric } from '../../../utils'; +import { + ExtendedStatMetaType, + ExtendedStats, + MetricAggregation, + Percentiles, +} from '../MetricAggregationsEditor/aggregations'; +import { SelectableValue } from '@grafana/data'; + +export const bucketAggregationConfig: BucketsConfiguration = { + terms: { + label: 'Terms', + requiresField: true, + defaultSettings: { + min_doc_count: '0', + size: '10', + order: 'desc', + orderBy: '_term', + }, + }, + filters: { + label: 'Filters', + requiresField: false, + defaultSettings: { + filters: [defaultFilter()], + }, + }, + geohash_grid: { + label: 'Geo Hash Grid', + requiresField: true, + defaultSettings: { + precision: '3', + }, + }, + date_histogram: { + label: 'Date Histogram', + requiresField: true, + defaultSettings: { + interval: 'auto', + min_doc_count: '0', + trimEdges: '0', + }, + }, + histogram: { + label: 'Histogram', + requiresField: true, + defaultSettings: { + interval: '1000', + min_doc_count: '0', + }, + }, +}; + +// TODO: Define better types for the following +type OrderByOption = SelectableValue; +export const orderOptions: OrderByOption[] = [ + { label: 'Top', value: 'desc' }, + { label: 'Bottom', value: 'asc' }, +]; + +export const sizeOptions = [ + { label: 'No limit', value: '0' }, + { label: '1', value: '1' }, + { label: '2', value: '2' }, + { label: '3', value: '3' }, + { label: '5', value: '5' }, + { label: '10', value: '10' }, + { label: '15', value: '15' }, + { label: '20', value: '20' }, +]; + +export const orderByOptions = [ + { label: 'Term value', value: '_term' }, + { label: 'Doc Count', value: '_count' }, +]; + +export const intervalOptions = [ + { label: 'auto', value: 'auto' }, + { label: '10s', value: '10s' }, + { label: '1m', value: '1m' }, + { label: '5m', value: '5m' }, + { label: '10m', value: '10m' }, + { label: '20m', value: '20m' }, + { label: '1h', value: '1h' }, + { label: '1d', value: '1d' }, +]; + +/** + * This returns the valid options for each of the enabled extended stat + */ +function createOrderByOptionsForExtendedStats(metric: ExtendedStats): OrderByOption[] { + if (!metric.meta) { + return []; + } + const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[]; + return metaKeys + .filter((key) => metric.meta?.[key]) + .map((key) => { + let method = key as string; + // The bucket path for std_deviation_bounds.lower and std_deviation_bounds.upper + // is accessed via std_lower and std_upper, respectively. + if (key === 'std_deviation_bounds_lower') { + method = 'std_lower'; + } + if (key === 'std_deviation_bounds_upper') { + method = 'std_upper'; + } + return { label: `${describeMetric(metric)} (${method})`, value: `${metric.id}[${method}]` }; + }); +} + +/** + * This returns the valid options for each of the percents listed in the percentile settings + */ +function createOrderByOptionsForPercentiles(metric: Percentiles): OrderByOption[] { + if (!metric.settings?.percents) { + return []; + } + return metric.settings.percents.map((percent) => { + // The bucket path for percentile numbers is appended with a `.0` if the number is whole + // otherwise you have to use the actual value. + const percentString = /^\d+\.\d+/.test(`${percent}`) ? percent : `${percent}.0`; + return { label: `${describeMetric(metric)} (${percent})`, value: `${metric.id}[${percentString}]` }; + }); +} + +/** + * This creates all the valid order by options based on the metrics + */ +export const createOrderByOptionsFromMetrics = (metrics: MetricAggregation[] = []): OrderByOption[] => { + const metricOptions = metrics.flatMap((metric) => { + if (metric.type === 'extended_stats') { + return createOrderByOptionsForExtendedStats(metric); + } else if (metric.type === 'percentiles') { + return createOrderByOptionsForPercentiles(metric); + } else { + return { label: describeMetric(metric), value: metric.id }; + } + }); + return [...orderByOptions, ...metricOptions]; +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx new file mode 100644 index 0000000..fce0ac0 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx @@ -0,0 +1,92 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { render } from '@testing-library/react'; +import { ElasticsearchProvider, useDatasource, useQuery } from './ElasticsearchQueryContext'; +import { ElasticsearchQuery } from '../../types'; +import { ElasticDatasource } from '../../datasource'; + +const query: ElasticsearchQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], +}; + +describe('ElasticsearchQueryContext', () => { + it('Should call onChange and onRunQuery with the default query when the query is empty', () => { + const datasource = { timeField: 'TIMEFIELD' } as ElasticDatasource; + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + + render( + + ); + + const changedQuery: ElasticsearchQuery = onChange.mock.calls[0][0]; + expect(changedQuery.query).toBeDefined(); + expect(changedQuery.alias).toBeDefined(); + expect(changedQuery.metrics).toBeDefined(); + expect(changedQuery.bucketAggs).toBeDefined(); + + // Should also set timeField to the configured `timeField` option in datasource configuration + expect(changedQuery.timeField).toBe(datasource.timeField); + + expect(onRunQuery).toHaveBeenCalled(); + }); + + describe('useQuery Hook', () => { + it('Should throw when used outside of ElasticsearchQueryContext', () => { + const { result } = renderHook(() => useQuery()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return the current query object', () => { + const wrapper: FunctionComponent = ({ children }) => ( + {}} + onRunQuery={() => {}} + > + {children} + + ); + + const { result } = renderHook(() => useQuery(), { + wrapper, + }); + + expect(result.current).toBe(query); + }); + }); + + describe('useDatasource Hook', () => { + it('Should throw when used outside of ElasticsearchQueryContext', () => { + const { result } = renderHook(() => useDatasource()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return the current datasource instance', () => { + const datasource = {} as ElasticDatasource; + + const wrapper: FunctionComponent = ({ children }) => ( + {}} onRunQuery={() => {}}> + {children} + + ); + + const { result } = renderHook(() => useDatasource(), { + wrapper, + }); + + expect(result.current).toBe(datasource); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx new file mode 100644 index 0000000..e05a73e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -0,0 +1,83 @@ +import React, { createContext, FunctionComponent, useCallback, useContext } from 'react'; +import { ElasticDatasource } from '../../datasource'; +import { combineReducers, useStatelessReducer, DispatchContext } from '../../hooks/useStatelessReducer'; +import { ElasticsearchQuery } from '../../types'; + +import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer'; +import { reducer as bucketAggsReducer } from './BucketAggregationsEditor/state/reducer'; +import { aliasPatternReducer, queryReducer, initQuery } from './state'; + +const DatasourceContext = createContext(undefined); +const QueryContext = createContext(undefined); + +interface Props { + query: ElasticsearchQuery; + onChange: (query: ElasticsearchQuery) => void; + onRunQuery: () => void; + datasource: ElasticDatasource; +} + +export const ElasticsearchProvider: FunctionComponent = ({ + children, + onChange, + onRunQuery, + query, + datasource, +}) => { + const onStateChange = useCallback( + (query: ElasticsearchQuery) => { + onChange(query); + onRunQuery(); + }, + [onChange, onRunQuery] + ); + + const reducer = combineReducers({ + query: queryReducer, + alias: aliasPatternReducer, + metrics: metricsReducer, + bucketAggs: bucketAggsReducer, + }); + + const dispatch = useStatelessReducer( + // timeField is part of the query model, but its value is always set to be the one from datasource settings. + (newState) => onStateChange({ ...query, ...newState, timeField: datasource.timeField }), + query, + reducer + ); + + // This initializes the query by dispatching an init action to each reducer. + // useStatelessReducer will then call `onChange` with the newly generated query + if (!query.metrics || !query.bucketAggs || query.query === undefined) { + dispatch(initQuery()); + + return null; + } + + return ( + + + {children} + + + ); +}; + +export const useQuery = (): ElasticsearchQuery => { + const query = useContext(QueryContext); + + if (!query) { + throw new Error('use ElasticsearchProvider first.'); + } + + return query; +}; + +export const useDatasource = () => { + const datasource = useContext(DatasourceContext); + if (!datasource) { + throw new Error('use ElasticsearchProvider first.'); + } + + return datasource; +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx new file mode 100644 index 0000000..3defc6b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx @@ -0,0 +1,122 @@ +import { MetricFindValue, SelectableValue } from '@grafana/data'; +import { InlineSegmentGroup, Segment, SegmentAsync, useTheme } from '@grafana/ui'; +import { cx } from 'emotion'; +import React, { FunctionComponent } from 'react'; +import { useDatasource, useQuery } from '../ElasticsearchQueryContext'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { getStyles } from './styles'; +import { SettingsEditor } from './SettingsEditor'; +import { MetricAggregationAction } from './state/types'; +import { metricAggregationConfig } from './utils'; +import { changeMetricField, changeMetricType } from './state/actions'; +import { MetricPicker } from '../../MetricPicker'; +import { segmentStyles } from '../styles'; +import { + isMetricAggregationWithField, + isMetricAggregationWithSettings, + isPipelineAggregation, + isPipelineAggregationWithMultipleBucketPaths, + MetricAggregation, + MetricAggregationType, +} from './aggregations'; + +const toOption = (metric: MetricAggregation) => ({ + label: metricAggregationConfig[metric.type].label, + value: metric.type, +}); + +const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue => ({ + label: text, + value: `${value || text}`, +}); + +interface Props { + value: MetricAggregation; +} + +// If a metric is a Pipeline Aggregation (https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html) +// it doesn't make sense to show it in the type picker when there is no non-pipeline-aggregation previously selected +// as they work on the outputs produced from other aggregations rather than from documents or fields. +// This means we should filter them out from the type picker if there's no other "basic" aggregation before the current one. +const isBasicAggregation = (metric: MetricAggregation) => !metricAggregationConfig[metric.type].isPipelineAgg; + +const getTypeOptions = ( + previousMetrics: MetricAggregation[], + esVersion: number +): Array> => { + // we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one + const includePipelineAggregations = previousMetrics.some(isBasicAggregation); + + return ( + Object.entries(metricAggregationConfig) + // Only showing metrics type supported by the configured version of ES + .filter(([_, { minVersion = 0, maxVersion = esVersion }]) => { + // TODO: Double check this + return esVersion >= minVersion && esVersion <= maxVersion; + }) + // Filtering out Pipeline Aggregations if there's no basic metric selected before + .filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg) + .map(([key, { label }]) => ({ + label, + value: key as MetricAggregationType, + })) + ); +}; + +export const MetricEditor: FunctionComponent = ({ value }) => { + const styles = getStyles(useTheme(), !!value.hide); + const datasource = useDatasource(); + const query = useQuery(); + const dispatch = useDispatch(); + + const previousMetrics = query.metrics!.slice( + 0, + query.metrics!.findIndex((m) => m.id === value.id) + ); + + // TODO: This could be common with the one in BucketAggregationEditor + const getFields = async () => { + const get = () => { + if (value.type === 'cardinality') { + return datasource.getFields(); + } + return datasource.getFields('number'); + }; + + return (await get().toPromise()).map(toSelectableValue); + }; + + return ( + <> + + dispatch(changeMetricType(value.id, e.value!))} + value={toOption(value)} + /> + + {isMetricAggregationWithField(value) && !isPipelineAggregation(value) && ( + dispatch(changeMetricField(value.id, e.value!))} + placeholder="Select Field" + value={value.field} + /> + )} + + {isPipelineAggregation(value) && !isPipelineAggregationWithMultipleBucketPaths(value) && ( + dispatch(changeMetricField(value.id, e.value?.id!))} + options={previousMetrics} + value={value.field} + /> + )} + + + {isMetricAggregationWithSettings(value) && } + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx new file mode 100644 index 0000000..ca31e61 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx @@ -0,0 +1,106 @@ +import React, { Fragment, FunctionComponent, useEffect } from 'react'; +import { Input, InlineLabel } from '@grafana/ui'; +import { MetricAggregationAction } from '../../state/types'; +import { changeMetricAttribute } from '../../state/actions'; +import { css } from 'emotion'; +import { AddRemove } from '../../../../AddRemove'; +import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer'; +import { MetricPicker } from '../../../../MetricPicker'; +import { reducer } from './state/reducer'; +import { + addPipelineVariable, + removePipelineVariable, + renamePipelineVariable, + changePipelineVariableMetric, +} from './state/actions'; +import { SettingField } from '../SettingField'; +import { BucketScript, MetricAggregation } from '../../aggregations'; +import { uniqueId } from 'lodash'; + +interface Props { + value: BucketScript; + previousMetrics: MetricAggregation[]; +} + +export const BucketScriptSettingsEditor: FunctionComponent = ({ value, previousMetrics }) => { + const upperStateDispatch = useDispatch>(); + + const dispatch = useStatelessReducer( + (newState) => upperStateDispatch(changeMetricAttribute(value, 'pipelineVariables', newState)), + value.pipelineVariables, + reducer + ); + + // The model might not have pipeline variables (or an empty array of pipeline vars) in it because of the way it was built in previous versions of the datasource. + // If this is the case we add a default one. + useEffect(() => { + if (!value.pipelineVariables?.length) { + dispatch(addPipelineVariable()); + } + }, []); + + return ( + <> +
    + Variables +
    + {value.pipelineVariables!.map((pipelineVar, index) => ( + // index as a key doesn't work here since removing an element + // in the middle of the list, will cause the next element to obtain the same key as the removed one. + // this will cause react to "drop" the last element of the list instead of the just removed one, + // and the default value for the input won't match the model as the DOM won't get updated. + // using pipelineVar.name is not an option since it might be duplicated by the user. + // generating a unique key on every render, while is probably not the best solution in terms of performance + // ensures the UI is in a correct state. We might want to optimize this if we see perf issue in the future. + +
    + dispatch(renamePipelineVariable(e.target.value, index))} + /> + dispatch(changePipelineVariableMetric(e.value!.id, index))} + options={previousMetrics} + value={pipelineVar.pipelineAgg} + /> +
    + + dispatch(addPipelineVariable())} + onRemove={() => dispatch(removePipelineVariable(index))} + /> +
    + ))} +
    +
    + + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts new file mode 100644 index 0000000..67d32db --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts @@ -0,0 +1,34 @@ +import { + ADD_PIPELINE_VARIABLE, + REMOVE_PIPELINE_VARIABLE, + PipelineVariablesAction, + RENAME_PIPELINE_VARIABLE, + CHANGE_PIPELINE_VARIABLE_METRIC, +} from './types'; + +export const addPipelineVariable = (): PipelineVariablesAction => ({ + type: ADD_PIPELINE_VARIABLE, +}); + +export const removePipelineVariable = (index: number): PipelineVariablesAction => ({ + type: REMOVE_PIPELINE_VARIABLE, + payload: { + index, + }, +}); + +export const renamePipelineVariable = (newName: string, index: number): PipelineVariablesAction => ({ + type: RENAME_PIPELINE_VARIABLE, + payload: { + index, + newName, + }, +}); + +export const changePipelineVariableMetric = (newMetric: string, index: number): PipelineVariablesAction => ({ + type: CHANGE_PIPELINE_VARIABLE_METRIC, + payload: { + index, + newMetric, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts new file mode 100644 index 0000000..00f85e1 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -0,0 +1,118 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { PipelineVariable } from '../../../aggregations'; +import { + addPipelineVariable, + changePipelineVariableMetric, + removePipelineVariable, + renamePipelineVariable, +} from './actions'; +import { reducer } from './reducer'; + +describe('BucketScript Settings Reducer', () => { + it('Should correctly add new pipeline variables', () => { + const var1: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const var2: PipelineVariable = { + name: 'var2', + pipelineAgg: '', + }; + + const var3: PipelineVariable = { + name: 'var3', + pipelineAgg: '', + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addPipelineVariable()) + .thenStateShouldEqual([var1]) + .whenActionIsDispatched(addPipelineVariable()) + .thenStateShouldEqual([var1, var2]) + .whenActionIsDispatched(removePipelineVariable(0)) + .thenStateShouldEqual([var2]) + .whenActionIsDispatched(addPipelineVariable()) + .thenStateShouldEqual([var2, var3]); + }); + + it('Should correctly remove pipeline variables', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: '', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(removePipelineVariable(0)) + .thenStateShouldEqual([secondVar]); + }); + + it('Should correctly rename pipeline variable', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: '', + }; + + const expectedSecondVar: PipelineVariable = { + ...secondVar, + name: 'new name', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(renamePipelineVariable(expectedSecondVar.name, 1)) + .thenStateShouldEqual([firstVar, expectedSecondVar]); + }); + + it('Should correctly change pipeline variable target metric', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: 'some agg', + }; + + const expectedSecondVar: PipelineVariable = { + ...secondVar, + pipelineAgg: 'some new agg', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(changePipelineVariableMetric(expectedSecondVar.pipelineAgg, 1)) + .thenStateShouldEqual([firstVar, expectedSecondVar]); + }); + + it('Should not change state with other action types', () => { + const initialState: PipelineVariable[] = [ + { + name: 'var1', + pipelineAgg: '1', + }, + { + name: 'var2', + pipelineAgg: '2', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts new file mode 100644 index 0000000..6b8b5b7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts @@ -0,0 +1,46 @@ +import { PipelineVariable } from '../../../aggregations'; +import { defaultPipelineVariable, generatePipelineVariableName } from '../utils'; +import { + PipelineVariablesAction, + REMOVE_PIPELINE_VARIABLE, + ADD_PIPELINE_VARIABLE, + RENAME_PIPELINE_VARIABLE, + CHANGE_PIPELINE_VARIABLE_METRIC, +} from './types'; + +export const reducer = (state: PipelineVariable[] = [], action: PipelineVariablesAction) => { + switch (action.type) { + case ADD_PIPELINE_VARIABLE: + return [...state, defaultPipelineVariable(generatePipelineVariableName(state))]; + + case REMOVE_PIPELINE_VARIABLE: + return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1)); + + case RENAME_PIPELINE_VARIABLE: + return state.map((pipelineVariable, index) => { + if (index !== action.payload.index) { + return pipelineVariable; + } + + return { + ...pipelineVariable, + name: action.payload.newName, + }; + }); + + case CHANGE_PIPELINE_VARIABLE_METRIC: + return state.map((pipelineVariable, index) => { + if (index !== action.payload.index) { + return pipelineVariable; + } + + return { + ...pipelineVariable, + pipelineAgg: action.payload.newMetric, + }; + }); + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts new file mode 100644 index 0000000..7807a55 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts @@ -0,0 +1,34 @@ +import { Action } from '../../../../../../hooks/useStatelessReducer'; + +export const ADD_PIPELINE_VARIABLE = '@pipelineVariables/add'; +export const REMOVE_PIPELINE_VARIABLE = '@pipelineVariables/remove'; +export const RENAME_PIPELINE_VARIABLE = '@pipelineVariables/rename'; +export const CHANGE_PIPELINE_VARIABLE_METRIC = '@pipelineVariables/change_metric'; + +export type AddPipelineVariableAction = Action; + +export interface RemovePipelineVariableAction extends Action { + payload: { + index: number; + }; +} + +export interface RenamePipelineVariableAction extends Action { + payload: { + index: number; + newName: string; + }; +} + +export interface ChangePipelineVariableMetricAction extends Action { + payload: { + index: number; + newMetric: string; + }; +} + +export type PipelineVariablesAction = + | AddPipelineVariableAction + | RemovePipelineVariableAction + | RenamePipelineVariableAction + | ChangePipelineVariableMetricAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.test.ts new file mode 100644 index 0000000..55671f9 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.test.ts @@ -0,0 +1,27 @@ +import { generatePipelineVariableName } from './utils'; + +describe('generatePipelineVariableName', () => { + it('Correctly generates a new name', () => { + // when we have no previous name, the generated one should be `var1` + expect(generatePipelineVariableName([])).toBe('var1'); + + expect( + generatePipelineVariableName([ + { + name: 'var1', + pipelineAgg: '', + }, + ]) + ).toBe('var2'); + + // It should only generate nemes based on variables named `var{n}` + expect( + generatePipelineVariableName([ + { + name: 'something1', + pipelineAgg: '', + }, + ]) + ).toBe('var1'); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts new file mode 100644 index 0000000..2f57c25 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts @@ -0,0 +1,10 @@ +import { PipelineVariable } from '../../aggregations'; + +export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' }); + +/** + * Given an array of pipeline variables generates a new unique pipeline variable name in the form of `var{n}`. + * The value for `n` is calculated based on the variables names in pipelineVars matching `var{n}`. + */ +export const generatePipelineVariableName = (pipelineVars: PipelineVariable[]): string => + `var${Math.max(0, ...pipelineVars.map((v) => parseInt(v.name.match('^var(\\d+)$')?.[1] || '0', 10))) + 1}`; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx new file mode 100644 index 0000000..5103c51 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx @@ -0,0 +1,178 @@ +import { Input, InlineField, Select, Switch } from '@grafana/ui'; +import React, { FunctionComponent } from 'react'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { movingAvgModelOptions } from '../../../../query_def'; +import { isEWMAMovingAverage, isHoltMovingAverage, isHoltWintersMovingAverage, MovingAverage } from '../aggregations'; +import { changeMetricSetting } from '../state/actions'; + +interface Props { + metric: MovingAverage; +} + +// The way we handle changes for those settings is not ideal compared to the other components in the editor +export const MovingAverageSettingsEditor: FunctionComponent = ({ metric }) => { + const dispatch = useDispatch(); + + return ( + <> + + dispatch(changeMetricSetting(metric, 'window', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.window} + /> + + + + dispatch(changeMetricSetting(metric, 'predict', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.predict} + /> + + + {isEWMAMovingAverage(metric) && ( + <> + + dispatch(changeMetricSetting(metric, 'alpha', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.alpha} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + {isHoltMovingAverage(metric) && ( + <> + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + alpha: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.alpha} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + beta: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.beta} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + {isHoltWintersMovingAverage(metric) && ( + <> + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + alpha: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.alpha} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + beta: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.beta} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + gamma: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.gamma} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + period: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.period} + /> + + + + ) => + dispatch( + changeMetricSetting(metric, 'settings', { ...metric.settings?.settings, pad: e.target.checked }) + ) + } + checked={!!metric.settings?.settings?.pad} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx new file mode 100644 index 0000000..01a9370 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -0,0 +1,46 @@ +import React, { ComponentProps, useState } from 'react'; +import { InlineField, Input } from '@grafana/ui'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { changeMetricSetting } from '../state/actions'; +import { ChangeMetricSettingAction } from '../state/types'; +import { SettingKeyOf } from '../../../types'; +import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../aggregations'; +import { uniqueId } from 'lodash'; +import { getScriptValue } from 'app/plugins/datasource/elasticsearch/utils'; + +interface Props> { + label: string; + settingName: K; + metric: T; + placeholder?: ComponentProps['placeholder']; + tooltip?: ComponentProps['tooltip']; +} + +export function SettingField>({ + label, + settingName, + metric, + placeholder, + tooltip, +}: Props) { + const dispatch = useDispatch>(); + const [id] = useState(uniqueId(`es-field-id-`)); + const settings = metric.settings; + + let defaultValue = settings?.[settingName as keyof typeof settings] || ''; + + if (settingName === 'script') { + defaultValue = getScriptValue(metric as MetricAggregationWithInlineScript); + } + + return ( + + dispatch(changeMetricSetting(metric, settingName, e.target.value as any))} + defaultValue={defaultValue} + /> + + ); +} diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx new file mode 100644 index 0000000..e8046ae --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SettingsEditor } from '.'; +import { ElasticsearchProvider } from '../../ElasticsearchQueryContext'; +import { ElasticDatasource } from '../../../../datasource'; +import { ElasticsearchQuery } from '../../../../types'; + +describe('Settings Editor', () => { + describe('Raw Data', () => { + it('Should correctly render the settings editor and trigger correct state changes', () => { + const metricId = '1'; + const initialSize = '500'; + const query: ElasticsearchQuery = { + refId: 'A', + query: '', + metrics: [ + { + id: metricId, + type: 'raw_data', + settings: { + size: initialSize, + }, + }, + ], + bucketAggs: [], + }; + + const onChange = jest.fn(); + + const { rerender } = render( + {}} + > + + + ); + + let settingsButtonEl = screen.getByRole('button', { + name: /Size: \d+$/i, + }); + + // The metric row should have a settings button + expect(settingsButtonEl).toBeInTheDocument(); + expect(settingsButtonEl.textContent).toBe(`Size: ${initialSize}`); + + // Open the settings editor + fireEvent.click(settingsButtonEl); + + // The settings editor should have a Size input + const sizeInputEl = screen.getByLabelText('Size'); + expect(sizeInputEl).toBeInTheDocument(); + + // We change value and trigger a blur event to trigger an update + const newSizeValue = '23'; + fireEvent.change(sizeInputEl, { target: { value: newSizeValue } }); + fireEvent.blur(sizeInputEl); + + // the onChange handler should have been called correctly, and the resulting + // query state should match what expected + expect(onChange).toHaveBeenCalledTimes(1); + rerender( + {}} + > + + + ); + + settingsButtonEl = screen.getByRole('button', { + name: /Size: \d+$/i, + }); + expect(settingsButtonEl).toBeInTheDocument(); + expect(settingsButtonEl.textContent).toBe(`Size: ${newSizeValue}`); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx new file mode 100644 index 0000000..0c20b13 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx @@ -0,0 +1,145 @@ +import { InlineField, Input, InlineSwitch } from '@grafana/ui'; +import React, { FunctionComponent, ComponentProps, useState } from 'react'; +import { extendedStats } from '../../../../query_def'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { changeMetricMeta, changeMetricSetting } from '../state/actions'; +import { + MetricAggregation, + isMetricAggregationWithInlineScript, + isMetricAggregationWithMissingSupport, + ExtendedStat, +} from '../aggregations'; +import { BucketScriptSettingsEditor } from './BucketScriptSettingsEditor'; +import { SettingField } from './SettingField'; +import { SettingsEditorContainer } from '../../SettingsEditorContainer'; +import { useDescription } from './useDescription'; +import { MovingAverageSettingsEditor } from './MovingAverageSettingsEditor'; +import { uniqueId } from 'lodash'; +import { metricAggregationConfig } from '../utils'; +import { useQuery } from '../../ElasticsearchQueryContext'; + +// TODO: Move this somewhere and share it with BucketsAggregation Editor +const inlineFieldProps: Partial> = { + labelWidth: 16, +}; + +interface Props { + metric: MetricAggregation; + previousMetrics: MetricAggregation[]; +} + +export const SettingsEditor: FunctionComponent = ({ metric, previousMetrics }) => { + const dispatch = useDispatch(); + const description = useDescription(metric); + const query = useQuery(); + + return ( + + ); +}; + +interface ExtendedStatSettingProps { + stat: ExtendedStat; + onChange: (checked: boolean) => void; + value: boolean; +} +const ExtendedStatSetting: FunctionComponent = ({ stat, onChange, value }) => { + // this is needed for the htmlFor prop in the label so that clicking the label will toggle the switch state. + const [id] = useState(uniqueId(`es-field-id-`)); + + return ( + + ) => onChange(e.target.checked)} + value={value} + /> + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts new file mode 100644 index 0000000..9fb4130 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts @@ -0,0 +1,40 @@ +import { extendedStats } from '../../../../query_def'; +import { MetricAggregation } from '../aggregations'; + +const hasValue = (value: string) => (object: { value: string }) => object.value === value; + +// FIXME: All the defaults and validations down here should be defined somewhere else +// as they are also the defaults that are gonna be applied to the query. +// In the previous version, the same method was taking care of describing the settings and setting defaults. +export const useDescription = (metric: MetricAggregation): string => { + switch (metric.type) { + case 'cardinality': { + const precisionThreshold = metric.settings?.precision_threshold || ''; + return `Precision threshold: ${precisionThreshold}`; + } + + case 'percentiles': + if (metric.settings?.percents && metric.settings?.percents?.length >= 1) { + return `Values: ${metric.settings?.percents}`; + } + + return 'Percents: Default'; + + case 'extended_stats': { + const selectedStats = Object.entries(metric.meta || {}) + .map(([key, value]) => value && extendedStats.find(hasValue(key))?.label) + .filter(Boolean); + + return `Stats: ${selectedStats.length > 0 ? selectedStats.join(', ') : 'None selected'}`; + } + + case 'raw_document': + case 'raw_data': { + const size = metric.settings?.size || 500; + return `Size: ${size}`; + } + + default: + return 'Options'; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts new file mode 100644 index 0000000..e833fdf --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts @@ -0,0 +1,357 @@ +import { metricAggregationConfig } from './utils'; + +export type PipelineMetricAggregationType = + | 'moving_avg' + | 'moving_fn' + | 'derivative' + | 'serial_diff' + | 'cumulative_sum' + | 'bucket_script'; + +export type MetricAggregationType = + | 'count' + | 'avg' + | 'sum' + | 'min' + | 'max' + | 'extended_stats' + | 'percentiles' + | 'cardinality' + | 'raw_document' + | 'raw_data' + | 'logs' + | PipelineMetricAggregationType; + +interface BaseMetricAggregation { + id: string; + type: MetricAggregationType; + hide?: boolean; +} + +export interface PipelineVariable { + name: string; + pipelineAgg: string; +} + +export interface MetricAggregationWithField extends BaseMetricAggregation { + field?: string; +} + +export interface MetricAggregationWithMissingSupport extends BaseMetricAggregation { + settings?: { + missing?: string; + }; +} + +type InlineScript = string | { inline?: string }; +export interface MetricAggregationWithInlineScript extends BaseMetricAggregation { + settings?: { + script?: InlineScript; + }; +} + +export interface Count extends BaseMetricAggregation { + type: 'count'; +} + +export interface Average + extends MetricAggregationWithField, + MetricAggregationWithMissingSupport, + MetricAggregationWithInlineScript { + type: 'avg'; + settings?: { + script?: InlineScript; + missing?: string; + }; +} + +export interface Sum extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'sum'; + settings?: { + script?: InlineScript; + missing?: string; + }; +} + +export interface Max extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'max'; + settings?: { + script?: InlineScript; + missing?: string; + }; +} + +export interface Min extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'min'; + settings?: { + script?: InlineScript; + missing?: string; + }; +} + +export type ExtendedStatMetaType = + | 'avg' + | 'min' + | 'max' + | 'sum' + | 'count' + | 'std_deviation' + | 'std_deviation_bounds_upper' + | 'std_deviation_bounds_lower'; +export interface ExtendedStat { + label: string; + value: ExtendedStatMetaType; +} + +export interface ExtendedStats extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'extended_stats'; + settings?: { + script?: InlineScript; + missing?: string; + sigma?: string; + }; + meta?: { + [P in ExtendedStatMetaType]?: boolean; + }; +} + +export interface Percentiles extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'percentiles'; + settings?: { + percents?: string[]; + script?: InlineScript; + missing?: string; + }; +} + +export interface UniqueCount extends MetricAggregationWithField { + type: 'cardinality'; + settings?: { + precision_threshold?: string; + missing?: string; + }; +} + +export interface RawDocument extends BaseMetricAggregation { + type: 'raw_document'; + settings?: { + size?: string; + }; +} + +export interface RawData extends BaseMetricAggregation { + type: 'raw_data'; + settings?: { + size?: string; + }; +} + +export interface Logs extends BaseMetricAggregation { + type: 'logs'; +} + +export interface BasePipelineMetricAggregation extends MetricAggregationWithField { + type: PipelineMetricAggregationType; + pipelineAgg?: string; +} + +export interface PipelineMetricAggregationWithMultipleBucketPaths extends BaseMetricAggregation { + type: PipelineMetricAggregationType; + pipelineVariables?: PipelineVariable[]; +} + +export type MovingAverageModel = 'simple' | 'linear' | 'ewma' | 'holt' | 'holt_winters'; + +export interface MovingAverageModelOption { + label: string; + value: MovingAverageModel; +} + +export interface BaseMovingAverageModelSettings { + model: MovingAverageModel; + window: number; + predict: number; +} + +export interface MovingAverageSimpleModelSettings extends BaseMovingAverageModelSettings { + model: 'simple'; +} + +export interface MovingAverageLinearModelSettings extends BaseMovingAverageModelSettings { + model: 'linear'; +} + +export interface MovingAverageEWMAModelSettings extends BaseMovingAverageModelSettings { + model: 'ewma'; + alpha: number; + minimize: boolean; +} + +export interface MovingAverageHoltModelSettings extends BaseMovingAverageModelSettings { + model: 'holt'; + settings: { + alpha?: number; + beta?: number; + }; + minimize: boolean; +} + +export interface MovingAverageHoltWintersModelSettings extends BaseMovingAverageModelSettings { + model: 'holt_winters'; + settings: { + alpha?: number; + beta?: number; + gamma?: number; + period?: number; + pad?: boolean; + }; + minimize: boolean; +} + +export type MovingAverageModelSettings = Partial< + Extract< + | MovingAverageSimpleModelSettings + | MovingAverageLinearModelSettings + | MovingAverageEWMAModelSettings + | MovingAverageHoltModelSettings + | MovingAverageHoltWintersModelSettings, + { model: T } + > +>; + +export interface MovingAverage + extends BasePipelineMetricAggregation { + type: 'moving_avg'; + settings?: MovingAverageModelSettings; +} + +export const isEWMAMovingAverage = (metric: MovingAverage | MovingAverage<'ewma'>): metric is MovingAverage<'ewma'> => + metric.settings?.model === 'ewma'; + +export const isHoltMovingAverage = (metric: MovingAverage | MovingAverage<'holt'>): metric is MovingAverage<'holt'> => + metric.settings?.model === 'holt'; + +export const isHoltWintersMovingAverage = ( + metric: MovingAverage | MovingAverage<'holt_winters'> +): metric is MovingAverage<'holt_winters'> => metric.settings?.model === 'holt_winters'; + +export interface MovingFunction extends BasePipelineMetricAggregation { + type: 'moving_fn'; + settings?: { + window?: string; + script?: InlineScript; + shift?: string; + }; +} + +export interface Derivative extends BasePipelineMetricAggregation { + type: 'derivative'; + settings?: { + unit?: string; + }; +} + +export interface SerialDiff extends BasePipelineMetricAggregation { + type: 'serial_diff'; + settings?: { + lag?: number; + }; +} + +export interface CumulativeSum extends BasePipelineMetricAggregation { + type: 'cumulative_sum'; + settings?: { + format?: string; + }; +} + +export interface BucketScript extends PipelineMetricAggregationWithMultipleBucketPaths { + type: 'bucket_script'; + settings?: { + script?: InlineScript; + }; +} + +type PipelineMetricAggregation = MovingAverage | Derivative | CumulativeSum | BucketScript; + +export type MetricAggregationWithSettings = + | BucketScript + | CumulativeSum + | Derivative + | SerialDiff + | RawData + | RawDocument + | UniqueCount + | Percentiles + | ExtendedStats + | Min + | Max + | Sum + | Average + | MovingAverage + | MovingFunction; + +export type MetricAggregationWithMeta = ExtendedStats; + +export type MetricAggregation = Count | Logs | PipelineMetricAggregation | MetricAggregationWithSettings; + +// Guards +// Given the structure of the aggregations (ie. `settings` field being always optional) we cannot +// determine types based solely on objects' properties, therefore we use `metricAggregationConfig` as the +// source of truth. + +/** + * Checks if `metric` requires a field (either referring to a document or another aggregation) + * @param metric + */ +export const isMetricAggregationWithField = ( + metric: BaseMetricAggregation | MetricAggregationWithField +): metric is MetricAggregationWithField => metricAggregationConfig[metric.type].requiresField; + +export const isPipelineAggregation = ( + metric: BaseMetricAggregation | PipelineMetricAggregation +): metric is PipelineMetricAggregation => metricAggregationConfig[metric.type].isPipelineAgg; + +export const isPipelineAggregationWithMultipleBucketPaths = ( + metric: BaseMetricAggregation | PipelineMetricAggregationWithMultipleBucketPaths +): metric is PipelineMetricAggregationWithMultipleBucketPaths => + metricAggregationConfig[metric.type].supportsMultipleBucketPaths; + +export const isMetricAggregationWithMissingSupport = ( + metric: BaseMetricAggregation | MetricAggregationWithMissingSupport +): metric is MetricAggregationWithMissingSupport => metricAggregationConfig[metric.type].supportsMissing; + +export const isMetricAggregationWithSettings = ( + metric: BaseMetricAggregation | MetricAggregationWithSettings +): metric is MetricAggregationWithSettings => metricAggregationConfig[metric.type].hasSettings; + +export const isMetricAggregationWithMeta = ( + metric: BaseMetricAggregation | MetricAggregationWithMeta +): metric is MetricAggregationWithMeta => metricAggregationConfig[metric.type].hasMeta; + +export const isMetricAggregationWithInlineScript = ( + metric: BaseMetricAggregation | MetricAggregationWithInlineScript +): metric is MetricAggregationWithInlineScript => metricAggregationConfig[metric.type].supportsInlineScript; + +export const METRIC_AGGREGATION_TYPES = [ + 'count', + 'avg', + 'sum', + 'min', + 'max', + 'extended_stats', + 'percentiles', + 'cardinality', + 'raw_document', + 'raw_data', + 'logs', + 'moving_avg', + 'moving_fn', + 'derivative', + 'serial_diff', + 'cumulative_sum', + 'bucket_script', +]; + +export const isMetricAggregationType = (s: MetricAggregationType | string): s is MetricAggregationType => + METRIC_AGGREGATION_TYPES.includes(s); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx new file mode 100644 index 0000000..73ac3ea --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx @@ -0,0 +1,40 @@ +import React, { FunctionComponent } from 'react'; +import { MetricEditor } from './MetricEditor'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { MetricAggregationAction } from './state/types'; +import { metricAggregationConfig } from './utils'; +import { addMetric, removeMetric, toggleMetricVisibility } from './state/actions'; +import { MetricAggregation } from './aggregations'; +import { useQuery } from '../ElasticsearchQueryContext'; +import { QueryEditorRow } from '../QueryEditorRow'; +import { IconButton } from '../../IconButton'; + +interface Props { + nextId: MetricAggregation['id']; +} + +export const MetricAggregationsEditor: FunctionComponent = ({ nextId }) => { + const dispatch = useDispatch(); + const { metrics } = useQuery(); + const totalMetrics = metrics?.length || 0; + + return ( + <> + {metrics?.map((metric, index) => ( + + ))} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts new file mode 100644 index 0000000..63a09ec --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts @@ -0,0 +1,96 @@ +import { SettingKeyOf } from '../../../types'; +import { MetricAggregation, MetricAggregationWithMeta, MetricAggregationWithSettings } from '../aggregations'; +import { + ADD_METRIC, + CHANGE_METRIC_FIELD, + CHANGE_METRIC_TYPE, + REMOVE_METRIC, + TOGGLE_METRIC_VISIBILITY, + CHANGE_METRIC_SETTING, + CHANGE_METRIC_META, + CHANGE_METRIC_ATTRIBUTE, + MetricAggregationAction, + ChangeMetricAttributeAction, + ChangeMetricSettingAction, + ChangeMetricMetaAction, +} from './types'; + +export const addMetric = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: ADD_METRIC, + payload: { + id, + }, +}); + +export const removeMetric = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: REMOVE_METRIC, + payload: { + id, + }, +}); + +export const changeMetricType = ( + id: MetricAggregation['id'], + type: MetricAggregation['type'] +): MetricAggregationAction => ({ + type: CHANGE_METRIC_TYPE, + payload: { + id, + type, + }, +}); + +export const changeMetricField = (id: MetricAggregation['id'], field: string): MetricAggregationAction => ({ + type: CHANGE_METRIC_FIELD, + payload: { + id, + field, + }, +}); + +export const toggleMetricVisibility = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: TOGGLE_METRIC_VISIBILITY, + payload: { + id, + }, +}); + +export const changeMetricAttribute = >( + metric: T, + attribute: K, + newValue: T[K] +): ChangeMetricAttributeAction => ({ + type: CHANGE_METRIC_ATTRIBUTE, + payload: { + metric, + attribute, + newValue, + }, +}); + +export const changeMetricSetting = >( + metric: T, + settingName: K, + // Maybe this could have been NonNullable[K], but it doesn't seem to work really well + newValue: NonNullable[K] +): ChangeMetricSettingAction => ({ + type: CHANGE_METRIC_SETTING, + payload: { + metric, + settingName, + newValue, + }, +}); + +export const changeMetricMeta = ( + metric: T, + meta: Extract['meta'], string>, + newValue: string | number | boolean +): ChangeMetricMetaAction => ({ + type: CHANGE_METRIC_META, + payload: { + metric, + meta, + newValue, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts new file mode 100644 index 0000000..293c03e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -0,0 +1,240 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { reducer } from './reducer'; +import { + addMetric, + changeMetricAttribute, + changeMetricField, + changeMetricMeta, + changeMetricSetting, + changeMetricType, + removeMetric, + toggleMetricVisibility, +} from './actions'; +import { Derivative, ExtendedStats, MetricAggregation } from '../aggregations'; +import { defaultMetricAgg } from '../../../../query_def'; +import { metricAggregationConfig } from '../utils'; +import { initQuery } from '../../state'; + +describe('Metric Aggregations Reducer', () => { + it('should correctly add new aggregations', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addMetric(firstAggregation.id)) + .thenStateShouldEqual([firstAggregation]) + .whenActionIsDispatched(addMetric(secondAggregation.id)) + .thenStateShouldEqual([firstAggregation, secondAggregation]); + }); + + describe('When removing aggregations', () => { + it('Should correctly remove aggregations', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(removeMetric(firstAggregation.id)) + .thenStateShouldEqual([secondAggregation]); + }); + + it('Should insert a default aggregation when the last one is removed', () => { + const initialState: MetricAggregation[] = [{ id: '2', type: 'avg' }]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched(removeMetric(initialState[0].id)) + .thenStateShouldEqual([defaultMetricAgg()]); + }); + }); + + describe("When changing existing aggregation's type", () => { + it('Should correctly change type to selected aggregation', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedSecondAggregation: MetricAggregation = { ...secondAggregation, type: 'avg' }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricType(secondAggregation.id, expectedSecondAggregation.type)) + .thenStateShouldEqual([firstAggregation, { ...secondAggregation, type: expectedSecondAggregation.type }]); + }); + + it('Should remove all other aggregations when the newly selected one is `isSingleMetric`', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedAggregation: MetricAggregation = { + ...secondAggregation, + type: 'raw_data', + ...metricAggregationConfig['raw_data'].defaults, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricType(secondAggregation.id, expectedAggregation.type)) + .thenStateShouldEqual([expectedAggregation]); + }); + }); + + it("Should correctly change aggregation's field", () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'min', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'moving_fn', + }; + + const expectedSecondAggregation = { + ...secondAggregation, + field: 'new field', + pipelineAgg: 'new field', + }; + + const expectedFirstAggregation = { + ...firstAggregation, + field: 'new field', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + // When changing a a pipelineAggregation field we set both pipelineAgg and field + .whenActionIsDispatched(changeMetricField(secondAggregation.id, expectedSecondAggregation.field)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]) + // otherwhise only field + .whenActionIsDispatched(changeMetricField(firstAggregation.id, expectedFirstAggregation.field)) + .thenStateShouldEqual([expectedFirstAggregation, expectedSecondAggregation]); + }); + + it('Should correctly toggle `hide` field', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(toggleMetricVisibility(firstAggregation.id)) + .thenStateShouldEqual([{ ...firstAggregation, hide: true }, secondAggregation]) + .whenActionIsDispatched(toggleMetricVisibility(firstAggregation.id)) + .thenStateShouldEqual([{ ...firstAggregation, hide: false }, secondAggregation]); + }); + + it("Should correctly change aggregation's settings", () => { + const firstAggregation: Derivative = { + id: '1', + type: 'derivative', + settings: { + unit: 'Some unit', + }, + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedSettings: typeof firstAggregation['settings'] = { + unit: 'Changed unit', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricSetting(firstAggregation, 'unit', expectedSettings.unit!)) + .thenStateShouldEqual([{ ...firstAggregation, settings: expectedSettings }, secondAggregation]); + }); + + it("Should correctly change aggregation's meta", () => { + const firstAggregation: ExtendedStats = { + id: '1', + type: 'extended_stats', + meta: { + avg: true, + }, + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedMeta: typeof firstAggregation['meta'] = { + avg: false, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricMeta(firstAggregation, 'avg', expectedMeta.avg!)) + .thenStateShouldEqual([{ ...firstAggregation, meta: expectedMeta }, secondAggregation]); + }); + + it("Should correctly change aggregation's attribute", () => { + const firstAggregation: ExtendedStats = { + id: '1', + type: 'extended_stats', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedHide: typeof firstAggregation['hide'] = false; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricAttribute(firstAggregation, 'hide', expectedHide)) + .thenStateShouldEqual([{ ...firstAggregation, hide: expectedHide }, secondAggregation]); + }); + + it('Should not change state with other action types', () => { + const initialState: MetricAggregation[] = [ + { + id: '1', + type: 'count', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); + + it('Should correctly initialize first Metric Aggregation', () => { + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(initQuery()) + .thenStateShouldEqual([defaultMetricAgg('1')]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts new file mode 100644 index 0000000..7bed9ec --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -0,0 +1,165 @@ +import { defaultMetricAgg } from '../../../../query_def'; +import { ElasticsearchQuery } from '../../../../types'; +import { removeEmpty } from '../../../../utils'; +import { INIT, InitAction } from '../../state'; +import { + isMetricAggregationWithMeta, + isMetricAggregationWithSettings, + isPipelineAggregation, + MetricAggregation, +} from '../aggregations'; +import { getChildren, metricAggregationConfig } from '../utils'; +import { + ADD_METRIC, + CHANGE_METRIC_TYPE, + REMOVE_METRIC, + TOGGLE_METRIC_VISIBILITY, + MetricAggregationAction, + CHANGE_METRIC_FIELD, + CHANGE_METRIC_SETTING, + CHANGE_METRIC_META, + CHANGE_METRIC_ATTRIBUTE, +} from './types'; + +export const reducer = ( + state: ElasticsearchQuery['metrics'], + action: MetricAggregationAction | InitAction +): ElasticsearchQuery['metrics'] => { + switch (action.type) { + case ADD_METRIC: + return [...state!, defaultMetricAgg(action.payload.id)]; + + case REMOVE_METRIC: + const metricToRemove = state!.find((m) => m.id === action.payload.id)!; + const metricsToRemove = [metricToRemove, ...getChildren(metricToRemove, state!)]; + const resultingMetrics = state!.filter( + (metric) => !metricsToRemove.some((toRemove) => toRemove.id === metric.id) + ); + if (resultingMetrics.length === 0) { + return [defaultMetricAgg('1')]; + } + return resultingMetrics; + + case CHANGE_METRIC_TYPE: + return state! + .filter((metric) => + // When the new metric type is `isSingleMetric` we remove all other metrics from the query + // leaving only the current one. + !!metricAggregationConfig[action.payload.type].isSingleMetric ? metric.id === action.payload.id : true + ) + .map((metric) => { + if (metric.id !== action.payload.id) { + return metric; + } + + /* + TODO: The previous version of the query editor was keeping some of the old metric's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + return { + id: metric.id, + type: action.payload.type, + ...metricAggregationConfig[action.payload.type].defaults, + } as MetricAggregation; + }); + + case CHANGE_METRIC_FIELD: + return state!.map((metric) => { + if (metric.id !== action.payload.id) { + return metric; + } + + const newMetric = { + ...metric, + field: action.payload.field, + }; + + if (isPipelineAggregation(metric)) { + return { ...newMetric, pipelineAgg: action.payload.field }; + } + + return newMetric; + }); + + case TOGGLE_METRIC_VISIBILITY: + return state!.map((metric) => { + if (metric.id !== action.payload.id) { + return metric; + } + + return { + ...metric, + hide: !metric.hide, + }; + }); + + case CHANGE_METRIC_SETTING: + return state!.map((metric) => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + // TODO: Here, instead of this if statement, we should assert that metric is MetricAggregationWithSettings + if (isMetricAggregationWithSettings(metric)) { + const newSettings = removeEmpty({ + ...metric.settings, + [action.payload.settingName]: action.payload.newValue, + }); + + return { + ...metric, + settings: { + ...newSettings, + }, + }; + } + + // This should never happen. + return metric; + }); + + case CHANGE_METRIC_META: + return state!.map((metric) => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + // TODO: Here, instead of this if statement, we should assert that metric is MetricAggregationWithMeta + if (isMetricAggregationWithMeta(metric)) { + return { + ...metric, + meta: { + ...metric.meta, + [action.payload.meta]: action.payload.newValue, + }, + }; + } + + // This should never happen. + return metric; + }); + + case CHANGE_METRIC_ATTRIBUTE: + return state!.map((metric) => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + return { + ...metric, + [action.payload.attribute]: action.payload.newValue, + }; + }); + + case INIT: + if (state?.length || 0 > 0) { + return state; + } + return [defaultMetricAgg('1')]; + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts new file mode 100644 index 0000000..4c1a6f9 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts @@ -0,0 +1,89 @@ +import { Action } from '../../../../hooks/useStatelessReducer'; +import { SettingKeyOf } from '../../../types'; +import { + MetricAggregation, + MetricAggregationWithMeta, + MetricAggregationWithSettings, + MetricAggregationWithField, +} from '../aggregations'; + +export const ADD_METRIC = '@metrics/add'; +export const REMOVE_METRIC = '@metrics/remove'; +export const CHANGE_METRIC_TYPE = '@metrics/change_type'; +export const CHANGE_METRIC_FIELD = '@metrics/change_field'; +export const CHANGE_METRIC_SETTING = '@metrics/change_setting'; +export const CHANGE_METRIC_META = '@metrics/change_meta'; +export const CHANGE_METRIC_ATTRIBUTE = '@metrics/change_attr'; +export const TOGGLE_METRIC_VISIBILITY = '@metrics/toggle_visibility'; + +export interface AddMetricAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface RemoveMetricAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface ChangeMetricTypeAction extends Action { + payload: { + id: MetricAggregation['id']; + type: MetricAggregation['type']; + }; +} + +export interface ChangeMetricFieldAction extends Action { + payload: { + id: MetricAggregation['id']; + field: MetricAggregationWithField['field']; + }; +} +export interface ToggleMetricVisibilityAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface ChangeMetricSettingAction + extends Action { + payload: { + metric: T; + settingName: SettingKeyOf; + newValue: unknown; + }; +} + +export interface ChangeMetricMetaAction extends Action { + payload: { + metric: T; + meta: Extract['meta'], string>; + newValue: string | number | boolean; + }; +} + +export interface ChangeMetricAttributeAction< + T extends MetricAggregation, + K extends Extract = Extract +> extends Action { + payload: { + metric: T; + attribute: K; + newValue: T[K]; + }; +} + +type CommonActions = + | AddMetricAction + | RemoveMetricAction + | ChangeMetricTypeAction + | ChangeMetricFieldAction + | ToggleMetricVisibilityAction; + +export type MetricAggregationAction = + | (T extends MetricAggregationWithSettings ? ChangeMetricSettingAction : never) + | (T extends MetricAggregationWithMeta ? ChangeMetricMetaAction : never) + | ChangeMetricAttributeAction + | CommonActions; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts new file mode 100644 index 0000000..470de7e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts @@ -0,0 +1,16 @@ +import { GrafanaTheme } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; +import { css } from 'emotion'; + +export const getStyles = stylesFactory((theme: GrafanaTheme, hidden: boolean) => ({ + color: + hidden && + css` + &, + &:hover, + label, + a { + color: ${hidden ? theme.colors.textFaint : theme.colors.text}; + } + `, +})); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts new file mode 100644 index 0000000..6872c41 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts @@ -0,0 +1,277 @@ +import { MetricsConfiguration } from '../../../types'; +import { + isMetricAggregationWithField, + isPipelineAggregationWithMultipleBucketPaths, + MetricAggregation, + PipelineMetricAggregationType, +} from './aggregations'; +import { + defaultPipelineVariable, + generatePipelineVariableName, +} from './SettingsEditor/BucketScriptSettingsEditor/utils'; + +export const metricAggregationConfig: MetricsConfiguration = { + count: { + label: 'Count', + requiresField: false, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: false, + hasMeta: false, + supportsInlineScript: false, + defaults: {}, + }, + avg: { + label: 'Average', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + sum: { + label: 'Sum', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + max: { + label: 'Max', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + min: { + label: 'Min', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + extended_stats: { + label: 'Extended Stats', + requiresField: true, + supportsMissing: true, + supportsInlineScript: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: true, + defaults: { + meta: { + std_deviation_bounds_lower: true, + std_deviation_bounds_upper: true, + }, + }, + }, + percentiles: { + label: 'Percentiles', + requiresField: true, + supportsMissing: true, + supportsInlineScript: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: { + settings: { + percents: ['25', '50', '75', '95', '99'], + }, + }, + }, + cardinality: { + label: 'Unique Count', + requiresField: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + moving_avg: { + label: 'Moving Average', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + model: 'simple', + window: 5, + }, + }, + }, + moving_fn: { + // TODO: Check this + label: 'Moving Function', + requiresField: true, + isPipelineAgg: true, + supportsMultipleBucketPaths: false, + supportsInlineScript: false, + supportsMissing: false, + hasMeta: false, + hasSettings: true, + minVersion: 70, + defaults: {}, + }, + derivative: { + label: 'Derivative', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + serial_diff: { + label: 'Serial Difference', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + cumulative_sum: { + label: 'Cumulative Sum', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + bucket_script: { + label: 'Bucket Script', + requiresField: false, + isPipelineAgg: true, + supportsMissing: false, + supportsMultipleBucketPaths: true, + minVersion: 2, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + pipelineVariables: [defaultPipelineVariable(generatePipelineVariableName([]))], + }, + }, + raw_document: { + label: 'Raw Document (legacy)', + requiresField: false, + isSingleMetric: true, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + size: '500', + }, + }, + }, + raw_data: { + label: 'Raw Data', + requiresField: false, + isSingleMetric: true, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + size: '500', + }, + }, + }, + logs: { + label: 'Logs', + requiresField: false, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: false, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, +}; + +interface PipelineOption { + label: string; + default?: string | number | boolean; +} + +type PipelineOptions = { + [K in PipelineMetricAggregationType]: PipelineOption[]; +}; + +export const pipelineOptions: PipelineOptions = { + moving_avg: [ + { label: 'window', default: 5 }, + { label: 'model', default: 'simple' }, + { label: 'predict' }, + { label: 'minimize', default: false }, + ], + moving_fn: [{ label: 'window', default: 5 }, { label: 'script' }], + derivative: [{ label: 'unit' }], + serial_diff: [{ label: 'lag' }], + cumulative_sum: [{ label: 'format' }], + bucket_script: [], +}; + +/** + * Given a metric `MetricA` and an array of metrics, returns all children of `MetricA`. + * `MetricB` is considered a child of `MetricA` if `MetricA` is referenced by `MetricB` in it's `field` attribute + * (`MetricA.id === MetricB.field`) or in it's pipeline aggregation variables (for bucket_scripts). + * @param metric + * @param metrics + */ +export const getChildren = (metric: MetricAggregation, metrics: MetricAggregation[]): MetricAggregation[] => { + const children = metrics.filter((m) => { + // TODO: Check this. + if (isPipelineAggregationWithMultipleBucketPaths(m)) { + return m.pipelineVariables?.some((pv) => pv.pipelineAgg === metric.id); + } + + return isMetricAggregationWithField(m) && metric.id === m.field; + }); + + return [...children, ...children.flatMap((child) => getChildren(child, metrics))]; +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx new file mode 100644 index 0000000..b4dfe93 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx @@ -0,0 +1,68 @@ +import { GrafanaTheme } from '@grafana/data'; +import { IconButton, InlineFieldRow, InlineLabel, InlineSegmentGroup, stylesFactory, useTheme } from '@grafana/ui'; +import { css } from 'emotion'; +import { noop } from 'lodash'; +import React, { FunctionComponent } from 'react'; + +interface Props { + label: string; + onRemoveClick?: false | (() => void); + onHideClick?: false | (() => void); + hidden?: boolean; +} + +export const QueryEditorRow: FunctionComponent = ({ + children, + label, + onRemoveClick, + onHideClick, + hidden = false, +}) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( + + + + {label} + + {onHideClick && ( + + )} + + + + + {children} + + ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + iconWrapper: css` + display: flex; + `, + icon: css` + color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing.xxs}; + `, + }; +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx new file mode 100644 index 0000000..ffe6897 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx @@ -0,0 +1,53 @@ +import { GrafanaTheme } from '@grafana/data'; +import { Icon, InlineSegmentGroup, stylesFactory, useTheme } from '@grafana/ui'; +import { css, cx } from 'emotion'; +import React, { FunctionComponent, useState } from 'react'; +import { segmentStyles } from './styles'; + +const getStyles = stylesFactory((theme: GrafanaTheme, hidden: boolean) => { + return { + wrapper: css` + display: flex; + flex-direction: column; + `, + settingsWrapper: css` + padding-top: ${theme.spacing.xs}; + `, + icon: css` + margin-right: ${theme.spacing.xs}; + `, + button: css` + justify-content: start; + ${hidden && + css` + color: ${theme.colors.textFaint}; + `} + `, + }; +}); +interface Props { + label: string; + hidden?: boolean; +} + +export const SettingsEditorContainer: FunctionComponent = ({ label, children, hidden = false }) => { + const [open, setOpen] = useState(false); + const styles = getStyles(useTheme(), hidden); + + return ( + +
    + + + {open &&
    {children}
    } +
    +
    + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.test.tsx new file mode 100644 index 0000000..c409810 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryEditor } from '.'; +import { ElasticDatasource } from '../../datasource'; +import { ElasticsearchQuery } from '../../types'; + +describe('QueryEditor', () => { + describe('Alias Field', () => { + it('Should correctly render and trigger changes on blur', () => { + const alias = '{{metric}}'; + const query: ElasticsearchQuery = { + refId: 'A', + query: '', + alias, + metrics: [ + { + id: '1', + type: 'raw_data', + }, + ], + bucketAggs: [], + }; + + const onChange = jest.fn(); + + render( + {}} /> + ); + + let aliasField = screen.getByLabelText('Alias') as HTMLInputElement; + + // The Query should have an alias field + expect(aliasField).toBeInTheDocument(); + + // its value should match the one in the query + expect(aliasField.value).toBe(alias); + + // We change value and trigger a blur event to trigger an update + const newAlias = 'new alias'; + fireEvent.change(aliasField, { target: { value: newAlias } }); + fireEvent.blur(aliasField); + + // the onChange handler should have been called correctly, and the resulting + // query state should match what expected + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].alias).toBe(newAlias); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx new file mode 100644 index 0000000..cc8da80 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx @@ -0,0 +1,62 @@ +import React, { FunctionComponent } from 'react'; +import { QueryEditorProps } from '@grafana/data'; +import { ElasticDatasource } from '../../datasource'; +import { ElasticsearchOptions, ElasticsearchQuery } from '../../types'; +import { ElasticsearchProvider } from './ElasticsearchQueryContext'; +import { InlineField, InlineFieldRow, Input, QueryField } from '@grafana/ui'; +import { changeAliasPattern, changeQuery } from './state'; +import { MetricAggregationsEditor } from './MetricAggregationsEditor'; +import { BucketAggregationsEditor } from './BucketAggregationsEditor'; +import { useDispatch } from '../../hooks/useStatelessReducer'; +import { useNextId } from '../../hooks/useNextId'; + +export type ElasticQueryEditorProps = QueryEditorProps; + +export const QueryEditor: FunctionComponent = ({ + query, + onChange, + onRunQuery, + datasource, +}) => ( + + + +); + +interface Props { + value: ElasticsearchQuery; +} + +const QueryEditorForm: FunctionComponent = ({ value }) => { + const dispatch = useDispatch(); + const nextId = useNextId(); + + return ( + <> + + + {}} + onChange={(query) => dispatch(changeQuery(query))} + placeholder="Lucene Query" + portalOrigin="elasticsearch" + /> + + + dispatch(changeAliasPattern(e.currentTarget.value))} + defaultValue={value.alias} + /> + + + + + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts new file mode 100644 index 0000000..ada6bdd --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -0,0 +1,64 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { ElasticsearchQuery } from '../../types'; +import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; + +describe('Query Reducer', () => { + describe('On Init', () => { + it('Should maintain the previous `query` if present', () => { + const initialQuery: ElasticsearchQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialQuery) + .whenActionIsDispatched(initQuery()) + .thenStateShouldEqual(initialQuery); + }); + + it('Should set an empty `query` if it is not already set', () => { + const initialQuery: ElasticsearchQuery['query'] = undefined; + const expectedQuery = ''; + + reducerTester() + .givenReducer(queryReducer, initialQuery) + .whenActionIsDispatched(initQuery()) + .thenStateShouldEqual(expectedQuery); + }); + }); + + it('Should correctly set `query`', () => { + const expectedQuery: ElasticsearchQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, '') + .whenActionIsDispatched(changeQuery(expectedQuery)) + .thenStateShouldEqual(expectedQuery); + }); + + it('Should not change state with other action types', () => { + const initialState: ElasticsearchQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); + +describe('Alias Pattern Reducer', () => { + it('Should correctly set `alias`', () => { + const expectedAlias: ElasticsearchQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, '') + .whenActionIsDispatched(changeAliasPattern(expectedAlias)) + .thenStateShouldEqual(expectedAlias); + }); + + it('Should not change state with other action types', () => { + const initialState: ElasticsearchQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts new file mode 100644 index 0000000..a655d76 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -0,0 +1,69 @@ +import { Action } from '../../hooks/useStatelessReducer'; +import { ElasticsearchQuery } from '../../types'; + +export const INIT = 'init'; +const CHANGE_QUERY = 'change_query'; +const CHANGE_ALIAS_PATTERN = 'change_alias_pattern'; + +export interface InitAction extends Action {} + +interface ChangeQueryAction extends Action { + payload: { + query: string; + }; +} + +interface ChangeAliasPatternAction extends Action { + payload: { + aliasPattern: string; + }; +} + +/** + * When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present. + * This means it won't override any existing value in place, but just ensure the query is in a "runnable" state. + */ +export const initQuery = (): InitAction => ({ type: INIT }); + +export const changeQuery = (query: string): ChangeQueryAction => ({ + type: CHANGE_QUERY, + payload: { + query, + }, +}); + +export const changeAliasPattern = (aliasPattern: string): ChangeAliasPatternAction => ({ + type: CHANGE_ALIAS_PATTERN, + payload: { + aliasPattern, + }, +}); + +export const queryReducer = (prevQuery: ElasticsearchQuery['query'], action: ChangeQueryAction | InitAction) => { + switch (action.type) { + case CHANGE_QUERY: + return action.payload.query; + + case INIT: + return prevQuery || ''; + + default: + return prevQuery; + } +}; + +export const aliasPatternReducer = ( + prevAliasPattern: ElasticsearchQuery['alias'], + action: ChangeAliasPatternAction | InitAction +) => { + switch (action.type) { + case CHANGE_ALIAS_PATTERN: + return action.payload.aliasPattern; + + case INIT: + return prevAliasPattern || ''; + + default: + return prevAliasPattern; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts new file mode 100644 index 0000000..70ee88d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts @@ -0,0 +1,5 @@ +import { css } from 'emotion'; + +export const segmentStyles = css` + min-width: 150px; +`; diff --git a/public/app/plugins/datasource/elasticsearch/components/types.ts b/public/app/plugins/datasource/elasticsearch/components/types.ts new file mode 100644 index 0000000..f7dd1ab --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/types.ts @@ -0,0 +1,4 @@ +export type SettingKeyOf }> = Extract< + keyof NonNullable, + string +>; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx new file mode 100644 index 0000000..eedbaf1 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { ConfigEditor } from './ConfigEditor'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { ElasticDetails } from './ElasticDetails'; +import { LogsConfig } from './LogsConfig'; +import { createDefaultConfigOptions } from './mocks'; + +describe('ConfigEditor', () => { + it('should render without error', () => { + mount( {}} options={createDefaultConfigOptions()} />); + }); + + it('should render all parts of the config', () => { + const wrapper = shallow( {}} options={createDefaultConfigOptions()} />); + expect(wrapper.find(DataSourceHttpSettings).length).toBe(1); + expect(wrapper.find(ElasticDetails).length).toBe(1); + expect(wrapper.find(LogsConfig).length).toBe(1); + }); + + it('should set defaults', () => { + const options = createDefaultConfigOptions(); + // @ts-ignore + delete options.jsonData.esVersion; + // @ts-ignore + delete options.jsonData.timeField; + delete options.jsonData.maxConcurrentShardRequests; + + expect.assertions(3); + + mount( + { + expect(options.jsonData.esVersion).toBe(5); + expect(options.jsonData.timeField).toBe('@timestamp'); + expect(options.jsonData.maxConcurrentShardRequests).toBe(256); + }} + options={options} + /> + ); + }); + + it('should not apply default if values are set', () => { + expect.assertions(3); + + mount( + { + expect(options.jsonData.esVersion).toBe(70); + expect(options.jsonData.timeField).toBe('@time'); + expect(options.jsonData.maxConcurrentShardRequests).toBe(300); + }} + options={createDefaultConfigOptions()} + /> + ); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx new file mode 100644 index 0000000..def2f5d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import { Alert, DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { ElasticsearchOptions } from '../types'; +import { defaultMaxConcurrentShardRequests, ElasticDetails } from './ElasticDetails'; +import { LogsConfig } from './LogsConfig'; +import { DataLinks } from './DataLinks'; +import { config } from 'app/core/config'; + +export type Props = DataSourcePluginOptionsEditorProps; +export const ConfigEditor = (props: Props) => { + const { options, onOptionsChange } = props; + + // Apply some defaults on initial render + useEffect(() => { + const esVersion = options.jsonData.esVersion || 5; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + timeField: options.jsonData.timeField || '@timestamp', + esVersion, + maxConcurrentShardRequests: + options.jsonData.maxConcurrentShardRequests || defaultMaxConcurrentShardRequests(esVersion), + logMessageField: options.jsonData.logMessageField || '', + logLevelField: options.jsonData.logLevelField || '', + }, + }); + }, []); + + return ( + <> + {options.access === 'direct' && ( + + Browser access mode in the Elasticsearch datasource is deprecated and will be removed in a future release. + + )} + + + + + + + onOptionsChange({ + ...options, + jsonData: newValue, + }) + } + /> + + { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + dataLinks: newValue, + }, + }); + }} + /> + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx new file mode 100644 index 0000000..87d6286 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLink.tsx @@ -0,0 +1,141 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { css } from 'emotion'; +import { VariableSuggestion } from '@grafana/data'; +import { Button, LegacyForms, DataLinkInput, stylesFactory } from '@grafana/ui'; +const { FormField, Switch } = LegacyForms; +import { DataLinkConfig } from '../types'; +import { usePrevious } from 'react-use'; +import { DataSourcePicker } from '../../../../core/components/Select/DataSourcePicker'; + +const getStyles = stylesFactory(() => ({ + firstRow: css` + display: flex; + `, + nameField: css` + flex: 2; + `, + regexField: css` + flex: 3; + `, + row: css` + display: flex; + align-items: baseline; + `, +})); + +type Props = { + value: DataLinkConfig; + onChange: (value: DataLinkConfig) => void; + onDelete: () => void; + suggestions: VariableSuggestion[]; + className?: string; +}; +export const DataLink = (props: Props) => { + const { value, onChange, onDelete, suggestions, className } = props; + const styles = getStyles(); + const [showInternalLink, setShowInternalLink] = useInternalLink(value.datasourceUid); + + const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent) => { + onChange({ + ...value, + [field]: event.currentTarget.value, + }); + }; + + return ( +
    +
    + +
    +
    + + onChange({ + ...value, + url: newValue, + }) + } + suggestions={suggestions} + /> + } + className={css` + width: 100%; + `} + /> +
    + +
    + { + if (showInternalLink) { + onChange({ + ...value, + datasourceUid: undefined, + }); + } + setShowInternalLink(!showInternalLink); + }} + /> + + {showInternalLink && ( + { + onChange({ + ...value, + datasourceUid: ds.uid, + }); + }} + current={value.datasourceUid} + /> + )} +
    +
    + ); +}; + +function useInternalLink(datasourceUid?: string): [boolean, Dispatch>] { + const [showInternalLink, setShowInternalLink] = useState(!!datasourceUid); + const previousUid = usePrevious(datasourceUid); + + // Force internal link visibility change if uid changed outside of this component. + useEffect(() => { + if (!previousUid && datasourceUid && !showInternalLink) { + setShowInternalLink(true); + } + if (previousUid && !datasourceUid && showInternalLink) { + setShowInternalLink(false); + } + }, [previousUid, datasourceUid, showInternalLink]); + + return [showInternalLink, setShowInternalLink]; +} diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx new file mode 100644 index 0000000..d6665b8 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DataLinks } from './DataLinks'; +import { Button } from '@grafana/ui'; +import { DataLink } from './DataLink'; +import { act } from 'react-dom/test-utils'; + +describe('DataLinks', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('renders correctly when no fields', async () => { + let wrapper: any; + await act( + // @ts-ignore we shouldn't use Promises in act => the "void | undefined" is here to forbid any sneaky "Promise" returns. + async () => { + wrapper = await mount( {}} />); + } + ); + expect(wrapper.find(Button).length).toBe(1); + expect(wrapper.find(Button).contains('Add')).toBeTruthy(); + expect(wrapper.find(DataLink).length).toBe(0); + }); + + it('renders correctly when there are fields', async () => { + let wrapper: any; + await act( + // @ts-ignore we shouldn't use Promises in act => the "void | undefined" is here to forbid any sneaky "Promise" returns. + async () => { + wrapper = await mount( {}} />); + } + ); + + expect(wrapper.find(Button).filterWhere((button: any) => button.contains('Add')).length).toBe(1); + expect(wrapper.find(DataLink).length).toBe(2); + }); + + it('adds new field', async () => { + const onChangeMock = jest.fn(); + let wrapper: any; + await act( + // @ts-ignore we shouldn't use Promises in act => the "void | undefined" is here to forbid any sneaky "Promise" returns. + async () => { + wrapper = await mount(); + } + ); + const addButton = wrapper.find(Button).filterWhere((button: any) => button.contains('Add')); + addButton.simulate('click'); + expect(onChangeMock.mock.calls[0][0].length).toBe(1); + }); + + it('removes field', async () => { + const onChangeMock = jest.fn(); + let wrapper: any; + await act( + // @ts-ignore we shouldn't use Promises in act => the "void | undefined" is here to forbid any sneaky "Promise" returns. + async () => { + wrapper = await mount(); + } + ); + const removeButton = wrapper.find(DataLink).at(0).find(Button); + removeButton.simulate('click'); + const newValue = onChangeMock.mock.calls[0][0]; + expect(newValue.length).toBe(1); + expect(newValue[0]).toMatchObject({ + field: 'regex2', + url: 'localhost2', + }); + }); +}); + +const testValue = [ + { + field: 'regex1', + url: 'localhost1', + }, + { + field: 'regex2', + url: 'localhost2', + }, +]; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx new file mode 100644 index 0000000..05ab609 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { css } from 'emotion'; +import { Button, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data'; +import { DataLinkConfig } from '../types'; +import { DataLink } from './DataLink'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + infoText: css` + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textWeak}; + `, + dataLink: css` + margin-bottom: ${theme.spacing.sm}; + `, +})); + +type Props = { + value?: DataLinkConfig[]; + onChange: (value: DataLinkConfig[]) => void; +}; +export const DataLinks = (props: Props) => { + const { value, onChange } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + return ( + <> +

    Data links

    + +
    + Add links to existing fields. Links will be shown in log row details next to the field value. +
    + + {value && value.length > 0 && ( +
    + {value.map((field, index) => { + return ( + { + const newDataLinks = [...value]; + newDataLinks.splice(index, 1, newField); + onChange(newDataLinks); + }} + onDelete={() => { + const newDataLinks = [...value]; + newDataLinks.splice(index, 1); + onChange(newDataLinks); + }} + suggestions={[ + { + value: DataLinkBuiltInVars.valueRaw, + label: 'Raw value', + documentation: 'Raw value of the field', + origin: VariableOrigin.Value, + }, + ]} + /> + ); + })} +
    + )} + + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx new file mode 100644 index 0000000..177a71c --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { last } from 'lodash'; +import { mount } from 'enzyme'; +import { ElasticDetails } from './ElasticDetails'; +import { createDefaultConfigOptions } from './mocks'; +import { LegacyForms } from '@grafana/ui'; +const { Select } = LegacyForms; + +describe('ElasticDetails', () => { + it('should render without error', () => { + mount( {}} value={createDefaultConfigOptions()} />); + }); + + it('should render "Max concurrent Shard Requests" if version high enough', () => { + const wrapper = mount( {}} value={createDefaultConfigOptions()} />); + expect(wrapper.find('input[aria-label="Max concurrent Shard Requests input"]').length).toBe(1); + }); + + it('should not render "Max concurrent Shard Requests" if version is low', () => { + const options = createDefaultConfigOptions(); + options.jsonData.esVersion = 5; + const wrapper = mount( {}} value={options} />); + expect(wrapper.find('input[aria-label="Max concurrent Shard Requests input"]').length).toBe(0); + }); + + it('should change database on interval change when not set explicitly', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const selectEl = wrapper.find({ label: 'Pattern' }).find(Select); + selectEl.props().onChange({ value: 'Daily', label: 'Daily' }); + + expect(onChangeMock.mock.calls[0][0].jsonData.interval).toBe('Daily'); + expect(onChangeMock.mock.calls[0][0].database).toBe('[logstash-]YYYY.MM.DD'); + }); + + it('should change database on interval change if pattern is from example', () => { + const onChangeMock = jest.fn(); + const options = createDefaultConfigOptions(); + options.database = '[logstash-]YYYY.MM.DD.HH'; + const wrapper = mount(); + + const selectEl = wrapper.find({ label: 'Pattern' }).find(Select); + selectEl.props().onChange({ value: 'Monthly', label: 'Monthly' }); + + expect(onChangeMock.mock.calls[0][0].jsonData.interval).toBe('Monthly'); + expect(onChangeMock.mock.calls[0][0].database).toBe('[logstash-]YYYY.MM'); + }); + + describe('version change', () => { + const testCases = [ + { version: 50, expectedMaxConcurrentShardRequests: 256 }, + { version: 50, maxConcurrentShardRequests: 50, expectedMaxConcurrentShardRequests: 50 }, + { version: 56, expectedMaxConcurrentShardRequests: 256 }, + { version: 56, maxConcurrentShardRequests: 256, expectedMaxConcurrentShardRequests: 256 }, + { version: 56, maxConcurrentShardRequests: 5, expectedMaxConcurrentShardRequests: 256 }, + { version: 56, maxConcurrentShardRequests: 200, expectedMaxConcurrentShardRequests: 200 }, + { version: 70, expectedMaxConcurrentShardRequests: 5 }, + { version: 70, maxConcurrentShardRequests: 256, expectedMaxConcurrentShardRequests: 5 }, + { version: 70, maxConcurrentShardRequests: 5, expectedMaxConcurrentShardRequests: 5 }, + { version: 70, maxConcurrentShardRequests: 6, expectedMaxConcurrentShardRequests: 6 }, + ]; + + const onChangeMock = jest.fn(); + const options = createDefaultConfigOptions(); + const wrapper = mount(); + + testCases.forEach((tc) => { + it(`sets maxConcurrentShardRequests = ${tc.maxConcurrentShardRequests} if version = ${tc.version},`, () => { + wrapper.setProps({ + onChange: onChangeMock, + value: { + ...options, + jsonData: { + ...options.jsonData, + maxConcurrentShardRequests: tc.maxConcurrentShardRequests, + }, + }, + }); + + const selectEl = wrapper.find({ label: 'Version' }).find(Select); + selectEl.props().onChange({ value: tc.version, label: tc.version.toString() }); + + expect(last(onChangeMock.mock.calls)[0].jsonData.maxConcurrentShardRequests).toBe( + tc.expectedMaxConcurrentShardRequests + ); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx new file mode 100644 index 0000000..49d36e7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { EventsWithValidation, regexValidation, LegacyForms } from '@grafana/ui'; +const { Select, Input, FormField } = LegacyForms; +import { ElasticsearchOptions, Interval } from '../types'; +import { DataSourceSettings, SelectableValue } from '@grafana/data'; + +const indexPatternTypes = [ + { label: 'No pattern', value: 'none' }, + { label: 'Hourly', value: 'Hourly', example: '[logstash-]YYYY.MM.DD.HH' }, + { label: 'Daily', value: 'Daily', example: '[logstash-]YYYY.MM.DD' }, + { label: 'Weekly', value: 'Weekly', example: '[logstash-]GGGG.WW' }, + { label: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' }, + { label: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' }, +]; + +const esVersions = [ + { label: '2.x', value: 2 }, + { label: '5.x', value: 5 }, + { label: '5.6+', value: 56 }, + { label: '6.0+', value: 60 }, + { label: '7.0+', value: 70 }, +]; + +type Props = { + value: DataSourceSettings; + onChange: (value: DataSourceSettings) => void; +}; +export const ElasticDetails = (props: Props) => { + const { value, onChange } = props; + + return ( + <> +

    Elasticsearch details

    + +
    +
    +
    + +
    + +
    + + pattern.value === (value.jsonData.interval === undefined ? 'none' : value.jsonData.interval) + )} + /> + } + /> +
    +
    + +
    + +
    + +
    + { + const maxConcurrentShardRequests = getMaxConcurrenShardRequestOrDefault( + value.jsonData.maxConcurrentShardRequests, + option.value! + ); + onChange({ + ...value, + jsonData: { + ...value.jsonData, + esVersion: option.value!, + maxConcurrentShardRequests, + }, + }); + }} + value={esVersions.find((version) => version.value === value.jsonData.esVersion)} + /> + } + /> +
    + {value.jsonData.esVersion >= 56 && ( +
    + +
    + )} +
    +
    + + } + tooltip={ + <> + A lower limit for the auto group by time interval. Recommended to be set to write frequency, for + example 1m if your data is written every minute. + + } + /> +
    +
    +
    + + ); +}; + +const changeHandler = ( + key: keyof DataSourceSettings, + value: Props['value'], + onChange: Props['onChange'] +) => (event: React.SyntheticEvent) => { + onChange({ + ...value, + [key]: event.currentTarget.value, + }); +}; + +const jsonDataChangeHandler = (key: keyof ElasticsearchOptions, value: Props['value'], onChange: Props['onChange']) => ( + event: React.SyntheticEvent +) => { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + [key]: event.currentTarget.value, + }, + }); +}; + +const intervalHandler = (value: Props['value'], onChange: Props['onChange']) => ( + option: SelectableValue +) => { + const { database } = value; + // If option value is undefined it will send its label instead so we have to convert made up value to undefined here. + const newInterval = option.value === 'none' ? undefined : option.value; + + if (!database || database.length === 0 || database.startsWith('[logstash-]')) { + let newDatabase = ''; + + if (newInterval !== undefined) { + const pattern = indexPatternTypes.find((pattern) => pattern.value === newInterval); + + if (pattern) { + newDatabase = pattern.example ?? ''; + } + } + + onChange({ + ...value, + database: newDatabase, + jsonData: { + ...value.jsonData, + interval: newInterval, + }, + }); + } else { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + interval: newInterval, + }, + }); + } +}; + +function getMaxConcurrenShardRequestOrDefault(maxConcurrentShardRequests: number | undefined, version: number): number { + if (maxConcurrentShardRequests === 5 && version < 70) { + return 256; + } + + if (maxConcurrentShardRequests === 256 && version >= 70) { + return 5; + } + + return maxConcurrentShardRequests || defaultMaxConcurrentShardRequests(version); +} + +export function defaultMaxConcurrentShardRequests(version: number) { + return version >= 70 ? 5 : 256; +} diff --git a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx new file mode 100644 index 0000000..eab865a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { LogsConfig } from './LogsConfig'; +import { createDefaultConfigOptions } from './mocks'; +import { LegacyForms } from '@grafana/ui'; +const { FormField } = LegacyForms; + +describe('ElasticDetails', () => { + it('should render without error', () => { + mount( {}} value={createDefaultConfigOptions().jsonData} />); + }); + + it('should render fields', () => { + const wrapper = shallow( {}} value={createDefaultConfigOptions().jsonData} />); + expect(wrapper.find(FormField).length).toBe(2); + }); + + it('should pass correct data to onChange', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const inputEl = wrapper.find(FormField).at(0).find('input'); + (inputEl.getDOMNode() as any).value = 'test_field'; + inputEl.simulate('change'); + expect(onChangeMock.mock.calls[0][0].logMessageField).toBe('test_field'); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx new file mode 100644 index 0000000..67d547e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/LogsConfig.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { LegacyForms } from '@grafana/ui'; +const { FormField } = LegacyForms; +import { ElasticsearchOptions } from '../types'; + +type Props = { + value: ElasticsearchOptions; + onChange: (value: ElasticsearchOptions) => void; +}; +export const LogsConfig = (props: Props) => { + const { value, onChange } = props; + const changeHandler = (key: keyof ElasticsearchOptions) => ( + event: React.SyntheticEvent + ) => { + onChange({ + ...value, + [key]: event.currentTarget.value, + }); + }; + + return ( + <> +

    Logs

    + +
    +
    + +
    +
    + +
    +
    + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts new file mode 100644 index 0000000..ea7b451 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts @@ -0,0 +1,15 @@ +import { DataSourceSettings } from '@grafana/data'; +import { ElasticsearchOptions } from '../types'; +import { createDatasourceSettings } from '../../../../features/datasources/mocks'; + +export function createDefaultConfigOptions(): DataSourceSettings { + return createDatasourceSettings({ + timeField: '@time', + esVersion: 70, + interval: 'Hourly', + timeInterval: '10s', + maxConcurrentShardRequests: 300, + logMessageField: 'test.message', + logLevelField: 'test.level', + }); +} diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts new file mode 100644 index 0000000..9d69c76 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -0,0 +1,1020 @@ +import _ from 'lodash'; +import { Observable, of, throwError } from 'rxjs'; +import { + ArrayVector, + CoreApp, + DataQueryRequest, + DataSourceInstanceSettings, + DataSourcePluginMeta, + dateMath, + DateTime, + dateTime, + Field, + MutableDataFrame, + TimeRange, + toUtc, +} from '@grafana/data'; +import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; + +import { ElasticDatasource, enhanceDataFrame } from './datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { ElasticsearchOptions, ElasticsearchQuery } from './types'; +import { Filters } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { createFetchResponse } from '../../../../test/helpers/createFetchResponse'; + +const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, + getDataSourceSrv: () => { + return { + getInstanceSettings: () => { + return { name: 'elastic25' }; + }, + }; + }, +})); + +const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ + from, + to, + raw: { + from, + to, + }, +}); + +interface Args { + data?: any; + from?: string; + jsonData?: any; + database?: string; + mockImplementation?: (options: BackendSrvRequest) => Observable; +} + +function getTestContext({ + data = {}, + from = 'now-5m', + jsonData = {}, + database = '[asd-]YYYY.MM.DD', + mockImplementation = undefined, +}: Args = {}) { + jest.clearAllMocks(); + + const defaultMock = (options: BackendSrvRequest) => of(createFetchResponse(data)); + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(mockImplementation ?? defaultMock); + + const templateSrv: any = { + replace: jest.fn((text) => { + if (text.startsWith('$')) { + return `resolvedVariable`; + } else { + return text; + } + }), + getAdhocFilters: jest.fn(() => []), + }; + + const timeSrv: any = { + time: { from, to: 'now' }, + }; + + timeSrv.timeRange = jest.fn(() => { + return { + from: dateMath.parse(timeSrv.time.from, false), + to: dateMath.parse(timeSrv.time.to, true), + }; + }); + + timeSrv.setTime = jest.fn((time) => { + timeSrv.time = time; + }); + + const instanceSettings: DataSourceInstanceSettings = { + id: 1, + meta: {} as DataSourcePluginMeta, + name: 'test-elastic', + type: 'type', + uid: 'uid', + url: ELASTICSEARCH_MOCK_URL, + database, + jsonData, + }; + + const ds = new ElasticDatasource(instanceSettings, templateSrv); + + return { timeSrv, ds, fetchMock }; +} + +describe('ElasticDatasource', function (this: any) { + describe('When testing datasource with index pattern', () => { + it('should translate index pattern to current day', () => { + const { ds, fetchMock } = getTestContext({ jsonData: { interval: 'Daily', esVersion: 2 } }); + + ds.testDatasource(); + + const today = toUtc().format('YYYY.MM.DD'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/asd-${today}/_mapping`); + }); + }); + + describe('When issuing metric query with interval pattern', () => { + async function runScenario() { + const range = { from: toUtc([2015, 4, 30, 10]), to: toUtc([2015, 5, 1, 10]) }; + const targets = [ + { + alias: '$varAlias', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], + metrics: [{ type: 'count', id: '1' }], + query: 'escape\\:test', + }, + ]; + const query: any = { range, targets }; + const data = { + responses: [ + { + aggregations: { + '1': { + buckets: [ + { + doc_count: 10, + key: 1000, + }, + ], + }, + }, + }, + ], + }; + const { ds, fetchMock } = getTestContext({ jsonData: { interval: 'Daily', esVersion: 2 }, data }); + + let result: any = {}; + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual({ + data: [ + { + datapoints: [[10, 1000]], + metric: 'count', + props: {}, + refId: undefined, + target: 'resolvedVariable', + }, + ], + }); + result = received[0]; + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestOptions = fetchMock.mock.calls[0][0]; + const parts = requestOptions.data.split('\n'); + const header = JSON.parse(parts[0]); + const body = JSON.parse(parts[1]); + + return { result, body, header, query }; + } + + it('should translate index pattern to current day', async () => { + const { header } = await runScenario(); + expect(header.index).toEqual(['asd-2015.05.30', 'asd-2015.05.31', 'asd-2015.06.01']); + }); + + it('should not resolve the variable in the original alias field in the query', async () => { + const { query } = await runScenario(); + expect(query.targets[0].alias).toEqual('$varAlias'); + }); + + it('should resolve the alias variable for the alias/target in the result', async () => { + const { result } = await runScenario(); + expect(result.data[0].target).toEqual('resolvedVariable'); + }); + + it('should json escape lucene query', async () => { + const { body } = await runScenario(); + expect(body.query.bool.filter[1].query_string.query).toBe('escape\\:test'); + }); + }); + + describe('When issuing logs query with interval pattern', () => { + async function setupDataSource(jsonData?: Partial) { + jsonData = { + interval: 'Daily', + esVersion: 2, + timeField: '@timestamp', + ...(jsonData || {}), + }; + const { ds } = getTestContext({ + jsonData, + data: logsResponse.data, + database: 'mock-index', + }); + + const query: DataQueryRequest = { + range: createTimeRange(toUtc([2015, 4, 30, 10]), toUtc([2019, 7, 1, 10])), + targets: [ + { + alias: '$varAlias', + refId: 'A', + bucketAggs: [ + { + type: 'date_histogram', + settings: { interval: 'auto' }, + id: '2', + }, + ], + metrics: [{ type: 'logs', id: '1' }], + query: 'escape\\:test', + timeField: '@timestamp', + }, + ], + } as DataQueryRequest; + + const queryBuilderSpy = jest.spyOn(ds.queryBuilder, 'getLogsQuery'); + let response: any = {}; + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + response = received[0]; + }); + + return { queryBuilderSpy, response }; + } + + it('should call getLogsQuery()', async () => { + const { queryBuilderSpy } = await setupDataSource(); + expect(queryBuilderSpy).toHaveBeenCalled(); + }); + + it('should enhance fields with links', async () => { + const { response } = await setupDataSource({ + dataLinks: [ + { + field: 'host', + url: 'http://localhost:3000/${__value.raw}', + }, + ], + }); + + expect(response.data.length).toBe(1); + const links = response.data[0].fields.find((field: Field) => field.name === 'host').config.links; + expect(links.length).toBe(1); + expect(links[0].url).toBe('http://localhost:3000/${__value.raw}'); + }); + }); + + describe('When issuing document query', () => { + async function runScenario() { + const range = createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])); + const targets = [{ refId: 'A', metrics: [{ type: 'raw_document', id: '1' }], query: 'test' }]; + const query: any = { range, targets }; + const data = { responses: [] }; + + const { ds, fetchMock } = getTestContext({ jsonData: { esVersion: 2 }, data, database: 'test' }); + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual({ data: [] }); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestOptions = fetchMock.mock.calls[0][0]; + const parts = requestOptions.data.split('\n'); + const header = JSON.parse(parts[0]); + const body = JSON.parse(parts[1]); + + return { body, header }; + } + + it('should set search type to query_then_fetch', async () => { + const { header } = await runScenario(); + expect(header.search_type).toEqual('query_then_fetch'); + }); + + it('should set size', async () => { + const { body } = await runScenario(); + expect(body.size).toBe(500); + }); + }); + + describe('When getting an error on response', () => { + const query: DataQueryRequest = { + range: createTimeRange(toUtc([2020, 1, 1, 10]), toUtc([2020, 2, 1, 10])), + targets: [ + { + refId: 'A', + alias: '$varAlias', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], + metrics: [{ type: 'count', id: '1' }], + query: 'escape\\:test', + }, + ], + } as DataQueryRequest; + + it('should process it properly', async () => { + const { ds } = getTestContext({ + jsonData: { interval: 'Daily', esVersion: 7 }, + data: { + took: 1, + responses: [ + { + error: { + reason: 'all shards failed', + }, + status: 400, + }, + ], + }, + }); + + const errObject = { + data: '{\n "reason": "all shards failed"\n}', + message: 'all shards failed', + config: { + url: 'http://localhost:3000/api/tsdb/query', + }, + }; + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual(errObject); + }); + }); + + it('should properly throw an error with just a message', async () => { + const response: FetchResponse = { + data: { + error: 'Bad Request', + message: 'Authentication to data source failed', + }, + status: 400, + url: 'http://localhost:3000/api/tsdb/query', + config: { url: 'http://localhost:3000/api/tsdb/query' }, + type: 'basic', + statusText: 'Bad Request', + redirected: false, + headers: ({} as unknown) as Headers, + ok: false, + }; + + const { ds } = getTestContext({ + mockImplementation: () => throwError(response), + }); + + const errObject = { + error: 'Bad Request', + message: 'Elasticsearch error: Authentication to data source failed', + }; + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual(errObject); + }); + }); + + it('should properly throw an unknown error', async () => { + const { ds } = getTestContext({ + jsonData: { interval: 'Daily', esVersion: 7 }, + data: { + took: 1, + responses: [ + { + error: {}, + status: 400, + }, + ], + }, + }); + + const errObject = { + data: '{}', + message: 'Unknown elastic error response', + config: { + url: 'http://localhost:3000/api/tsdb/query', + }, + }; + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual(errObject); + }); + }); + }); + + describe('When getting fields', () => { + const data = { + metricbeat: { + mappings: { + metricsets: { + _all: {}, + _meta: { + test: 'something', + }, + properties: { + '@timestamp': { type: 'date' }, + __timestamp: { type: 'date' }, + '@timestampnano': { type: 'date_nanos' }, + beat: { + properties: { + name: { + fields: { raw: { type: 'keyword' } }, + type: 'string', + }, + hostname: { type: 'string' }, + }, + }, + system: { + properties: { + cpu: { + properties: { + system: { type: 'float' }, + user: { type: 'float' }, + }, + }, + process: { + properties: { + cpu: { + properties: { + total: { type: 'float' }, + }, + }, + name: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + it('should return nested fields', async () => { + const { ds } = getTestContext({ data, jsonData: { esVersion: 50 }, database: 'metricbeat' }); + + await expect(ds.getFields()).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + + expect(fields).toEqual([ + '@timestamp', + '__timestamp', + '@timestampnano', + 'beat.name.raw', + 'beat.name', + 'beat.hostname', + 'system.cpu.system', + 'system.cpu.user', + 'system.process.cpu.total', + 'system.process.name', + ]); + }); + }); + + it('should return number fields', async () => { + const { ds } = getTestContext({ data, jsonData: { esVersion: 50 }, database: 'metricbeat' }); + + await expect(ds.getFields('number')).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + + expect(fields).toEqual(['system.cpu.system', 'system.cpu.user', 'system.process.cpu.total']); + }); + }); + + it('should return date fields', async () => { + const { ds } = getTestContext({ data, jsonData: { esVersion: 50 }, database: 'metricbeat' }); + + await expect(ds.getFields('date')).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + + expect(fields).toEqual(['@timestamp', '__timestamp', '@timestampnano']); + }); + }); + }); + + describe('When getting field mappings on indices with gaps', () => { + const basicResponse = { + metricbeat: { + mappings: { + metricsets: { + _all: {}, + properties: { + '@timestamp': { type: 'date' }, + beat: { + properties: { + hostname: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }; + + const alternateResponse = { + metricbeat: { + mappings: { + metricsets: { + _all: {}, + properties: { + '@timestamp': { type: 'date' }, + }, + }, + }, + }, + }; + + it('should return fields of the newest available index', async () => { + const twoDaysBefore = toUtc().subtract(2, 'day').format('YYYY.MM.DD'); + const threeDaysBefore = toUtc().subtract(3, 'day').format('YYYY.MM.DD'); + const baseUrl = `${ELASTICSEARCH_MOCK_URL}/asd-${twoDaysBefore}/_mapping`; + const alternateUrl = `${ELASTICSEARCH_MOCK_URL}/asd-${threeDaysBefore}/_mapping`; + + const { ds, timeSrv } = getTestContext({ + from: 'now-2w', + jsonData: { interval: 'Daily', esVersion: 50 }, + mockImplementation: (options) => { + if (options.url === baseUrl) { + return of(createFetchResponse(basicResponse)); + } else if (options.url === alternateUrl) { + return of(createFetchResponse(alternateResponse)); + } + return throwError({ status: 404 }); + }, + }); + + const range = timeSrv.timeRange(); + + await expect(ds.getFields(undefined, range)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual(['@timestamp', 'beat.hostname']); + }); + }); + + it('should not retry when ES is down', async () => { + const twoDaysBefore = toUtc().subtract(2, 'day').format('YYYY.MM.DD'); + + const { ds, timeSrv, fetchMock } = getTestContext({ + from: 'now-2w', + jsonData: { interval: 'Daily', esVersion: 50 }, + mockImplementation: (options) => { + if (options.url === `${ELASTICSEARCH_MOCK_URL}/asd-${twoDaysBefore}/_mapping`) { + return of(createFetchResponse(basicResponse)); + } + return throwError({ status: 500 }); + }, + }); + + const range = timeSrv.timeRange(); + + await expect(ds.getFields(undefined, range)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toStrictEqual({ status: 500 }); + expect(fetchMock).toBeCalledTimes(1); + }); + }); + + it('should not retry more than 7 indices', async () => { + const { ds, timeSrv, fetchMock } = getTestContext({ + from: 'now-2w', + jsonData: { interval: 'Daily', esVersion: 50 }, + mockImplementation: (options) => { + return throwError({ status: 404 }); + }, + }); + const range = timeSrv.timeRange(); + + await expect(ds.getFields(undefined, range)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toStrictEqual('Could not find an available index for this time range.'); + expect(fetchMock).toBeCalledTimes(7); + }); + }); + }); + + describe('When getting fields from ES 7.0', () => { + const data = { + 'genuine.es7._mapping.response': { + mappings: { + properties: { + '@timestamp_millis': { + type: 'date', + format: 'epoch_millis', + }, + classification_terms: { + type: 'keyword', + }, + domains: { + type: 'keyword', + }, + ip_address: { + type: 'ip', + }, + justification_blob: { + properties: { + criterion: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + overall_vote_score: { + type: 'float', + }, + shallow: { + properties: { + jsi: { + properties: { + sdb: { + properties: { + dsel2: { + properties: { + 'bootlegged-gille': { + properties: { + botness: { + type: 'float', + }, + general_algorithm_score: { + type: 'float', + }, + }, + }, + 'uncombed-boris': { + properties: { + botness: { + type: 'float', + }, + general_algorithm_score: { + type: 'float', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + overall_vote_score: { + type: 'float', + }, + ua_terms_long: { + type: 'keyword', + }, + ua_terms_short: { + type: 'keyword', + }, + }, + }, + }, + }; + + it('should return nested fields', async () => { + const { ds } = getTestContext({ data, database: 'genuine.es7._mapping.response', jsonData: { esVersion: 70 } }); + + await expect(ds.getFields()).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual([ + '@timestamp_millis', + 'classification_terms', + 'domains', + 'ip_address', + 'justification_blob.criterion.keyword', + 'justification_blob.criterion', + 'justification_blob.overall_vote_score', + 'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.botness', + 'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.general_algorithm_score', + 'justification_blob.shallow.jsi.sdb.dsel2.uncombed-boris.botness', + 'justification_blob.shallow.jsi.sdb.dsel2.uncombed-boris.general_algorithm_score', + 'overall_vote_score', + 'ua_terms_long', + 'ua_terms_short', + ]); + }); + }); + + it('should return number fields', async () => { + const { ds } = getTestContext({ data, database: 'genuine.es7._mapping.response', jsonData: { esVersion: 70 } }); + + await expect(ds.getFields('number')).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual([ + 'justification_blob.overall_vote_score', + 'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.botness', + 'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.general_algorithm_score', + 'justification_blob.shallow.jsi.sdb.dsel2.uncombed-boris.botness', + 'justification_blob.shallow.jsi.sdb.dsel2.uncombed-boris.general_algorithm_score', + 'overall_vote_score', + ]); + }); + }); + + it('should return date fields', async () => { + const { ds } = getTestContext({ data, database: 'genuine.es7._mapping.response', jsonData: { esVersion: 70 } }); + + await expect(ds.getFields('date')).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + + const fieldObjects = received[0]; + const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual(['@timestamp_millis']); + }); + }); + }); + + describe('When issuing aggregation query on es5.x', () => { + async function runScenario() { + const range = createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])); + const targets = [ + { + refId: 'A', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + metrics: [{ type: 'count', id: '1' }], + query: 'test', + }, + ]; + const query: any = { range, targets }; + const data = { responses: [] }; + + const { ds, fetchMock } = getTestContext({ jsonData: { esVersion: 5 }, data, database: 'test' }); + + await expect(ds.query(query)).toEmitValuesWith((received) => { + expect(received.length).toBe(1); + expect(received[0]).toEqual({ data: [] }); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestOptions = fetchMock.mock.calls[0][0]; + const parts = requestOptions.data.split('\n'); + const header = JSON.parse(parts[0]); + const body = JSON.parse(parts[1]); + + return { body, header }; + } + + it('should not set search type to count', async () => { + const { header } = await runScenario(); + expect(header.search_type).not.toEqual('count'); + }); + + it('should set size to 0', async () => { + const { body } = await runScenario(); + expect(body.size).toBe(0); + }); + }); + + describe('When issuing metricFind query on es5.x', () => { + async function runScenario() { + const data = { + responses: [ + { + aggregations: { + '1': { + buckets: [ + { doc_count: 1, key: 'test' }, + { + doc_count: 2, + key: 'test2', + key_as_string: 'test2_as_string', + }, + ], + }, + }, + }, + ], + }; + + const { ds, fetchMock } = getTestContext({ jsonData: { esVersion: 5 }, data, database: 'test' }); + + const results = await ds.metricFindQuery('{"find": "terms", "field": "test"}'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestOptions = fetchMock.mock.calls[0][0]; + const parts = requestOptions.data.split('\n'); + const header = JSON.parse(parts[0]); + const body = JSON.parse(parts[1]); + + return { results, body, header }; + } + + it('should get results', async () => { + const { results } = await runScenario(); + expect(results.length).toEqual(2); + }); + + it('should use key or key_as_string', async () => { + const { results } = await runScenario(); + expect(results[0].text).toEqual('test'); + expect(results[1].text).toEqual('test2_as_string'); + }); + + it('should not set search type to count', async () => { + const { header } = await runScenario(); + expect(header.search_type).not.toEqual('count'); + }); + + it('should set size to 0', async () => { + const { body } = await runScenario(); + expect(body.size).toBe(0); + }); + + it('should not set terms aggregation size to 0', async () => { + const { body } = await runScenario(); + expect(body['aggs']['1']['terms'].size).not.toBe(0); + }); + }); + + describe('query', () => { + it('should replace range as integer not string', async () => { + const { ds } = getTestContext({ jsonData: { interval: 'Daily', esVersion: 2, timeField: '@time' } }); + const postMock = jest.fn((url: string, data: any) => of(createFetchResponse({ responses: [] }))); + ds['post'] = postMock; + + await expect(ds.query(createElasticQuery())).toEmitValuesWith((received) => { + expect(postMock).toHaveBeenCalledTimes(1); + + const query = postMock.mock.calls[0][1]; + expect(typeof JSON.parse(query.split('\n')[1]).query.bool.filter[0].range['@time'].gte).toBe('number'); + }); + }); + }); + + it('should correctly interpolate variables in query', () => { + const { ds } = getTestContext(); + const query: ElasticsearchQuery = { + refId: 'A', + bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '$var', label: '' }] }, id: '1' }], + metrics: [{ type: 'count', id: '1' }], + query: '$var', + }; + + const interpolatedQuery = ds.interpolateVariablesInQueries([query], {})[0]; + + expect(interpolatedQuery.query).toBe('resolvedVariable'); + expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('resolvedVariable'); + }); + + it('should correctly handle empty query strings', () => { + const { ds } = getTestContext(); + const query: ElasticsearchQuery = { + refId: 'A', + bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '', label: '' }] }, id: '1' }], + metrics: [{ type: 'count', id: '1' }], + query: '', + }; + + const interpolatedQuery = ds.interpolateVariablesInQueries([query], {})[0]; + + expect(interpolatedQuery.query).toBe('*'); + expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('*'); + }); +}); + +describe('enhanceDataFrame', () => { + it('adds links to dataframe', () => { + const df = new MutableDataFrame({ + fields: [ + { + name: 'urlField', + values: new ArrayVector([]), + }, + { + name: 'traceField', + values: new ArrayVector([]), + }, + ], + }); + + enhanceDataFrame(df, [ + { + field: 'urlField', + url: 'someUrl', + }, + { + field: 'traceField', + url: 'query', + datasourceUid: 'dsUid', + }, + ]); + + expect(df.fields[0].config.links!.length).toBe(1); + expect(df.fields[0].config.links![0]).toEqual({ + title: '', + url: 'someUrl', + }); + expect(df.fields[1].config.links!.length).toBe(1); + expect(df.fields[1].config.links![0]).toEqual({ + title: '', + url: '', + internal: { + query: { query: 'query' }, + datasourceName: 'elastic25', + datasourceUid: 'dsUid', + }, + }); + }); +}); + +const createElasticQuery = (): DataQueryRequest => { + return { + requestId: '', + dashboardId: 0, + interval: '', + panelId: 0, + intervalMs: 1, + scopedVars: {}, + timezone: '', + app: CoreApp.Dashboard, + startTime: 0, + range: { + from: dateTime([2015, 4, 30, 10]), + to: dateTime([2015, 5, 1, 10]), + } as any, + targets: [ + { + refId: '', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + metrics: [{ type: 'count', id: '' }], + query: 'test', + }, + ], + }; +}; + +const logsResponse = { + data: { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + doc_count: 10, + key: 1000, + }, + { + doc_count: 15, + key: 2000, + }, + ], + }, + }, + hits: { + hits: [ + { + '@timestamp': ['2019-06-24T09:51:19.765Z'], + _id: 'fdsfs', + _type: '_doc', + _index: 'mock-index', + _source: { + '@timestamp': '2019-06-24T09:51:19.765Z', + host: 'djisaodjsoad', + message: 'hello, i am a message', + }, + fields: { + '@timestamp': ['2019-06-24T09:51:19.765Z'], + }, + }, + { + '@timestamp': ['2019-06-24T09:52:19.765Z'], + _id: 'kdospaidopa', + _type: '_doc', + _index: 'mock-index', + _source: { + '@timestamp': '2019-06-24T09:52:19.765Z', + host: 'dsalkdakdop', + message: 'hello, i am also message', + }, + fields: { + '@timestamp': ['2019-06-24T09:52:19.765Z'], + }, + }, + ], + }, + }, + ], + }, +}; diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts new file mode 100644 index 0000000..7ac4d57 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -0,0 +1,882 @@ +import _ from 'lodash'; +import { + DataFrame, + DataLink, + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + DateTime, + dateTime, + Field, + getDefaultTimeRange, + LogRowModel, + MetricFindValue, + PluginMeta, + ScopedVars, + TimeRange, + toUtc, +} from '@grafana/data'; +import LanguageProvider from './language_provider'; +import { ElasticResponse } from './elastic_response'; +import { IndexPattern } from './index_pattern'; +import { ElasticQueryBuilder } from './query_builder'; +import { defaultBucketAgg, hasMetricOfType } from './query_def'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery } from './types'; +import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; +import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; +import { + isMetricAggregationWithField, + isPipelineAggregationWithMultipleBucketPaths, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { bucketAggregationConfig } from './components/QueryEditor/BucketAggregationsEditor/utils'; +import { isBucketAggregationWithField } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { generate, Observable, of, throwError } from 'rxjs'; +import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty } from 'rxjs/operators'; +import { getScriptValue } from './utils'; + +// Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields. +// custom fields can start with underscores, therefore is not safe to exclude anything that starts with one. +const ELASTIC_META_FIELDS = [ + '_index', + '_type', + '_id', + '_source', + '_size', + '_field_names', + '_ignored', + '_routing', + '_meta', +]; + +export class ElasticDatasource extends DataSourceApi { + basicAuth?: string; + withCredentials?: boolean; + url: string; + name: string; + index: string; + timeField: string; + esVersion: number; + interval: string; + maxConcurrentShardRequests?: number; + queryBuilder: ElasticQueryBuilder; + indexPattern: IndexPattern; + logMessageField?: string; + logLevelField?: string; + dataLinks: DataLinkConfig[]; + languageProvider: LanguageProvider; + + constructor( + instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { + super(instanceSettings); + this.basicAuth = instanceSettings.basicAuth; + this.withCredentials = instanceSettings.withCredentials; + this.url = instanceSettings.url!; + this.name = instanceSettings.name; + this.index = instanceSettings.database ?? ''; + const settingsData = instanceSettings.jsonData || ({} as ElasticsearchOptions); + + this.timeField = settingsData.timeField; + this.esVersion = settingsData.esVersion; + this.indexPattern = new IndexPattern(this.index, settingsData.interval); + this.interval = settingsData.timeInterval; + this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests; + this.queryBuilder = new ElasticQueryBuilder({ + timeField: this.timeField, + esVersion: this.esVersion, + }); + this.logMessageField = settingsData.logMessageField || ''; + this.logLevelField = settingsData.logLevelField || ''; + this.dataLinks = settingsData.dataLinks || []; + + if (this.logMessageField === '') { + this.logMessageField = undefined; + } + + if (this.logLevelField === '') { + this.logLevelField = undefined; + } + this.languageProvider = new LanguageProvider(this); + } + + private request(method: string, url: string, data?: undefined): Observable { + const options: any = { + url: this.url + '/' + url, + method: method, + data: data, + }; + + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + if (this.basicAuth) { + options.headers = { + Authorization: this.basicAuth, + }; + } + + return getBackendSrv() + .fetch(options) + .pipe( + map((results) => { + results.data.$$config = results.config; + return results.data; + }), + catchError((err) => { + if (err.data) { + const message = err.data.error?.reason ?? err.data.message ?? 'Unknown error'; + + return throwError({ + message: 'Elasticsearch error: ' + message, + error: err.data.error, + }); + } + + return throwError(err); + }) + ); + } + + async importQueries(queries: DataQuery[], originMeta: PluginMeta): Promise { + return this.languageProvider.importQueries(queries, originMeta.id); + } + + /** + * Sends a GET request to the specified url on the newest matching and available index. + * + * When multiple indices span the provided time range, the request is sent starting from the newest index, + * and then going backwards until an index is found. + * + * @param url the url to query the index on, for example `/_mapping`. + */ + private get(url: string, range = getDefaultTimeRange()): Observable { + let indexList = this.indexPattern.getIndexList(range.from, range.to); + if (!Array.isArray(indexList)) { + indexList = [this.indexPattern.getIndexForToday()]; + } + + const indexUrlList = indexList.map((index) => index + url); + + return this.requestAllIndices(indexUrlList); + } + + private requestAllIndices(indexList: string[]): Observable { + const maxTraversals = 7; // do not go beyond one week (for a daily pattern) + const listLen = indexList.length; + + return generate( + 0, + (i) => i < Math.min(listLen, maxTraversals), + (i) => i + 1 + ).pipe( + mergeMap((index) => { + // catch all errors and emit an object with an err property to simplify checks later in the pipeline + return this.request('GET', indexList[listLen - index - 1]).pipe(catchError((err) => of({ err }))); + }), + skipWhile((resp) => resp.err && resp.err.status === 404), // skip all requests that fail because missing Elastic index + throwIfEmpty(() => 'Could not find an available index for this time range.'), // when i === Math.min(listLen, maxTraversals) generate will complete but without emitting any values which means we didn't find a valid index + first(), // take the first value that isn't skipped + map((resp) => { + if (resp.err) { + throw resp.err; // if there is some other error except 404 then we must throw it + } + + return resp; + }) + ); + } + + private post(url: string, data: any): Observable { + return this.request('POST', url, data); + } + + annotationQuery(options: any): Promise { + const annotation = options.annotation; + const timeField = annotation.timeField || '@timestamp'; + const timeEndField = annotation.timeEndField || null; + const queryString = annotation.query || '*'; + const tagsField = annotation.tagsField || 'tags'; + const textField = annotation.textField || null; + + const dateRanges = []; + const rangeStart: any = {}; + rangeStart[timeField] = { + from: options.range.from.valueOf(), + to: options.range.to.valueOf(), + format: 'epoch_millis', + }; + dateRanges.push({ range: rangeStart }); + + if (timeEndField) { + const rangeEnd: any = {}; + rangeEnd[timeEndField] = { + from: options.range.from.valueOf(), + to: options.range.to.valueOf(), + format: 'epoch_millis', + }; + dateRanges.push({ range: rangeEnd }); + } + + const queryInterpolated = this.templateSrv.replace(queryString, {}, 'lucene'); + const query = { + bool: { + filter: [ + { + bool: { + should: dateRanges, + minimum_should_match: 1, + }, + }, + { + query_string: { + query: queryInterpolated, + }, + }, + ], + }, + }; + + const data: any = { + query, + size: 10000, + }; + + // fields field not supported on ES 5.x + if (this.esVersion < 5) { + data['fields'] = [timeField, '_source']; + } + + const header: any = { + search_type: 'query_then_fetch', + ignore_unavailable: true, + }; + + // old elastic annotations had index specified on them + if (annotation.index) { + header.index = annotation.index; + } else { + header.index = this.indexPattern.getIndexList(options.range.from, options.range.to); + } + + const payload = JSON.stringify(header) + '\n' + JSON.stringify(data) + '\n'; + + return this.post('_msearch', payload) + .pipe( + map((res) => { + const list = []; + const hits = res.responses[0].hits.hits; + + const getFieldFromSource = (source: any, fieldName: any) => { + if (!fieldName) { + return; + } + + const fieldNames = fieldName.split('.'); + let fieldValue = source; + + for (let i = 0; i < fieldNames.length; i++) { + fieldValue = fieldValue[fieldNames[i]]; + if (!fieldValue) { + console.log('could not find field in annotation: ', fieldName); + return ''; + } + } + + return fieldValue; + }; + + for (let i = 0; i < hits.length; i++) { + const source = hits[i]._source; + let time = getFieldFromSource(source, timeField); + if (typeof hits[i].fields !== 'undefined') { + const fields = hits[i].fields; + if (_.isString(fields[timeField]) || _.isNumber(fields[timeField])) { + time = fields[timeField]; + } + } + + const event: { + annotation: any; + time: number; + timeEnd?: number; + text: string; + tags: string | string[]; + } = { + annotation: annotation, + time: toUtc(time).valueOf(), + text: getFieldFromSource(source, textField), + tags: getFieldFromSource(source, tagsField), + }; + + if (timeEndField) { + const timeEnd = getFieldFromSource(source, timeEndField); + if (timeEnd) { + event.timeEnd = toUtc(timeEnd).valueOf(); + } + } + + // legacy support for title tield + if (annotation.titleField) { + const title = getFieldFromSource(source, annotation.titleField); + if (title) { + event.text = title + '\n' + event.text; + } + } + + if (typeof event.tags === 'string') { + event.tags = event.tags.split(','); + } + + list.push(event); + } + return list; + }) + ) + .toPromise(); + } + + private interpolateLuceneQuery(queryString: string, scopedVars: ScopedVars) { + // Elasticsearch queryString should always be '*' if empty string + return this.templateSrv.replace(queryString, scopedVars, 'lucene') || '*'; + } + + interpolateVariablesInQueries(queries: ElasticsearchQuery[], scopedVars: ScopedVars): ElasticsearchQuery[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + query: this.interpolateLuceneQuery(query.query || '', scopedVars), + }; + + for (let bucketAgg of query.bucketAggs || []) { + if (bucketAgg.type === 'filters') { + for (let filter of bucketAgg.settings?.filters || []) { + filter.query = this.interpolateLuceneQuery(filter.query, scopedVars); + } + } + } + return expandedQuery; + }); + } + return expandedQueries; + } + + testDatasource() { + // validate that the index exist and has date field + return this.getFields('date') + .pipe( + mergeMap((dateFields) => { + const timeField: any = _.find(dateFields, { text: this.timeField }); + if (!timeField) { + return of({ status: 'error', message: 'No date field named ' + this.timeField + ' found' }); + } + return of({ status: 'success', message: 'Index OK. Time field name OK.' }); + }), + catchError((err) => { + console.error(err); + if (err.message) { + return of({ status: 'error', message: err.message }); + } else { + return of({ status: 'error', message: err.status }); + } + }) + ) + .toPromise(); + } + + getQueryHeader(searchType: any, timeFrom?: DateTime, timeTo?: DateTime): string { + const queryHeader: any = { + search_type: searchType, + ignore_unavailable: true, + index: this.indexPattern.getIndexList(timeFrom, timeTo), + }; + + if (this.esVersion >= 56 && this.esVersion < 70) { + queryHeader['max_concurrent_shard_requests'] = this.maxConcurrentShardRequests; + } + + return JSON.stringify(queryHeader); + } + + getQueryDisplayText(query: ElasticsearchQuery) { + // TODO: This might be refactored a bit. + const metricAggs = query.metrics; + const bucketAggs = query.bucketAggs; + let text = ''; + + if (query.query) { + text += 'Query: ' + query.query + ', '; + } + + text += 'Metrics: '; + + text += metricAggs?.reduce((acc, metric) => { + const metricConfig = metricAggregationConfig[metric.type]; + + let text = metricConfig.label + '('; + + if (isMetricAggregationWithField(metric)) { + text += metric.field; + } + if (isPipelineAggregationWithMultipleBucketPaths(metric)) { + text += getScriptValue(metric).replace(new RegExp('params.', 'g'), ''); + } + text += '), '; + + return `${acc} ${text}`; + }, ''); + + text += bucketAggs?.reduce((acc, bucketAgg, index) => { + const bucketConfig = bucketAggregationConfig[bucketAgg.type]; + + let text = ''; + if (index === 0) { + text += ' Group by: '; + } + + text += bucketConfig.label + '('; + if (isBucketAggregationWithField(bucketAgg)) { + text += bucketAgg.field; + } + + return `${acc} ${text}), `; + }, ''); + + if (query.alias) { + text += 'Alias: ' + query.alias; + } + + return text; + } + + /** + * This method checks to ensure the user is running a 5.0+ cluster. This is + * necessary bacause the query being used for the getLogRowContext relies on the + * search_after feature. + */ + showContextToggle(): boolean { + return this.esVersion > 5; + } + + getLogRowContext = async (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => { + const sortField = row.dataFrame.fields.find((f) => f.name === 'sort'); + const searchAfter = sortField?.values.get(row.rowIndex) || [row.timeEpochMs]; + const sort = options?.direction === 'FORWARD' ? 'asc' : 'desc'; + + const header = + options?.direction === 'FORWARD' + ? this.getQueryHeader('query_then_fetch', dateTime(row.timeEpochMs)) + : this.getQueryHeader('query_then_fetch', undefined, dateTime(row.timeEpochMs)); + + const limit = options?.limit ?? 10; + const esQuery = JSON.stringify({ + size: limit, + query: { + bool: { + filter: [ + { + range: { + [this.timeField]: { + [options?.direction === 'FORWARD' ? 'gte' : 'lte']: row.timeEpochMs, + format: 'epoch_millis', + }, + }, + }, + ], + }, + }, + sort: [{ [this.timeField]: sort }, { _doc: sort }], + search_after: searchAfter, + }); + const payload = [header, esQuery].join('\n') + '\n'; + const url = this.getMultiSearchUrl(); + const response = await this.post(url, payload).toPromise(); + const targets: ElasticsearchQuery[] = [{ refId: `${row.dataFrame.refId}`, metrics: [{ type: 'logs', id: '1' }] }]; + const elasticResponse = new ElasticResponse(targets, transformHitsBasedOnDirection(response, sort)); + const logResponse = elasticResponse.getLogs(this.logMessageField, this.logLevelField); + const dataFrame = _.first(logResponse.data); + if (!dataFrame) { + return { data: [] }; + } + /** + * The LogRowContextProvider requires there is a field in the dataFrame.fields + * named `ts` for timestamp and `line` for the actual log line to display. + * Unfortunatly these fields are hardcoded and are required for the lines to + * be properly displayed. This code just copies the fields based on this.timeField + * and this.logMessageField and recreates the dataFrame so it works. + */ + const timestampField = dataFrame.fields.find((f: Field) => f.name === this.timeField); + const lineField = dataFrame.fields.find((f: Field) => f.name === this.logMessageField); + if (timestampField && lineField) { + return { + data: [ + { + ...dataFrame, + fields: [...dataFrame.fields, { ...timestampField, name: 'ts' }, { ...lineField, name: 'line' }], + }, + ], + }; + } + return logResponse; + }; + + query(options: DataQueryRequest): Observable { + let payload = ''; + const targets = this.interpolateVariablesInQueries(_.cloneDeep(options.targets), options.scopedVars); + const sentTargets: ElasticsearchQuery[] = []; + let targetsContainsLogsQuery = targets.some((target) => hasMetricOfType(target, 'logs')); + + // add global adhoc filters to timeFilter + const adhocFilters = this.templateSrv.getAdhocFilters(this.name); + + for (const target of targets) { + if (target.hide) { + continue; + } + + let queryObj; + if (hasMetricOfType(target, 'logs')) { + target.bucketAggs = [defaultBucketAgg()]; + target.metrics = []; + // Setting this for metrics queries that are typed as logs + queryObj = this.queryBuilder.getLogsQuery(target, adhocFilters, target.query); + } else { + if (target.alias) { + target.alias = this.templateSrv.replace(target.alias, options.scopedVars, 'lucene'); + } + + queryObj = this.queryBuilder.build(target, adhocFilters, target.query); + } + + const esQuery = JSON.stringify(queryObj); + + const searchType = queryObj.size === 0 && this.esVersion < 5 ? 'count' : 'query_then_fetch'; + const header = this.getQueryHeader(searchType, options.range.from, options.range.to); + payload += header + '\n'; + + payload += esQuery + '\n'; + + sentTargets.push(target); + } + + if (sentTargets.length === 0) { + return of({ data: [] }); + } + + // We replace the range here for actual values. We need to replace it together with enclosing "" so that we replace + // it as an integer not as string with digits. This is because elastic will convert the string only if the time + // field is specified as type date (which probably should) but can also be specified as integer (millisecond epoch) + // and then sending string will error out. + payload = payload.replace(/"\$timeFrom"/g, options.range.from.valueOf().toString()); + payload = payload.replace(/"\$timeTo"/g, options.range.to.valueOf().toString()); + payload = this.templateSrv.replace(payload, options.scopedVars); + + const url = this.getMultiSearchUrl(); + + return this.post(url, payload).pipe( + map((res) => { + const er = new ElasticResponse(sentTargets, res); + + // TODO: This needs to be revisited, it seems wrong to process ALL the sent queries as logs if only one of them was a log query + if (targetsContainsLogsQuery) { + const response = er.getLogs(this.logMessageField, this.logLevelField); + for (const dataFrame of response.data) { + enhanceDataFrame(dataFrame, this.dataLinks); + } + return response; + } + + return er.getTimeSeries(); + }) + ); + } + + isMetadataField(fieldName: string) { + return ELASTIC_META_FIELDS.includes(fieldName); + } + + // TODO: instead of being a string, this could be a custom type representing all the elastic types + getFields(type?: string, range?: TimeRange): Observable { + const configuredEsVersion = this.esVersion; + return this.get('/_mapping', range).pipe( + map((result) => { + const typeMap: any = { + float: 'number', + double: 'number', + integer: 'number', + long: 'number', + date: 'date', + date_nanos: 'date', + string: 'string', + text: 'string', + scaled_float: 'number', + nested: 'nested', + histogram: 'number', + }; + + const shouldAddField = (obj: any, key: string) => { + if (this.isMetadataField(key)) { + return false; + } + + if (!type) { + return true; + } + + // equal query type filter, or via typemap translation + return type === obj.type || type === typeMap[obj.type]; + }; + + // Store subfield names: [system, process, cpu, total] -> system.process.cpu.total + const fieldNameParts: any = []; + const fields: any = {}; + + function getFieldsRecursively(obj: any) { + for (const key in obj) { + const subObj = obj[key]; + + // Check mapping field for nested fields + if (_.isObject(subObj.properties)) { + fieldNameParts.push(key); + getFieldsRecursively(subObj.properties); + } + + if (_.isObject(subObj.fields)) { + fieldNameParts.push(key); + getFieldsRecursively(subObj.fields); + } + + if (_.isString(subObj.type)) { + const fieldName = fieldNameParts.concat(key).join('.'); + + // Hide meta-fields and check field type + if (shouldAddField(subObj, key)) { + fields[fieldName] = { + text: fieldName, + type: subObj.type, + }; + } + } + } + fieldNameParts.pop(); + } + + for (const indexName in result) { + const index = result[indexName]; + if (index && index.mappings) { + const mappings = index.mappings; + + if (configuredEsVersion < 70) { + for (const typeName in mappings) { + const properties = mappings[typeName].properties; + getFieldsRecursively(properties); + } + } else { + const properties = mappings.properties; + getFieldsRecursively(properties); + } + } + } + + // transform to array + return _.map(fields, (value) => { + return value; + }); + }) + ); + } + + getTerms(queryDef: any, range = getDefaultTimeRange()): Observable { + const searchType = this.esVersion >= 5 ? 'query_then_fetch' : 'count'; + const header = this.getQueryHeader(searchType, range.from, range.to); + let esQuery = JSON.stringify(this.queryBuilder.getTermsQuery(queryDef)); + + esQuery = esQuery.replace(/\$timeFrom/g, range.from.valueOf().toString()); + esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf().toString()); + esQuery = header + '\n' + esQuery + '\n'; + + const url = this.getMultiSearchUrl(); + + return this.post(url, esQuery).pipe( + map((res) => { + if (!res.responses[0].aggregations) { + return []; + } + + const buckets = res.responses[0].aggregations['1'].buckets; + return _.map(buckets, (bucket) => { + return { + text: bucket.key_as_string || bucket.key, + value: bucket.key, + }; + }); + }) + ); + } + + getMultiSearchUrl() { + if (this.esVersion >= 70 && this.maxConcurrentShardRequests) { + return `_msearch?max_concurrent_shard_requests=${this.maxConcurrentShardRequests}`; + } + + return '_msearch'; + } + + metricFindQuery(query: string, options?: any): Promise { + const range = options?.range; + const parsedQuery = JSON.parse(query); + if (query) { + if (parsedQuery.find === 'fields') { + parsedQuery.type = this.templateSrv.replace(parsedQuery.type, {}, 'lucene'); + return this.getFields(parsedQuery.type, range).toPromise(); + } + + if (parsedQuery.find === 'terms') { + parsedQuery.field = this.templateSrv.replace(parsedQuery.field, {}, 'lucene'); + parsedQuery.query = this.templateSrv.replace(parsedQuery.query || '*', {}, 'lucene'); + return this.getTerms(parsedQuery, range).toPromise(); + } + } + + return Promise.resolve([]); + } + + getTagKeys() { + return this.getFields().toPromise(); + } + + getTagValues(options: any) { + return this.getTerms({ field: options.key, query: '*' }).toPromise(); + } + + targetContainsTemplate(target: any) { + if (this.templateSrv.variableExists(target.query) || this.templateSrv.variableExists(target.alias)) { + return true; + } + + for (const bucketAgg of target.bucketAggs) { + if (this.templateSrv.variableExists(bucketAgg.field) || this.objectContainsTemplate(bucketAgg.settings)) { + return true; + } + } + + for (const metric of target.metrics) { + if ( + this.templateSrv.variableExists(metric.field) || + this.objectContainsTemplate(metric.settings) || + this.objectContainsTemplate(metric.meta) + ) { + return true; + } + } + + return false; + } + + private isPrimitive(obj: any) { + if (obj === null || obj === undefined) { + return true; + } + if (['string', 'number', 'boolean'].some((type) => type === typeof true)) { + return true; + } + + return false; + } + + private objectContainsTemplate(obj: any) { + if (!obj) { + return false; + } + + for (const key of Object.keys(obj)) { + if (this.isPrimitive(obj[key])) { + if (this.templateSrv.variableExists(obj[key])) { + return true; + } + } else if (Array.isArray(obj[key])) { + for (const item of obj[key]) { + if (this.objectContainsTemplate(item)) { + return true; + } + } + } else { + if (this.objectContainsTemplate(obj[key])) { + return true; + } + } + } + + return false; + } +} + +/** + * Modifies dataframe and adds dataLinks from the config. + * Exported for tests. + */ +export function enhanceDataFrame(dataFrame: DataFrame, dataLinks: DataLinkConfig[]) { + const dataSourceSrv = getDataSourceSrv(); + + if (!dataLinks.length) { + return; + } + + for (const field of dataFrame.fields) { + const dataLinkConfig = dataLinks.find((dataLink) => field.name && field.name.match(dataLink.field)); + + if (!dataLinkConfig) { + continue; + } + + let link: DataLink; + + if (dataLinkConfig.datasourceUid) { + const dsSettings = dataSourceSrv.getInstanceSettings(dataLinkConfig.datasourceUid); + + link = { + title: '', + url: '', + internal: { + query: { query: dataLinkConfig.url }, + datasourceUid: dataLinkConfig.datasourceUid, + datasourceName: dsSettings?.name ?? 'Data source not found', + }, + }; + } else { + link = { + title: '', + url: dataLinkConfig.url, + }; + } + + field.config = field.config || {}; + field.config.links = [...(field.config.links || []), link]; + } +} + +function transformHitsBasedOnDirection(response: any, direction: 'asc' | 'desc') { + if (direction === 'desc') { + return response; + } + const actualResponse = response.responses[0]; + return { + ...response, + responses: [ + { + ...actualResponse, + hits: { + ...actualResponse.hits, + hits: actualResponse.hits.hits.reverse(), + }, + }, + ], + }; +} diff --git a/public/app/plugins/datasource/elasticsearch/elastic_response.ts b/public/app/plugins/datasource/elasticsearch/elastic_response.ts new file mode 100644 index 0000000..470c064 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/elastic_response.ts @@ -0,0 +1,707 @@ +import _ from 'lodash'; +import flatten from 'app/core/utils/flatten'; +import * as queryDef from './query_def'; +import TableModel from 'app/core/table_model'; +import { + DataQueryResponse, + DataFrame, + toDataFrame, + FieldType, + MutableDataFrame, + PreferredVisualisationType, +} from '@grafana/data'; +import { ElasticsearchAggregation, ElasticsearchQuery } from './types'; +import { + ExtendedStatMetaType, + isMetricAggregationWithField, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { describeMetric, getScriptValue } from './utils'; +import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; + +const HIGHLIGHT_TAGS_EXP = `${queryDef.highlightTags.pre}([^@]+)${queryDef.highlightTags.post}`; + +export class ElasticResponse { + constructor(private targets: ElasticsearchQuery[], private response: any) { + this.targets = targets; + this.response = response; + } + + processMetrics(esAgg: any, target: ElasticsearchQuery, seriesList: any, props: any) { + let newSeries: any; + + for (let y = 0; y < target.metrics!.length; y++) { + const metric = target.metrics![y]; + if (metric.hide) { + continue; + } + + switch (metric.type) { + case 'count': { + newSeries = { datapoints: [], metric: 'count', props, refId: target.refId }; + for (let i = 0; i < esAgg.buckets.length; i++) { + const bucket = esAgg.buckets[i]; + const value = bucket.doc_count; + newSeries.datapoints.push([value, bucket.key]); + } + seriesList.push(newSeries); + break; + } + case 'percentiles': { + if (esAgg.buckets.length === 0) { + break; + } + + const firstBucket = esAgg.buckets[0]; + const percentiles = firstBucket[metric.id].values; + + for (const percentileName in percentiles) { + newSeries = { + datapoints: [], + metric: 'p' + percentileName, + props: props, + field: metric.field, + refId: target.refId, + }; + + for (let i = 0; i < esAgg.buckets.length; i++) { + const bucket = esAgg.buckets[i]; + const values = bucket[metric.id].values; + newSeries.datapoints.push([values[percentileName], bucket.key]); + } + seriesList.push(newSeries); + } + + break; + } + case 'extended_stats': { + for (const statName in metric.meta) { + if (!metric.meta[statName as ExtendedStatMetaType]) { + continue; + } + + newSeries = { + datapoints: [], + metric: statName, + props: props, + field: metric.field, + refId: target.refId, + }; + + for (let i = 0; i < esAgg.buckets.length; i++) { + const bucket = esAgg.buckets[i]; + const stats = bucket[metric.id]; + + // add stats that are in nested obj to top level obj + stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; + stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; + + newSeries.datapoints.push([stats[statName], bucket.key]); + } + + seriesList.push(newSeries); + } + + break; + } + default: { + newSeries = { + datapoints: [], + metric: metric.type, + metricId: metric.id, + props: props, + refId: target.refId, + }; + + if (isMetricAggregationWithField(metric)) { + newSeries.field = metric.field; + } + + for (let i = 0; i < esAgg.buckets.length; i++) { + const bucket = esAgg.buckets[i]; + const value = bucket[metric.id]; + + if (value !== undefined) { + if (value.normalized_value) { + newSeries.datapoints.push([value.normalized_value, bucket.key]); + } else { + newSeries.datapoints.push([value.value, bucket.key]); + } + } + } + seriesList.push(newSeries); + break; + } + } + } + } + + processAggregationDocs( + esAgg: any, + aggDef: ElasticsearchAggregation, + target: ElasticsearchQuery, + table: any, + props: any + ) { + // add columns + if (table.columns.length === 0) { + for (const propKey of _.keys(props)) { + table.addColumn({ text: propKey, filterable: true }); + } + table.addColumn({ text: aggDef.field, filterable: true }); + } + + // helper func to add values to value array + const addMetricValue = (values: any[], metricName: string, value: any) => { + table.addColumn({ text: metricName }); + values.push(value); + }; + const buckets = _.isArray(esAgg.buckets) ? esAgg.buckets : [esAgg.buckets]; + for (const bucket of buckets) { + const values = []; + + for (const propValues of _.values(props)) { + values.push(propValues); + } + + // add bucket key (value) + values.push(bucket.key); + + for (const metric of target.metrics || []) { + switch (metric.type) { + case 'count': { + addMetricValue(values, this.getMetricName(metric.type), bucket.doc_count); + break; + } + case 'extended_stats': { + for (const statName in metric.meta) { + if (!metric.meta[statName as ExtendedStatMetaType]) { + continue; + } + + const stats = bucket[metric.id]; + // add stats that are in nested obj to top level obj + stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; + stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; + + addMetricValue(values, this.getMetricName(statName as ExtendedStatMetaType), stats[statName]); + } + break; + } + case 'percentiles': { + const percentiles = bucket[metric.id].values; + + for (const percentileName in percentiles) { + addMetricValue(values, `p${percentileName} ${metric.field}`, percentiles[percentileName]); + } + break; + } + default: { + let metricName = this.getMetricName(metric.type); + const otherMetrics = _.filter(target.metrics, { type: metric.type }); + + // if more of the same metric type include field field name in property + if (otherMetrics.length > 1) { + if (isMetricAggregationWithField(metric)) { + metricName += ' ' + metric.field; + } + + if (metric.type === 'bucket_script') { + //Use the formula in the column name + metricName = getScriptValue(metric); + } + } + + addMetricValue(values, metricName, bucket[metric.id].value); + break; + } + } + } + + table.rows.push(values); + } + } + + // This is quite complex + // need to recurse down the nested buckets to build series + processBuckets(aggs: any, target: ElasticsearchQuery, seriesList: any, table: TableModel, props: any, depth: number) { + let bucket, aggDef: any, esAgg, aggId; + const maxDepth = target.bucketAggs!.length - 1; + + for (aggId in aggs) { + aggDef = _.find(target.bucketAggs, { id: aggId }); + esAgg = aggs[aggId]; + + if (!aggDef) { + continue; + } + + if (depth === maxDepth) { + if (aggDef.type === 'date_histogram') { + this.processMetrics(esAgg, target, seriesList, props); + } else { + this.processAggregationDocs(esAgg, aggDef, target, table, props); + } + } else { + for (const nameIndex in esAgg.buckets) { + bucket = esAgg.buckets[nameIndex]; + props = _.clone(props); + if (bucket.key !== void 0) { + props[aggDef.field] = bucket.key; + } else { + props['filter'] = nameIndex; + } + if (bucket.key_as_string) { + props[aggDef.field] = bucket.key_as_string; + } + this.processBuckets(bucket, target, seriesList, table, props, depth + 1); + } + } + } + } + + private getMetricName(metric: string): string { + const metricDef = Object.entries(metricAggregationConfig) + .filter(([key]) => key === metric) + .map(([_, value]) => value)[0]; + + if (metricDef) { + return metricDef.label; + } + + const extendedStat = queryDef.extendedStats.find((e) => e.value === metric); + if (extendedStat) { + return extendedStat.label; + } + + return metric; + } + + private getSeriesName(series: any, target: ElasticsearchQuery, metricTypeCount: any) { + let metricName = this.getMetricName(series.metric); + + if (target.alias) { + const regex = /\{\{([\s\S]+?)\}\}/g; + + return target.alias.replace(regex, (match: any, g1: any, g2: any) => { + const group = g1 || g2; + + if (group.indexOf('term ') === 0) { + return series.props[group.substring(5)]; + } + if (series.props[group] !== void 0) { + return series.props[group]; + } + if (group === 'metric') { + return metricName; + } + if (group === 'field') { + return series.field || ''; + } + + return match; + }); + } + + if (queryDef.isPipelineAgg(series.metric)) { + if (series.metric && queryDef.isPipelineAggWithMultipleBucketPaths(series.metric)) { + const agg: any = _.find(target.metrics, { id: series.metricId }); + if (agg && agg.settings.script) { + metricName = getScriptValue(agg); + + for (const pv of agg.pipelineVariables) { + const appliedAgg: any = _.find(target.metrics, { id: pv.pipelineAgg }); + if (appliedAgg) { + metricName = metricName.replace('params.' + pv.name, describeMetric(appliedAgg)); + } + } + } else { + metricName = 'Unset'; + } + } else { + const appliedAgg: any = _.find(target.metrics, { id: series.field }); + if (appliedAgg) { + metricName += ' ' + describeMetric(appliedAgg); + } else { + metricName = 'Unset'; + } + } + } else if (series.field) { + metricName += ' ' + series.field; + } + + const propKeys = _.keys(series.props); + if (propKeys.length === 0) { + return metricName; + } + + let name = ''; + for (const propName in series.props) { + name += series.props[propName] + ' '; + } + + if (metricTypeCount === 1) { + return name.trim(); + } + + return name.trim() + ' ' + metricName; + } + + nameSeries(seriesList: any, target: ElasticsearchQuery) { + const metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length; + + for (let i = 0; i < seriesList.length; i++) { + const series = seriesList[i]; + series.target = this.getSeriesName(series, target, metricTypeCount); + } + } + + processHits(hits: { total: { value: any }; hits: any[] }, seriesList: any[], target: ElasticsearchQuery) { + const hitsTotal = typeof hits.total === 'number' ? hits.total : hits.total.value; // <- Works with Elasticsearch 7.0+ + + const series: any = { + target: target.refId, + type: 'docs', + refId: target.refId, + datapoints: [], + total: hitsTotal, + filterable: true, + }; + let propName, hit, doc: any, i; + + for (i = 0; i < hits.hits.length; i++) { + hit = hits.hits[i]; + doc = { + _id: hit._id, + _type: hit._type, + _index: hit._index, + sort: hit.sort, + highlight: hit.highlight, + }; + + if (hit._source) { + for (propName in hit._source) { + doc[propName] = hit._source[propName]; + } + } + + for (propName in hit.fields) { + doc[propName] = hit.fields[propName]; + } + series.datapoints.push(doc); + } + + seriesList.push(series); + } + + trimDatapoints(aggregations: any, target: ElasticsearchQuery) { + const histogram: any = _.find(target.bucketAggs, { type: 'date_histogram' }); + + const shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges; + if (shouldDropFirstAndLast) { + const trim = histogram.settings.trimEdges; + for (const prop in aggregations) { + const points = aggregations[prop]; + if (points.datapoints.length > trim * 2) { + points.datapoints = points.datapoints.slice(trim, points.datapoints.length - trim); + } + } + } + } + + getErrorFromElasticResponse(response: any, err: any) { + const result: any = {}; + result.data = JSON.stringify(err, null, 4); + if (err.root_cause && err.root_cause.length > 0 && err.root_cause[0].reason) { + result.message = err.root_cause[0].reason; + } else { + result.message = err.reason || 'Unknown elastic error response'; + } + + if (response.$$config) { + result.config = response.$$config; + } + + return result; + } + + getTimeSeries() { + if (this.targets.some((target) => queryDef.hasMetricOfType(target, 'raw_data'))) { + return this.processResponseToDataFrames(false); + } + return this.processResponseToSeries(); + } + + getLogs(logMessageField?: string, logLevelField?: string): DataQueryResponse { + return this.processResponseToDataFrames(true, logMessageField, logLevelField); + } + + processResponseToDataFrames( + isLogsRequest: boolean, + logMessageField?: string, + logLevelField?: string + ): DataQueryResponse { + const dataFrame: DataFrame[] = []; + for (let n = 0; n < this.response.responses.length; n++) { + const response = this.response.responses[n]; + if (response.error) { + throw this.getErrorFromElasticResponse(this.response, response.error); + } + + if (response.hits && response.hits.hits.length > 0) { + const { propNames, docs } = flattenHits(response.hits.hits); + if (docs.length > 0) { + let series = createEmptyDataFrame( + propNames, + this.targets[0].timeField!, + isLogsRequest, + logMessageField, + logLevelField + ); + + // Add a row for each document + for (const doc of docs) { + if (logLevelField) { + // Remap level field based on the datasource config. This field is + // then used in explore to figure out the log level. We may rewrite + // some actual data in the level field if they are different. + doc['level'] = doc[logLevelField]; + } + // When highlighting exists, we need to collect all the highlighted + // phrases and add them to the DataFrame's meta.searchWords array. + if (doc.highlight) { + // There might be multiple words so we need two versions of the + // regular expression. One to match gobally, when used with part.match, + // it returns and array of matches. The second one is used to capture the + // values between the tags. + const globalHighlightWordRegex = new RegExp(HIGHLIGHT_TAGS_EXP, 'g'); + const highlightWordRegex = new RegExp(HIGHLIGHT_TAGS_EXP); + const newSearchWords = Object.keys(doc.highlight) + .flatMap((key) => { + return doc.highlight[key].flatMap((line: string) => { + const matchedPhrases = line.match(globalHighlightWordRegex); + if (!matchedPhrases) { + return []; + } + return matchedPhrases.map((part) => { + const matches = part.match(highlightWordRegex); + return (matches && matches[1]) || null; + }); + }); + }) + .filter(_.identity); + // If meta and searchWords already exists, add the words and + // deduplicate otherwise create a new set of search words. + const searchWords = series.meta?.searchWords + ? _.uniq([...series.meta.searchWords, ...newSearchWords]) + : [...newSearchWords]; + series.meta = series.meta ? { ...series.meta, searchWords } : { searchWords }; + } + series.add(doc); + } + if (isLogsRequest) { + series = addPreferredVisualisationType(series, 'logs'); + } + const target = this.targets[n]; + series.refId = target.refId; + dataFrame.push(series); + } + } + + if (response.aggregations) { + const aggregations = response.aggregations; + const target = this.targets[n]; + const tmpSeriesList: any[] = []; + const table = new TableModel(); + + this.processBuckets(aggregations, target, tmpSeriesList, table, {}, 0); + this.trimDatapoints(tmpSeriesList, target); + this.nameSeries(tmpSeriesList, target); + + if (table.rows.length > 0) { + const series = toDataFrame(table); + series.refId = target.refId; + dataFrame.push(series); + } + + for (let y = 0; y < tmpSeriesList.length; y++) { + let series = toDataFrame(tmpSeriesList[y]); + + // When log results, show aggregations only in graph. Log fields are then going to be shown in table. + if (isLogsRequest) { + series = addPreferredVisualisationType(series, 'graph'); + } + + series.refId = target.refId; + dataFrame.push(series); + } + } + } + + return { data: dataFrame }; + } + + processResponseToSeries = () => { + const seriesList = []; + + for (let i = 0; i < this.response.responses.length; i++) { + const response = this.response.responses[i]; + const target = this.targets[i]; + + if (response.error) { + throw this.getErrorFromElasticResponse(this.response, response.error); + } + + if (response.hits && response.hits.hits.length > 0) { + this.processHits(response.hits, seriesList, target); + } + + if (response.aggregations) { + const aggregations = response.aggregations; + const target = this.targets[i]; + const tmpSeriesList: any[] = []; + const table = new TableModel(); + table.refId = target.refId; + + this.processBuckets(aggregations, target, tmpSeriesList, table, {}, 0); + this.trimDatapoints(tmpSeriesList, target); + this.nameSeries(tmpSeriesList, target); + + for (let y = 0; y < tmpSeriesList.length; y++) { + seriesList.push(tmpSeriesList[y]); + } + + if (table.rows.length > 0) { + seriesList.push(table); + } + } + } + + return { data: seriesList }; + }; +} + +type Doc = { + _id: string; + _type: string; + _index: string; + _source?: any; + sort?: Array; + highlight?: Record; +}; + +/** + * Flatten the docs from response mainly the _source part which can be nested. This flattens it so that it is one level + * deep and the keys are: `level1Name.level2Name...`. Also returns list of all properties from all the docs (not all + * docs have to have the same keys). + * @param hits + */ +const flattenHits = (hits: Doc[]): { docs: Array>; propNames: string[] } => { + const docs: any[] = []; + // We keep a list of all props so that we can create all the fields in the dataFrame, this can lead + // to wide sparse dataframes in case the scheme is different per document. + let propNames: string[] = []; + + for (const hit of hits) { + const flattened = hit._source ? flatten(hit._source) : {}; + const doc = { + _id: hit._id, + _type: hit._type, + _index: hit._index, + sort: hit.sort, + highlight: hit.highlight, + _source: { ...flattened }, + ...flattened, + }; + + for (const propName of Object.keys(doc)) { + if (propNames.indexOf(propName) === -1) { + propNames.push(propName); + } + } + + docs.push(doc); + } + + propNames.sort(); + return { docs, propNames }; +}; + +/** + * Create empty dataframe but with created fields. Fields are based from propNames (should be from the response) and + * also from configuration specified fields for message, time, and level. + * @param propNames + * @param timeField + * @param logMessageField + * @param logLevelField + */ +const createEmptyDataFrame = ( + propNames: string[], + timeField: string, + isLogsRequest: boolean, + logMessageField?: string, + logLevelField?: string +): MutableDataFrame => { + const series = new MutableDataFrame({ fields: [] }); + + series.addField({ + config: { + filterable: true, + }, + name: timeField, + type: FieldType.time, + }); + + if (logMessageField) { + series.addField({ + name: logMessageField, + type: FieldType.string, + }).parse = (v: any) => { + return v || ''; + }; + } + + if (logLevelField) { + series.addField({ + name: 'level', + type: FieldType.string, + }).parse = (v: any) => { + return v || ''; + }; + } + + const fieldNames = series.fields.map((field) => field.name); + + for (const propName of propNames) { + // Do not duplicate fields. This can mean that we will shadow some fields. + if (fieldNames.includes(propName)) { + continue; + } + // Do not add _source field (besides logs) as we are showing each _source field in table instead. + if (!isLogsRequest && propName === '_source') { + continue; + } + + series.addField({ + config: { + filterable: true, + }, + name: propName, + type: FieldType.string, + }).parse = (v: any) => { + return v || ''; + }; + } + + return series; +}; + +const addPreferredVisualisationType = (series: any, type: PreferredVisualisationType) => { + let s = series; + s.meta + ? (s.meta.preferredVisualisationType = type) + : (s.meta = { + preferredVisualisationType: type, + }); + + return s; +}; diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx new file mode 100644 index 0000000..83a74e2 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx @@ -0,0 +1,29 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ElasticsearchProvider } from '../components/QueryEditor/ElasticsearchQueryContext'; +import { useNextId } from './useNextId'; +import { ElasticsearchQuery } from '../types'; + +describe('useNextId', () => { + it('Should return the next available id', () => { + const query: ElasticsearchQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'avg' }], + bucketAggs: [{ id: '2', type: 'date_histogram' }], + }; + const wrapper: FunctionComponent = ({ children }) => { + return ( + {}} onRunQuery={() => {}}> + {children} + + ); + }; + + const { result } = renderHook(() => useNextId(), { + wrapper, + }); + + expect(result.current).toBe('3'); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts new file mode 100644 index 0000000..8d0d994 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useQuery } from '../components/QueryEditor/ElasticsearchQueryContext'; +import { BucketAggregation } from '../components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { MetricAggregation } from '../components/QueryEditor/MetricAggregationsEditor/aggregations'; + +const toId = (e: T): T['id'] => e.id; + +const toInt = (idString: string) => parseInt(idString, 10); + +export const useNextId = (): MetricAggregation['id'] | BucketAggregation['id'] => { + const { metrics, bucketAggs } = useQuery(); + + return useMemo( + () => + (Math.max(...[...(metrics?.map(toId) || ['0']), ...(bucketAggs?.map(toId) || ['0'])].map(toInt)) + 1).toString(), + [metrics, bucketAggs] + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx new file mode 100644 index 0000000..91e2cf7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx @@ -0,0 +1,69 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useStatelessReducer, useDispatch, DispatchContext, combineReducers } from './useStatelessReducer'; + +describe('useStatelessReducer Hook', () => { + it('When dispatch is called, it should call the provided reducer with the correct action and state', () => { + const action = { type: 'SOME ACTION' }; + const reducer = jest.fn(); + const state = { someProp: 'some state' }; + + const { result } = renderHook(() => useStatelessReducer(() => {}, state, reducer)); + + result.current(action); + + expect(reducer).toHaveBeenCalledWith(state, action); + }); + + it('When an action is dispatched, it should call the provided onChange callback with the result from the reducer', () => { + const action = { type: 'SOME ACTION' }; + const state = { propA: 'A', propB: 'B' }; + const expectedState = { ...state, propB: 'Changed' }; + const reducer = () => expectedState; + const onChange = jest.fn(); + + const { result } = renderHook(() => useStatelessReducer(onChange, state, reducer)); + + result.current(action); + + expect(onChange).toHaveBeenLastCalledWith(expectedState); + }); +}); + +describe('useDispatch Hook', () => { + it('Should throw when used outside of DispatchContext', () => { + const { result } = renderHook(() => useDispatch()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return a dispatch function', () => { + const dispatch = jest.fn(); + const wrapper: FunctionComponent = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useDispatch(), { + wrapper, + }); + + expect(result.current).toBe(dispatch); + }); +}); + +describe('combineReducers', () => { + it('Should correctly combine reducers', () => { + const reducerA = jest.fn(); + const reducerB = jest.fn(); + + const combinedReducer = combineReducers({ reducerA, reducerB }); + + const action = { type: 'SOME ACTION' }; + const initialState = { reducerA: 'A', reducerB: 'B' }; + + combinedReducer(initialState, action); + + expect(reducerA).toHaveBeenCalledWith(initialState.reducerA, action); + expect(reducerB).toHaveBeenCalledWith(initialState.reducerB, action); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts new file mode 100644 index 0000000..8a38cc3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts @@ -0,0 +1,45 @@ +import { createContext, useCallback, useContext } from 'react'; + +export interface Action { + type: T; +} + +export type Reducer = (state: S, action: A) => S; + +export const combineReducers = (reducers: { [P in keyof S]: Reducer }) => ( + state: S, + action: A +): Partial => { + const newState = {} as S; + for (const key in reducers) { + newState[key] = reducers[key](state[key], action); + } + return newState; +}; + +export const useStatelessReducer = ( + onChange: (value: State) => void, + state: State, + reducer: (state: State, action: A) => State +) => { + const dispatch = useCallback( + (action: A) => { + onChange(reducer(state, action)); + }, + [onChange, state, reducer] + ); + + return dispatch; +}; + +export const DispatchContext = createContext<((action: Action) => void) | undefined>(undefined); + +export const useDispatch = (): ((action: T) => void) => { + const dispatch = useContext(DispatchContext); + + if (!dispatch) { + throw new Error('Use DispatchContext first.'); + } + + return dispatch; +}; diff --git a/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg b/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg new file mode 100644 index 0000000..33a4d65 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/elasticsearch/index_pattern.ts b/public/app/plugins/datasource/elasticsearch/index_pattern.ts new file mode 100644 index 0000000..386ebd9 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/index_pattern.ts @@ -0,0 +1,60 @@ +import { toUtc, dateTime, DateTime, DurationUnit } from '@grafana/data'; +import { Interval } from './types'; + +type IntervalMap = Record< + Interval, + { + startOf: DurationUnit; + amount: DurationUnit; + } +>; + +const intervalMap: IntervalMap = { + Hourly: { startOf: 'hour', amount: 'hours' }, + Daily: { startOf: 'day', amount: 'days' }, + Weekly: { startOf: 'isoWeek', amount: 'weeks' }, + Monthly: { startOf: 'month', amount: 'months' }, + Yearly: { startOf: 'year', amount: 'years' }, +}; + +export class IndexPattern { + private dateLocale = 'en'; + + constructor(private pattern: string, private interval?: keyof typeof intervalMap) {} + + getIndexForToday() { + if (this.interval) { + return toUtc().locale(this.dateLocale).format(this.pattern); + } else { + return this.pattern; + } + } + + getIndexList(from?: DateTime, to?: DateTime) { + // When no `from` or `to` is provided, we request data from 7 subsequent/previous indices + // for the provided index pattern. + // This is useful when requesting log context where the only time data we have is the log + // timestamp. + const indexOffset = 7; + if (!this.interval) { + return this.pattern; + } + + const intervalInfo = intervalMap[this.interval]; + const start = dateTime(from || dateTime(to).add(-indexOffset, intervalInfo.amount)) + .utc() + .startOf(intervalInfo.startOf); + const endEpoch = dateTime(to || dateTime(from).add(indexOffset, intervalInfo.amount)) + .utc() + .startOf(intervalInfo.startOf) + .valueOf(); + const indexList = []; + + while (start.valueOf() <= endEpoch) { + indexList.push(start.locale(this.dateLocale).format(this.pattern)); + start.add(1, intervalInfo.amount); + } + + return indexList; + } +} diff --git a/public/app/plugins/datasource/elasticsearch/language_provider.test.ts b/public/app/plugins/datasource/elasticsearch/language_provider.test.ts new file mode 100644 index 0000000..c8b2c9f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/language_provider.test.ts @@ -0,0 +1,174 @@ +import LanguageProvider from './language_provider'; +import { PromQuery } from '../prometheus/types'; +import { ElasticDatasource } from './datasource'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { ElasticsearchOptions, ElasticsearchQuery } from './types'; +import { TemplateSrv } from '../../../features/templating/template_srv'; +import { defaultBucketAgg } from './query_def'; +import { DateHistogram } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; + +const templateSrvStub = { + getAdhocFilters: jest.fn(() => [] as any[]), + replace: jest.fn((a: string) => a), +} as any; + +const dataSource = new ElasticDatasource( + { + url: 'http://es.com', + database: '[asd-]YYYY.MM.DD', + jsonData: { + interval: 'Daily', + esVersion: 2, + timeField: '@time', + }, + } as DataSourceInstanceSettings, + templateSrvStub as TemplateSrv +); + +const baseLogsQuery: Partial = { + metrics: [{ type: 'logs', id: '1' }], + bucketAggs: [{ ...defaultBucketAgg('2'), field: dataSource.timeField } as DateHistogram], +}; + +describe('transform prometheus query to elasticsearch query', () => { + it('With exact equals labels ( 2 labels ) and metric __name__', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: 'my_metric{label1="value1",label2="value2"}' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '__name__:"my_metric" AND label1:"value1" AND label2:"value2"', + refId: promQuery.refId, + }, + ]); + }); + + it('With exact equals labels ( 1 labels ) and metric __name__', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: 'my_metric{label1="value1"}' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '__name__:"my_metric" AND label1:"value1"', + refId: promQuery.refId, + }, + ]); + }); + + it('With exact equals labels ( 1 labels )', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: '{label1="value1"}' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: 'label1:"value1"', + refId: promQuery.refId, + }, + ]); + }); + + it('With no label and metric __name__', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: 'my_metric{}' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '__name__:"my_metric"', + refId: promQuery.refId, + }, + ]); + }); + + it('With no label and metric __name__ without bracket', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: 'my_metric' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '__name__:"my_metric"', + refId: promQuery.refId, + }, + ]); + }); + + it('With rate function and exact equals labels ( 2 labels ) and metric __name__', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: 'rate(my_metric{label1="value1",label2="value2"}[5m])' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '__name__:"my_metric" AND label1:"value1" AND label2:"value2"', + refId: promQuery.refId, + }, + ]); + }); + + it('With rate function and exact equals labels not equals labels regex and not regex labels and metric __name__', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { + refId: 'bar', + expr: 'rate(my_metric{label1="value1",label2!="value2",label3=~"value.+",label4!~".*tothemoon"}[5m])', + }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: + '__name__:"my_metric" AND label1:"value1" AND NOT label2:"value2" AND label3:/value.+/ AND NOT label4:/.*tothemoon/', + refId: promQuery.refId, + }, + ]); + }); +}); + +describe('transform malformed prometheus query to elasticsearch query', () => { + it('With only bracket', () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: '{' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '', + refId: promQuery.refId, + }, + ]); + }); + + it('Empty query', async () => { + const instance = new LanguageProvider(dataSource); + const promQuery: PromQuery = { refId: 'bar', expr: '' }; + const result = instance.importQueries([promQuery], 'prometheus'); + + expect(result).toEqual([ + { + ...baseLogsQuery, + query: '', + refId: promQuery.refId, + }, + ]); + }); +}); + +describe('Unsupportated datasources', () => { + it('Generates a default query', async () => { + const instance = new LanguageProvider(dataSource); + const someQuery = { refId: 'bar' }; + const result = instance.importQueries([someQuery], 'THIS DATASOURCE TYPE DOESNT EXIST'); + expect(result).toEqual([{ refId: someQuery.refId }]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/language_provider.ts b/public/app/plugins/datasource/elasticsearch/language_provider.ts new file mode 100644 index 0000000..ea25957 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/language_provider.ts @@ -0,0 +1,137 @@ +import { ElasticsearchQuery } from './types'; +import { DataQuery, LanguageProvider } from '@grafana/data'; + +import { ElasticDatasource } from './datasource'; + +import { PromQuery } from '../prometheus/types'; + +import Prism, { Token } from 'prismjs'; +import grammar from '../prometheus/promql'; +import { defaultBucketAgg } from './query_def'; + +function getNameLabelValue(promQuery: string, tokens: any): string { + let nameLabelValue = ''; + for (let prop in tokens) { + if (typeof tokens[prop] === 'string') { + nameLabelValue = tokens[prop] as string; + break; + } + } + return nameLabelValue; +} + +function extractPrometheusLabels(promQuery: string): string[][] { + const labels: string[][] = []; + if (!promQuery || promQuery.length === 0) { + return labels; + } + const tokens = Prism.tokenize(promQuery, grammar); + const nameLabelValue = getNameLabelValue(promQuery, tokens); + if (nameLabelValue && nameLabelValue.length > 0) { + labels.push(['__name__', '=', '"' + nameLabelValue + '"']); + } + + for (let prop in tokens) { + if (tokens[prop] instanceof Token) { + let token: Token = tokens[prop] as Token; + if (token.type === 'context-labels') { + let labelKey = ''; + let labelValue = ''; + let labelOperator = ''; + let contentTokens: any[] = token.content as any[]; + for (let currentToken in contentTokens) { + if (typeof contentTokens[currentToken] === 'string') { + let currentStr: string; + currentStr = contentTokens[currentToken] as string; + if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') { + labelOperator = currentStr; + } + } else if (contentTokens[currentToken] instanceof Token) { + switch (contentTokens[currentToken].type) { + case 'label-key': + labelKey = contentTokens[currentToken].content as string; + break; + case 'label-value': + labelValue = contentTokens[currentToken].content as string; + labels.push([labelKey, labelOperator, labelValue]); + break; + } + } + } + } + } + } + return labels; +} + +function getElasticsearchQuery(prometheusLabels: string[][]): string { + let elasticsearchLuceneLabels = []; + for (let keyOperatorValue of prometheusLabels) { + switch (keyOperatorValue[1]) { + case '=': { + elasticsearchLuceneLabels.push(keyOperatorValue[0] + ':' + keyOperatorValue[2]); + break; + } + case '!=': { + elasticsearchLuceneLabels.push('NOT ' + keyOperatorValue[0] + ':' + keyOperatorValue[2]); + break; + } + case '=~': { + elasticsearchLuceneLabels.push( + keyOperatorValue[0] + ':/' + keyOperatorValue[2].substring(1, keyOperatorValue[2].length - 1) + '/' + ); + break; + } + case '!~': { + elasticsearchLuceneLabels.push( + 'NOT ' + keyOperatorValue[0] + ':/' + keyOperatorValue[2].substring(1, keyOperatorValue[2].length - 1) + '/' + ); + break; + } + } + } + return elasticsearchLuceneLabels.join(' AND '); +} + +export default class ElasticsearchLanguageProvider extends LanguageProvider { + request: (url: string, params?: any) => Promise; + start: () => Promise; + datasource: ElasticDatasource; + + constructor(datasource: ElasticDatasource, initialValues?: any) { + super(); + this.datasource = datasource; + + Object.assign(this, initialValues); + } + + /** + * The current implementation only supports switching from Prometheus/Loki queries. + * For them we transform the query to an ES Logs query since it's the behaviour most users expect. + * For every other datasource we just copy the refId and let the query editor initialize a default query. + * */ + importQueries(queries: DataQuery[], datasourceType: string): ElasticsearchQuery[] { + if (datasourceType === 'prometheus' || datasourceType === 'loki') { + return queries.map((query) => { + let prometheusQuery = query as PromQuery; + const expr = getElasticsearchQuery(extractPrometheusLabels(prometheusQuery.expr)); + return { + metrics: [ + { + id: '1', + type: 'logs', + }, + ], + bucketAggs: [{ ...defaultBucketAgg('2'), field: this.datasource.timeField }], + query: expr, + refId: query.refId, + }; + }); + } + return queries.map((query) => { + return { + refId: query.refId, + }; + }); + } +} diff --git a/public/app/plugins/datasource/elasticsearch/module.ts b/public/app/plugins/datasource/elasticsearch/module.ts new file mode 100644 index 0000000..1ac22e3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/module.ts @@ -0,0 +1,13 @@ +import { DataSourcePlugin } from '@grafana/data'; +import { ElasticDatasource } from './datasource'; +import { ConfigEditor } from './configuration/ConfigEditor'; +import { QueryEditor } from './components/QueryEditor'; + +class ElasticAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; +} + +export const plugin = new DataSourcePlugin(ElasticDatasource) + .setQueryEditor(QueryEditor) + .setConfigEditor(ConfigEditor) + .setAnnotationQueryCtrl(ElasticAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html new file mode 100644 index 0000000..d51077f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html @@ -0,0 +1,38 @@ +
    +
    + Index name + +
    +
    +
    + +
    +
    +
    + +
    +
    Field mappings
    +
    +
    + Time + +
    +
    + Time End + +
    +
    + Text + +
    +
    + Tags + +
    +
    + Title (deprecated) + +
    +
    +
    diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json new file mode 100644 index 0000000..5c1fa70 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -0,0 +1,29 @@ +{ + "type": "datasource", + "name": "Elasticsearch", + "id": "elasticsearch", + "category": "logging", + + "info": { + "description": "Open source logging & analytics database", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "keywords": ["elasticsearch"], + "logos": { + "small": "img/elasticsearch.svg", + "large": "img/elasticsearch.svg" + }, + "links": [{ "name": "Learn more", "url": "https://grafana.com/docs/features/datasources/elasticsearch/" }] + }, + + "alerting": true, + "annotations": true, + "metrics": true, + "logs": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.ts b/public/app/plugins/datasource/elasticsearch/query_builder.ts new file mode 100644 index 0000000..256359b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/query_builder.ts @@ -0,0 +1,454 @@ +import { + Filters, + Histogram, + DateHistogram, + Terms, +} from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + isMetricAggregationWithField, + isMetricAggregationWithSettings, + isPipelineAggregation, + isPipelineAggregationWithMultipleBucketPaths, + MetricAggregation, + MetricAggregationWithInlineScript, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { defaultBucketAgg, defaultMetricAgg, findMetricById, highlightTags } from './query_def'; +import { ElasticsearchQuery } from './types'; +import { convertOrderByToMetricId, getScriptValue } from './utils'; + +export class ElasticQueryBuilder { + timeField: string; + esVersion: number; + + constructor(options: { timeField: string; esVersion: number }) { + this.timeField = options.timeField; + this.esVersion = options.esVersion; + } + + getRangeFilter() { + const filter: any = {}; + filter[this.timeField] = { + gte: '$timeFrom', + lte: '$timeTo', + format: 'epoch_millis', + }; + + return filter; + } + + buildTermsAgg(aggDef: Terms, queryNode: { terms?: any; aggs?: any }, target: ElasticsearchQuery) { + queryNode.terms = { field: aggDef.field }; + + if (!aggDef.settings) { + return queryNode; + } + + // TODO: This default should be somewhere else together with the one used in the UI + const size = aggDef.settings?.size ? parseInt(aggDef.settings.size, 10) : 500; + queryNode.terms.size = size === 0 ? 500 : size; + + if (aggDef.settings.orderBy !== void 0) { + queryNode.terms.order = {}; + if (aggDef.settings.orderBy === '_term' && this.esVersion >= 60) { + queryNode.terms.order['_key'] = aggDef.settings.order; + } else { + queryNode.terms.order[aggDef.settings.orderBy] = aggDef.settings.order; + } + + // if metric ref, look it up and add it to this agg level + const metricId = convertOrderByToMetricId(aggDef.settings.orderBy); + if (metricId) { + for (let metric of target.metrics || []) { + if (metric.id === metricId) { + if (metric.type === 'count') { + queryNode.terms.order = { _count: aggDef.settings.order }; + } else if (isMetricAggregationWithField(metric)) { + queryNode.aggs = {}; + queryNode.aggs[metric.id] = { + [metric.type]: { field: metric.field }, + }; + } + break; + } + } + } + } + + if (aggDef.settings.min_doc_count !== void 0) { + queryNode.terms.min_doc_count = parseInt(aggDef.settings.min_doc_count, 10); + + if (isNaN(queryNode.terms.min_doc_count)) { + queryNode.terms.min_doc_count = aggDef.settings.min_doc_count; + } + } + + if (aggDef.settings.missing) { + queryNode.terms.missing = aggDef.settings.missing; + } + + return queryNode; + } + + getDateHistogramAgg(aggDef: DateHistogram) { + const esAgg: any = {}; + const settings = aggDef.settings || {}; + esAgg.interval = settings.interval; + esAgg.field = this.timeField; + esAgg.min_doc_count = settings.min_doc_count || 0; + esAgg.extended_bounds = { min: '$timeFrom', max: '$timeTo' }; + esAgg.format = 'epoch_millis'; + + if (settings.offset !== '') { + esAgg.offset = settings.offset; + } + + if (esAgg.interval === 'auto') { + esAgg.interval = '$__interval'; + } + + return esAgg; + } + + getHistogramAgg(aggDef: Histogram) { + const esAgg: any = {}; + const settings = aggDef.settings || {}; + esAgg.interval = settings.interval; + esAgg.field = aggDef.field; + esAgg.min_doc_count = settings.min_doc_count || 0; + + return esAgg; + } + + getFiltersAgg(aggDef: Filters) { + const filterObj: Record = {}; + + for (let { query, label } of aggDef.settings?.filters || []) { + filterObj[label || query] = { + query_string: { + query: query, + analyze_wildcard: true, + }, + }; + } + + return filterObj; + } + + documentQuery(query: any, size: number) { + query.size = size; + query.sort = [ + { + [this.timeField]: { order: 'desc', unmapped_type: 'boolean' }, + }, + { + _doc: { order: 'desc' }, + }, + ]; + + // fields field not supported on ES 5.x + if (this.esVersion < 5) { + query.fields = ['*', '_source']; + } + + query.script_fields = {}; + return query; + } + + addAdhocFilters(query: any, adhocFilters: any) { + if (!adhocFilters) { + return; + } + + let i, filter, condition: any, queryCondition: any; + + for (i = 0; i < adhocFilters.length; i++) { + filter = adhocFilters[i]; + condition = {}; + condition[filter.key] = filter.value; + queryCondition = {}; + queryCondition[filter.key] = { query: filter.value }; + + switch (filter.operator) { + case '=': + if (!query.query.bool.must) { + query.query.bool.must = []; + } + query.query.bool.must.push({ match_phrase: queryCondition }); + break; + case '!=': + if (!query.query.bool.must_not) { + query.query.bool.must_not = []; + } + query.query.bool.must_not.push({ match_phrase: queryCondition }); + break; + case '<': + condition[filter.key] = { lt: filter.value }; + query.query.bool.filter.push({ range: condition }); + break; + case '>': + condition[filter.key] = { gt: filter.value }; + query.query.bool.filter.push({ range: condition }); + break; + case '=~': + query.query.bool.filter.push({ regexp: condition }); + break; + case '!~': + query.query.bool.filter.push({ + bool: { must_not: { regexp: condition } }, + }); + break; + } + } + } + + build(target: ElasticsearchQuery, adhocFilters?: any, queryString?: string) { + // make sure query has defaults; + target.metrics = target.metrics || [defaultMetricAgg()]; + target.bucketAggs = target.bucketAggs || [defaultBucketAgg()]; + target.timeField = this.timeField; + let metric: MetricAggregation; + + let i, j, pv, nestedAggs; + const query = { + size: 0, + query: { + bool: { + filter: [ + { range: this.getRangeFilter() }, + { + query_string: { + analyze_wildcard: true, + query: queryString, + }, + }, + ], + }, + }, + }; + + this.addAdhocFilters(query, adhocFilters); + + // If target doesn't have bucketAggs and type is not raw_document, it is invalid query. + if (target.bucketAggs.length === 0) { + metric = target.metrics[0]; + + if (!metric || !(metric.type === 'raw_document' || metric.type === 'raw_data')) { + throw { message: 'Invalid query' }; + } + } + + /* Handle document query: + * Check if metric type is raw_document. If metric doesn't have size (or size is 0), update size to 500. + * Otherwise it will not be a valid query and error will be thrown. + */ + if (target.metrics?.[0]?.type === 'raw_document' || target.metrics?.[0]?.type === 'raw_data') { + metric = target.metrics[0]; + + // TODO: This default should be somewhere else together with the one used in the UI + const size = metric.settings?.size ? parseInt(metric.settings.size, 10) : 500; + + return this.documentQuery(query, size || 500); + } + + nestedAggs = query; + + for (i = 0; i < target.bucketAggs.length; i++) { + const aggDef = target.bucketAggs[i]; + const esAgg: any = {}; + + switch (aggDef.type) { + case 'date_histogram': { + esAgg['date_histogram'] = this.getDateHistogramAgg(aggDef); + break; + } + case 'histogram': { + esAgg['histogram'] = this.getHistogramAgg(aggDef); + break; + } + case 'filters': { + esAgg['filters'] = { filters: this.getFiltersAgg(aggDef) }; + break; + } + case 'terms': { + this.buildTermsAgg(aggDef, esAgg, target); + break; + } + case 'geohash_grid': { + esAgg['geohash_grid'] = { + field: aggDef.field, + precision: aggDef.settings?.precision, + }; + break; + } + } + + nestedAggs.aggs = nestedAggs.aggs || {}; + nestedAggs.aggs[aggDef.id] = esAgg; + nestedAggs = esAgg; + } + + nestedAggs.aggs = {}; + + for (i = 0; i < target.metrics.length; i++) { + metric = target.metrics[i]; + if (metric.type === 'count') { + continue; + } + + const aggField: any = {}; + let metricAgg: any = null; + + if (isPipelineAggregation(metric)) { + if (isPipelineAggregationWithMultipleBucketPaths(metric)) { + if (metric.pipelineVariables) { + metricAgg = { + buckets_path: {}, + }; + + for (j = 0; j < metric.pipelineVariables.length; j++) { + pv = metric.pipelineVariables[j]; + + if (pv.name && pv.pipelineAgg && /^\d*$/.test(pv.pipelineAgg)) { + const appliedAgg = findMetricById(target.metrics, pv.pipelineAgg); + if (appliedAgg) { + if (appliedAgg.type === 'count') { + metricAgg.buckets_path[pv.name] = '_count'; + } else { + metricAgg.buckets_path[pv.name] = pv.pipelineAgg; + } + } + } + } + } else { + continue; + } + } else { + if (metric.field && /^\d*$/.test(metric.field)) { + const appliedAgg = findMetricById(target.metrics, metric.field); + if (appliedAgg) { + if (appliedAgg.type === 'count') { + metricAgg = { buckets_path: '_count' }; + } else { + metricAgg = { buckets_path: metric.field }; + } + } + } else { + continue; + } + } + } else if (isMetricAggregationWithField(metric)) { + metricAgg = { field: metric.field }; + } + + if (isMetricAggregationWithSettings(metric)) { + Object.entries(metric.settings || {}) + .filter(([_, v]) => v !== null) + .forEach(([k, v]) => { + metricAgg[k] = k === 'script' ? getScriptValue(metric as MetricAggregationWithInlineScript) : v; + }); + } + + aggField[metric.type] = metricAgg; + nestedAggs.aggs[metric.id] = aggField; + } + + return query; + } + + getTermsQuery(queryDef: any) { + const query: any = { + size: 0, + query: { + bool: { + filter: [{ range: this.getRangeFilter() }], + }, + }, + }; + + if (queryDef.query) { + query.query.bool.filter.push({ + query_string: { + analyze_wildcard: true, + query: queryDef.query, + }, + }); + } + + let size = 500; + if (queryDef.size) { + size = queryDef.size; + } + + query.aggs = { + '1': { + terms: { + field: queryDef.field, + size: size, + order: {}, + }, + }, + }; + + // Default behaviour is to order results by { _key: asc } + // queryDef.order allows selection of asc/desc + // queryDef.orderBy allows selection of doc_count ordering (defaults desc) + + const { orderBy = 'key', order = orderBy === 'doc_count' ? 'desc' : 'asc' } = queryDef; + + if (['asc', 'desc'].indexOf(order) < 0) { + throw { message: `Invalid query sort order ${order}` }; + } + + switch (orderBy) { + case 'key': + case 'term': + const keyname = this.esVersion >= 60 ? '_key' : '_term'; + query.aggs['1'].terms.order[keyname] = order; + break; + case 'doc_count': + query.aggs['1'].terms.order['_count'] = order; + break; + default: + throw { message: `Invalid query sort type ${orderBy}` }; + } + + return query; + } + + getLogsQuery(target: ElasticsearchQuery, adhocFilters?: any, querystring?: string) { + let query: any = { + size: 0, + query: { + bool: { + filter: [{ range: this.getRangeFilter() }], + }, + }, + }; + + this.addAdhocFilters(query, adhocFilters); + + if (target.query) { + query.query.bool.filter.push({ + query_string: { + analyze_wildcard: true, + query: querystring, + }, + }); + } + + query = this.documentQuery(query, 500); + + return { + ...query, + aggs: this.build(target, null, querystring).aggs, + highlight: { + fields: { + '*': {}, + }, + pre_tags: [highlightTags.pre], + post_tags: [highlightTags.post], + fragment_size: 2147483647, + }, + }; + } +} diff --git a/public/app/plugins/datasource/elasticsearch/query_def.ts b/public/app/plugins/datasource/elasticsearch/query_def.ts new file mode 100644 index 0000000..6d7fb83 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/query_def.ts @@ -0,0 +1,58 @@ +import { BucketAggregation } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + ExtendedStat, + MetricAggregation, + MovingAverageModelOption, + MetricAggregationType, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { metricAggregationConfig, pipelineOptions } from './components/QueryEditor/MetricAggregationsEditor/utils'; +import { ElasticsearchQuery } from './types'; + +export const extendedStats: ExtendedStat[] = [ + { label: 'Avg', value: 'avg' }, + { label: 'Min', value: 'min' }, + { label: 'Max', value: 'max' }, + { label: 'Sum', value: 'sum' }, + { label: 'Count', value: 'count' }, + { label: 'Std Dev', value: 'std_deviation' }, + { label: 'Std Dev Upper', value: 'std_deviation_bounds_upper' }, + { label: 'Std Dev Lower', value: 'std_deviation_bounds_lower' }, +]; + +export const movingAvgModelOptions: MovingAverageModelOption[] = [ + { label: 'Simple', value: 'simple' }, + { label: 'Linear', value: 'linear' }, + { label: 'Exponentially Weighted', value: 'ewma' }, + { label: 'Holt Linear', value: 'holt' }, + { label: 'Holt Winters', value: 'holt_winters' }, +]; + +export const highlightTags = { + pre: '@HIGHLIGHT@', + post: '@/HIGHLIGHT@', +}; + +export function defaultMetricAgg(id = '1'): MetricAggregation { + return { type: 'count', id }; +} + +export function defaultBucketAgg(id = '1'): BucketAggregation { + return { type: 'date_histogram', id, settings: { interval: 'auto' } }; +} + +export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) => + metrics.find((metric) => metric.id === id); + +export function hasMetricOfType(target: ElasticsearchQuery, type: MetricAggregationType): boolean { + return !!target?.metrics?.some((m) => m.type === type); +} + +// Even if we have type guards when building a query, we currently have no way of getting this information from the response. +// We should try to find a better (type safe) way of doing the following 2. +export function isPipelineAgg(metricType: MetricAggregationType) { + return metricType in pipelineOptions; +} + +export function isPipelineAggWithMultipleBucketPaths(metricType: MetricAggregationType) { + return !!metricAggregationConfig[metricType].supportsMultipleBucketPaths; +} diff --git a/public/app/plugins/datasource/elasticsearch/query_help.md b/public/app/plugins/datasource/elasticsearch/query_help.md new file mode 100644 index 0000000..ba42059 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/query_help.md @@ -0,0 +1,10 @@ +#### Alias patterns +- {{term fieldname}} = replaced with value of term group by +- {{metric}} = replaced with metric name (ex. Average, Min, Max) +- {{field}} = replaced with the metric field name + +#### Documentation links + +[Grafana's Elasticsearch Documentation](http://docs.grafana.org/features/datasources/elasticsearch) + +[Official Elasticsearch Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) diff --git a/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts b/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts new file mode 100644 index 0000000..e518e18 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts @@ -0,0 +1,1348 @@ +import { DataFrameView, FieldCache, KeyValue, MutableDataFrame } from '@grafana/data'; +import { ElasticResponse } from '../elastic_response'; +import flatten from 'app/core/utils/flatten'; +import { ElasticsearchQuery } from '../types'; +import { highlightTags } from '../query_def'; + +describe('ElasticResponse', () => { + let targets: ElasticsearchQuery[]; + let response: any; + let result: any; + + describe('refId matching', () => { + // We default to the old table structure to ensure backward compatibility, + // therefore we only process responses as DataFrames when there's at least one + // raw_data (new) query type. + // We should test if refId gets populated wether there's such type of query or not + interface MockedQueryData { + target: ElasticsearchQuery; + response: any; + } + + const countQuery: MockedQueryData = { + target: { + refId: 'COUNT_GROUPBY_DATE_HISTOGRAM', + metrics: [{ type: 'count', id: 'c_1' }], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'c_2' }], + } as ElasticsearchQuery, + response: { + aggregations: { + c_2: { + buckets: [ + { + doc_count: 10, + key: 1000, + }, + ], + }, + }, + }, + }; + + const countGroupByHistogramQuery: MockedQueryData = { + target: { + refId: 'COUNT_GROUPBY_HISTOGRAM', + metrics: [{ type: 'count', id: 'h_3' }], + bucketAggs: [{ type: 'histogram', field: 'bytes', id: 'h_4' }], + }, + response: { + aggregations: { + h_4: { + buckets: [{ doc_count: 1, key: 1000 }], + }, + }, + }, + }; + + const rawDocumentQuery: MockedQueryData = { + target: { + refId: 'RAW_DOC', + metrics: [{ type: 'raw_document', id: 'r_5' }], + bucketAggs: [], + }, + response: { + hits: { + total: 2, + hits: [ + { + _id: '5', + _type: 'type', + _index: 'index', + _source: { sourceProp: 'asd' }, + fields: { fieldProp: 'field' }, + }, + { + _source: { sourceProp: 'asd2' }, + fields: { fieldProp: 'field2' }, + }, + ], + }, + }, + }; + + const percentilesQuery: MockedQueryData = { + target: { + refId: 'PERCENTILE', + metrics: [{ type: 'percentiles', settings: { percents: ['75', '90'] }, id: 'p_1' }], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'p_3' }], + }, + response: { + aggregations: { + p_3: { + buckets: [ + { + p_1: { values: { '75': 3.3, '90': 5.5 } }, + doc_count: 10, + key: 1000, + }, + { + p_1: { values: { '75': 2.3, '90': 4.5 } }, + doc_count: 15, + key: 2000, + }, + ], + }, + }, + }, + }; + + const extendedStatsQuery: MockedQueryData = { + target: { + refId: 'EXTENDEDSTATS', + metrics: [ + { + type: 'extended_stats', + meta: { max: true, std_deviation_bounds_upper: true }, + id: 'e_1', + }, + ], + bucketAggs: [ + { type: 'terms', field: 'host', id: 'e_3' }, + { type: 'date_histogram', id: 'e_4' }, + ], + }, + response: { + aggregations: { + e_3: { + buckets: [ + { + key: 'server1', + e_4: { + buckets: [ + { + e_1: { + max: 10.2, + min: 5.5, + std_deviation_bounds: { upper: 3, lower: -2 }, + }, + doc_count: 10, + key: 1000, + }, + ], + }, + }, + { + key: 'server2', + e_4: { + buckets: [ + { + e_1: { + max: 10.2, + min: 5.5, + std_deviation_bounds: { upper: 3, lower: -2 }, + }, + doc_count: 10, + key: 1000, + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const commonTargets = [ + { ...countQuery.target }, + { ...countGroupByHistogramQuery.target }, + { ...rawDocumentQuery.target }, + { ...percentilesQuery.target }, + { ...extendedStatsQuery.target }, + ]; + + const commonResponses = [ + { ...countQuery.response }, + { ...countGroupByHistogramQuery.response }, + { ...rawDocumentQuery.response }, + { ...percentilesQuery.response }, + { ...extendedStatsQuery.response }, + ]; + + describe('When processing responses as DataFrames (raw_data query present)', () => { + beforeEach(() => { + targets = [ + ...commonTargets, + // Raw Data Query + { + refId: 'D', + metrics: [{ type: 'raw_data', id: '6' }], + bucketAggs: [], + }, + ]; + + response = { + responses: [ + ...commonResponses, + // Raw Data Query + { + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: '6', + _type: '_doc', + _index: 'index', + _source: { sourceProp: 'asd' }, + }, + ], + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should add the correct refId to each returned series', () => { + expect(result.data[0].refId).toBe(countQuery.target.refId); + + expect(result.data[1].refId).toBe(countGroupByHistogramQuery.target.refId); + + expect(result.data[2].refId).toBe(rawDocumentQuery.target.refId); + + expect(result.data[3].refId).toBe(percentilesQuery.target.refId); + expect(result.data[4].refId).toBe(percentilesQuery.target.refId); + + expect(result.data[5].refId).toBe(extendedStatsQuery.target.refId); + + // Raw Data query + expect(result.data[result.data.length - 1].refId).toBe('D'); + }); + }); + + describe('When NOT processing responses as DataFrames (raw_data query NOT present)', () => { + beforeEach(() => { + targets = [...commonTargets]; + + response = { + responses: [...commonResponses], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should add the correct refId to each returned series', () => { + expect(result.data[0].refId).toBe(countQuery.target.refId); + + expect(result.data[1].refId).toBe(countGroupByHistogramQuery.target.refId); + + expect(result.data[2].refId).toBe(rawDocumentQuery.target.refId); + + expect(result.data[3].refId).toBe(percentilesQuery.target.refId); + expect(result.data[4].refId).toBe(percentilesQuery.target.refId); + + expect(result.data[5].refId).toBe(extendedStatsQuery.target.refId); + }); + }); + }); + + describe('simple query and count', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + doc_count: 10, + key: 1000, + }, + { + doc_count: 15, + key: 2000, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 1 series', () => { + expect(result.data.length).toBe(1); + expect(result.data[0].target).toBe('Count'); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].datapoints[0][0]).toBe(10); + expect(result.data[0].datapoints[0][1]).toBe(1000); + }); + }); + + describe('simple query count & avg aggregation', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { type: 'count', id: '1' }, + { type: 'avg', field: 'value', id: '2' }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '3': { + buckets: [ + { + '2': { value: 88 }, + doc_count: 10, + key: 1000, + }, + { + '2': { value: 99 }, + doc_count: 15, + key: 2000, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(2); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].datapoints[0][0]).toBe(10); + expect(result.data[0].datapoints[0][1]).toBe(1000); + + expect(result.data[1].target).toBe('Average value'); + expect(result.data[1].datapoints[0][0]).toBe(88); + expect(result.data[1].datapoints[1][0]).toBe(99); + }); + }); + + describe('single group by query one metric', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [ + { type: 'terms', field: 'host', id: '2' }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '3': { + buckets: [ + { doc_count: 1, key: 1000 }, + { doc_count: 3, key: 2000 }, + ], + }, + doc_count: 4, + key: 'server1', + }, + { + '3': { + buckets: [ + { doc_count: 2, key: 1000 }, + { doc_count: 8, key: 2000 }, + ], + }, + doc_count: 10, + key: 'server2', + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(2); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('server1'); + expect(result.data[1].target).toBe('server2'); + }); + }); + + describe('single group by query two metrics', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { type: 'count', id: '1' }, + { type: 'avg', field: '@value', id: '4' }, + ], + bucketAggs: [ + { type: 'terms', field: 'host', id: '2' }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '3': { + buckets: [ + { '4': { value: 10 }, doc_count: 1, key: 1000 }, + { '4': { value: 12 }, doc_count: 3, key: 2000 }, + ], + }, + doc_count: 4, + key: 'server1', + }, + { + '3': { + buckets: [ + { '4': { value: 20 }, doc_count: 1, key: 1000 }, + { '4': { value: 32 }, doc_count: 3, key: 2000 }, + ], + }, + doc_count: 10, + key: 'server2', + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(4); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('server1 Count'); + expect(result.data[1].target).toBe('server1 Average @value'); + expect(result.data[2].target).toBe('server2 Count'); + expect(result.data[3].target).toBe('server2 Average @value'); + }); + }); + + describe('with percentiles ', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'percentiles', settings: { percents: ['75', '90'] }, id: '1', field: '@value' }], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '3': { + buckets: [ + { + '1': { values: { '75': 3.3, '90': 5.5 } }, + doc_count: 10, + key: 1000, + }, + { + '1': { values: { '75': 2.3, '90': 4.5 } }, + doc_count: 15, + key: 2000, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(2); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('p75 @value'); + expect(result.data[1].target).toBe('p90 @value'); + expect(result.data[0].datapoints[0][0]).toBe(3.3); + expect(result.data[0].datapoints[0][1]).toBe(1000); + expect(result.data[1].datapoints[1][0]).toBe(4.5); + }); + }); + + describe('with extended_stats', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { + type: 'extended_stats', + meta: { max: true, std_deviation_bounds_upper: true }, + id: '1', + field: '@value', + }, + ], + bucketAggs: [ + { type: 'terms', field: 'host', id: '3' }, + { type: 'date_histogram', id: '4' }, + ], + }, + ]; + response = { + responses: [ + { + aggregations: { + '3': { + buckets: [ + { + key: 'server1', + '4': { + buckets: [ + { + '1': { + max: 10.2, + min: 5.5, + std_deviation_bounds: { upper: 3, lower: -2 }, + }, + doc_count: 10, + key: 1000, + }, + ], + }, + }, + { + key: 'server2', + '4': { + buckets: [ + { + '1': { + max: 10.2, + min: 5.5, + std_deviation_bounds: { upper: 3, lower: -2 }, + }, + doc_count: 10, + key: 1000, + }, + ], + }, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 4 series', () => { + expect(result.data.length).toBe(4); + expect(result.data[0].datapoints.length).toBe(1); + expect(result.data[0].target).toBe('server1 Max @value'); + expect(result.data[1].target).toBe('server1 Std Dev Upper @value'); + + expect(result.data[0].datapoints[0][0]).toBe(10.2); + expect(result.data[1].datapoints[0][0]).toBe(3); + }); + }); + + describe('single group by with alias pattern', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + alias: '{{term @host}} {{metric}} and {{not_exist}} {{@host}}', + bucketAggs: [ + { type: 'terms', field: '@host', id: '2' }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '3': { + buckets: [ + { doc_count: 1, key: 1000 }, + { doc_count: 3, key: 2000 }, + ], + }, + doc_count: 4, + key: 'server1', + }, + { + '3': { + buckets: [ + { doc_count: 2, key: 1000 }, + { doc_count: 8, key: 2000 }, + ], + }, + doc_count: 10, + key: 'server2', + }, + { + '3': { + buckets: [ + { doc_count: 2, key: 1000 }, + { doc_count: 8, key: 2000 }, + ], + }, + doc_count: 10, + key: 0, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(3); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('server1 Count and {{not_exist}} server1'); + expect(result.data[1].target).toBe('server2 Count and {{not_exist}} server2'); + expect(result.data[2].target).toBe('0 Count and {{not_exist}} 0'); + }); + }); + + describe('histogram response', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [{ type: 'histogram', field: 'bytes', id: '3' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '3': { + buckets: [ + { doc_count: 1, key: 1000 }, + { doc_count: 3, key: 2000 }, + { doc_count: 2, key: 1000 }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return table with byte and count', () => { + expect(result.data[0].rows.length).toBe(3); + expect(result.data[0].columns).toEqual([{ text: 'bytes', filterable: true }, { text: 'Count' }]); + }); + }); + + describe('with two filters agg', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [ + { + id: '2', + type: 'filters', + settings: { + filters: [ + { query: '@metric:cpu', label: '' }, + { query: '@metric:logins.count', label: '' }, + ], + }, + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: { + '@metric:cpu': { + '3': { + buckets: [ + { doc_count: 1, key: 1000 }, + { doc_count: 3, key: 2000 }, + ], + }, + }, + '@metric:logins.count': { + '3': { + buckets: [ + { doc_count: 2, key: 1000 }, + { doc_count: 8, key: 2000 }, + ], + }, + }, + }, + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 series', () => { + expect(result.data.length).toBe(2); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('@metric:cpu'); + expect(result.data[1].target).toBe('@metric:logins.count'); + }); + }); + + describe('with dropfirst and last aggregation', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { type: 'avg', id: '1', field: '@value' }, + { type: 'count', id: '3' }, + ], + bucketAggs: [ + { + id: '2', + type: 'date_histogram', + field: 'host', + settings: { trimEdges: '1' }, + }, + ], + }, + ]; + + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '1': { value: 1000 }, + key: 1, + doc_count: 369, + }, + { + '1': { value: 2000 }, + key: 2, + doc_count: 200, + }, + { + '1': { value: 2000 }, + key: 3, + doc_count: 200, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should remove first and last value', () => { + expect(result.data.length).toBe(2); + expect(result.data[0].datapoints.length).toBe(1); + }); + }); + + describe('No group by time', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { type: 'avg', id: '1', field: '@value' }, + { type: 'count', id: '3' }, + ], + bucketAggs: [{ id: '2', type: 'terms', field: 'host' }], + }, + ]; + + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '1': { value: 1000 }, + key: 'server-1', + doc_count: 369, + }, + { + '1': { value: 2000 }, + key: 'server-2', + doc_count: 200, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return table', () => { + expect(result.data.length).toBe(1); + expect(result.data[0].type).toBe('table'); + expect(result.data[0].rows.length).toBe(2); + expect(result.data[0].rows[0][0]).toBe('server-1'); + expect(result.data[0].rows[0][1]).toBe(1000); + expect(result.data[0].rows[0][2]).toBe(369); + + expect(result.data[0].rows[1][0]).toBe('server-2'); + expect(result.data[0].rows[1][1]).toBe(2000); + }); + }); + + describe('No group by time with percentiles ', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'percentiles', field: 'value', settings: { percents: ['75', '90'] }, id: '1' }], + bucketAggs: [{ type: 'terms', field: 'id', id: '3' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '3': { + buckets: [ + { + '1': { values: { '75': 3.3, '90': 5.5 } }, + doc_count: 10, + key: 'id1', + }, + { + '1': { values: { '75': 2.3, '90': 4.5 } }, + doc_count: 15, + key: 'id2', + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return table', () => { + expect(result.data.length).toBe(1); + expect(result.data[0].type).toBe('table'); + expect(result.data[0].columns[0].text).toBe('id'); + expect(result.data[0].columns[1].text).toBe('p75 value'); + expect(result.data[0].columns[2].text).toBe('p90 value'); + expect(result.data[0].rows.length).toBe(2); + expect(result.data[0].rows[0][0]).toBe('id1'); + expect(result.data[0].rows[0][1]).toBe(3.3); + expect(result.data[0].rows[0][2]).toBe(5.5); + expect(result.data[0].rows[1][0]).toBe('id2'); + expect(result.data[0].rows[1][1]).toBe(2.3); + expect(result.data[0].rows[1][2]).toBe(4.5); + }); + }); + + describe('Multiple metrics of same type', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { type: 'avg', id: '1', field: 'test' }, + { type: 'avg', id: '2', field: 'test2' }, + ], + bucketAggs: [{ id: '2', type: 'terms', field: 'host' }], + }, + ]; + + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + '1': { value: 1000 }, + '2': { value: 3000 }, + key: 'server-1', + doc_count: 369, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should include field in metric name', () => { + expect(result.data[0].type).toBe('table'); + expect(result.data[0].rows[0][1]).toBe(1000); + expect(result.data[0].rows[0][2]).toBe(3000); + }); + }); + + describe('Raw documents query', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'raw_document', id: '1' }], + bucketAggs: [], + }, + ]; + response = { + responses: [ + { + hits: { + total: 100, + hits: [ + { + _id: '1', + _type: 'type', + _index: 'index', + _source: { sourceProp: 'asd' }, + fields: { fieldProp: 'field' }, + }, + { + _source: { sourceProp: 'asd2' }, + fields: { fieldProp: 'field2' }, + }, + ], + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return docs', () => { + expect(result.data.length).toBe(1); + expect(result.data[0].type).toBe('docs'); + expect(result.data[0].total).toBe(100); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].datapoints[0].sourceProp).toBe('asd'); + expect(result.data[0].datapoints[0].fieldProp).toBe('field'); + }); + }); + + describe('with bucket_script ', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { id: '1', type: 'sum', field: '@value' }, + { id: '3', type: 'max', field: '@value' }, + { + id: '4', + pipelineVariables: [ + { name: 'var1', pipelineAgg: '1' }, + { name: 'var2', pipelineAgg: '3' }, + ], + settings: { script: 'params.var1 * params.var2' }, + type: 'bucket_script', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + 1: { value: 2 }, + 3: { value: 3 }, + 4: { value: 6 }, + doc_count: 60, + key: 1000, + }, + { + 1: { value: 3 }, + 3: { value: 4 }, + 4: { value: 12 }, + doc_count: 60, + key: 2000, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + it('should return 3 series', () => { + expect(result.data.length).toBe(3); + expect(result.data[0].datapoints.length).toBe(2); + expect(result.data[0].target).toBe('Sum @value'); + expect(result.data[1].target).toBe('Max @value'); + expect(result.data[2].target).toBe('Sum @value * Max @value'); + expect(result.data[0].datapoints[0][0]).toBe(2); + expect(result.data[1].datapoints[0][0]).toBe(3); + expect(result.data[2].datapoints[0][0]).toBe(6); + expect(result.data[0].datapoints[1][0]).toBe(3); + expect(result.data[1].datapoints[1][0]).toBe(4); + expect(result.data[2].datapoints[1][0]).toBe(12); + }); + }); + + describe('terms with bucket_script and two scripts', () => { + let result: any; + + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [ + { id: '1', type: 'sum', field: '@value' }, + { id: '3', type: 'max', field: '@value' }, + { + id: '4', + pipelineVariables: [ + { name: 'var1', pipelineAgg: '1' }, + { name: 'var2', pipelineAgg: '3' }, + ], + settings: { script: 'params.var1 * params.var2' }, + type: 'bucket_script', + }, + { + id: '5', + pipelineVariables: [ + { name: 'var1', pipelineAgg: '1' }, + { name: 'var2', pipelineAgg: '3' }, + ], + settings: { script: 'params.var1 * params.var2 * 4' }, + type: 'bucket_script', + }, + ], + bucketAggs: [{ type: 'terms', field: '@timestamp', id: '2' }], + }, + ]; + response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + 1: { value: 2 }, + 3: { value: 3 }, + 4: { value: 6 }, + 5: { value: 24 }, + doc_count: 60, + key: 1000, + }, + { + 1: { value: 3 }, + 3: { value: 4 }, + 4: { value: 12 }, + 5: { value: 48 }, + doc_count: 60, + key: 2000, + }, + ], + }, + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return 2 rows with 5 columns', () => { + expect(result.data[0].columns.length).toBe(5); + expect(result.data[0].rows.length).toBe(2); + expect(result.data[0].rows[0][1]).toBe(2); + expect(result.data[0].rows[0][2]).toBe(3); + expect(result.data[0].rows[0][3]).toBe(6); + expect(result.data[0].rows[0][4]).toBe(24); + expect(result.data[0].rows[1][1]).toBe(3); + expect(result.data[0].rows[1][2]).toBe(4); + expect(result.data[0].rows[1][3]).toBe(12); + expect(result.data[0].rows[1][4]).toBe(48); + }); + }); + + describe('Raw Data Query', () => { + beforeEach(() => { + targets = [ + { + refId: 'A', + metrics: [{ type: 'raw_data', id: '1' }], + bucketAggs: [], + }, + ]; + + response = { + responses: [ + { + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: '1', + _type: '_doc', + _index: 'index', + _source: { sourceProp: 'asd' }, + }, + ], + }, + }, + ], + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should create dataframes with filterable fields', () => { + for (const field of result.data[0].fields) { + expect(field.config.filterable).toBe(true); + } + }); + }); + + describe('simple logs query and count', () => { + const targets: ElasticsearchQuery[] = [ + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [{ type: 'date_histogram', settings: { interval: 'auto' }, id: '2' }], + key: 'Q-1561369883389-0.7611823271062786-0', + query: 'hello AND message', + timeField: '@timestamp', + }, + ]; + const response = { + responses: [ + { + aggregations: { + '2': { + buckets: [ + { + doc_count: 10, + key: 1000, + }, + { + doc_count: 15, + key: 2000, + }, + ], + }, + }, + hits: { + hits: [ + { + _id: 'fdsfs', + _type: '_doc', + _index: 'mock-index', + _source: { + '@timestamp': '2019-06-24T09:51:19.765Z', + host: 'djisaodjsoad', + message: 'hello, i am a message', + level: 'debug', + fields: { + lvl: 'debug', + }, + }, + highlight: { + message: [ + `${highlightTags.pre}hello${highlightTags.post}, i am a ${highlightTags.pre}message${highlightTags.post}`, + ], + }, + }, + { + _id: 'kdospaidopa', + _type: '_doc', + _index: 'mock-index', + _source: { + '@timestamp': '2019-06-24T09:52:19.765Z', + host: 'dsalkdakdop', + message: 'hello, i am also message', + level: 'error', + fields: { + lvl: 'info', + }, + }, + highlight: { + message: [ + `${highlightTags.pre}hello${highlightTags.post}, i am a ${highlightTags.pre}message${highlightTags.post}`, + ], + }, + }, + ], + }, + }, + ], + }; + + it('should return histogram aggregation and documents', () => { + const result = new ElasticResponse(targets, response).getLogs(); + expect(result.data.length).toBe(2); + const logResults = result.data[0] as MutableDataFrame; + expect(logResults).toHaveProperty('meta'); + expect(logResults.meta).toEqual({ + searchWords: ['hello', 'message'], + preferredVisualisationType: 'logs', + }); + + const fields = logResults.fields.map((f) => { + return { + name: f.name, + type: f.type, + }; + }); + + expect(fields).toContainEqual({ name: '@timestamp', type: 'time' }); + expect(fields).toContainEqual({ name: 'host', type: 'string' }); + expect(fields).toContainEqual({ name: 'message', type: 'string' }); + + let rows = new DataFrameView(logResults); + for (let i = 0; i < rows.length; i++) { + const r = rows.get(i); + expect(r._id).toEqual(response.responses[0].hits.hits[i]._id); + expect(r._type).toEqual(response.responses[0].hits.hits[i]._type); + expect(r._index).toEqual(response.responses[0].hits.hits[i]._index); + expect(r._source).toEqual( + flatten( + response.responses[0].hits.hits[i]._source, + (null as unknown) as { delimiter?: any; maxDepth?: any; safe?: any } + ) + ); + } + + // Make a map from the histogram results + const hist: KeyValue = {}; + const histogramResults = new MutableDataFrame(result.data[1]); + rows = new DataFrameView(histogramResults); + + for (let i = 0; i < rows.length; i++) { + const row = rows.get(i); + hist[row.Time] = row.Value; + } + + response.responses[0].aggregations['2'].buckets.forEach((bucket: any) => { + expect(hist[bucket.key]).toEqual(bucket.doc_count); + }); + }); + + it('should map levels field', () => { + const result = new ElasticResponse(targets, response).getLogs(undefined, 'level'); + const fieldCache = new FieldCache(result.data[0]); + const field = fieldCache.getFieldByName('level'); + expect(field?.values.toArray()).toEqual(['debug', 'error']); + }); + + it('should re map levels field to new field', () => { + const result = new ElasticResponse(targets, response).getLogs(undefined, 'fields.lvl'); + const fieldCache = new FieldCache(result.data[0]); + const field = fieldCache.getFieldByName('level'); + expect(field?.values.toArray()).toEqual(['debug', 'info']); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/specs/index_pattern.test.ts b/public/app/plugins/datasource/elasticsearch/specs/index_pattern.test.ts new file mode 100644 index 0000000..76a9679 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/specs/index_pattern.test.ts @@ -0,0 +1,111 @@ +/// + +import { IndexPattern } from '../index_pattern'; +import { toUtc, getLocale, setLocale, dateTime } from '@grafana/data'; + +describe('IndexPattern', () => { + const originalLocale = getLocale(); + afterEach(() => setLocale(originalLocale)); + + describe('when getting index for today', () => { + test('should return correct index name', () => { + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const expected = 'asd-' + toUtc().format('YYYY.MM.DD'); + + expect(pattern.getIndexForToday()).toBe(expected); + }); + + test('should format date using western arabic numerals regardless of locale', () => { + setLocale('ar_SA'); // saudi-arabic, formatting for YYYY.MM.DD looks like "٢٠٢٠.٠٩.٠٣" + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const expected = 'asd-' + toUtc().locale('en').format('YYYY.MM.DD'); + expect(pattern.getIndexForToday()).toBe(expected); + }); + }); + + describe('when getting index list for time range', () => { + describe('no interval', () => { + test('should return correct index', () => { + const pattern = new IndexPattern('my-metrics'); + const from = dateTime(new Date(2015, 4, 30, 1, 2, 3)); + const to = dateTime(new Date(2015, 5, 1, 12, 5, 6)); + expect(pattern.getIndexList(from, to)).toEqual('my-metrics'); + }); + }); + + describe('daily', () => { + test('should return correct index list', () => { + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const from = dateTime(new Date(1432940523000)); + const to = dateTime(new Date(1433153106000)); + + const expected = ['asd-2015.05.29', 'asd-2015.05.30', 'asd-2015.05.31', 'asd-2015.06.01']; + + expect(pattern.getIndexList(from, to)).toEqual(expected); + }); + + test('should format date using western arabic numerals regardless of locale', () => { + setLocale('ar_SA'); // saudi-arabic, formatting for YYYY.MM.DD looks like "٢٠٢٠.٠٩.٠٣" + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const from = dateTime(new Date(1432940523000)); + const to = dateTime(new Date(1433153106000)); + + const expected = ['asd-2015.05.29', 'asd-2015.05.30', 'asd-2015.05.31', 'asd-2015.06.01']; + + expect(pattern.getIndexList(from, to)).toEqual(expected); + }); + }); + + describe('weekly', () => { + it('should return correct index list', () => { + const pattern = new IndexPattern('[asd-]YYYY.WW', 'Weekly'); + // Sunday, February 21, 2021 1:00:00 AM + const from = dateTime(new Date(1613869200000)); + // Friday, March 5, 2021 1:00:00 AM + const to = dateTime(new Date(1614906000000)); + + const expected = ['asd-2021.07', 'asd-2021.08', 'asd-2021.09']; + + expect(pattern.getIndexList(from, to)).toEqual(expected); + }); + }); + }); + + describe('when getting index list from single date', () => { + it('Should return index matching the starting time and subsequent ones', () => { + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const from = dateTime(new Date(1432940523000)); + + const expected = [ + 'asd-2015.05.29', + 'asd-2015.05.30', + 'asd-2015.05.31', + 'asd-2015.06.01', + 'asd-2015.06.02', + 'asd-2015.06.03', + 'asd-2015.06.04', + 'asd-2015.06.05', + ]; + + expect(pattern.getIndexList(from)).toEqual(expected); + }); + + it('Should return index matching the starting time and previous ones', () => { + const pattern = new IndexPattern('[asd-]YYYY.MM.DD', 'Daily'); + const to = dateTime(new Date(1432940523000)); + + const expected = [ + 'asd-2015.05.22', + 'asd-2015.05.23', + 'asd-2015.05.24', + 'asd-2015.05.25', + 'asd-2015.05.26', + 'asd-2015.05.27', + 'asd-2015.05.28', + 'asd-2015.05.29', + ]; + + expect(pattern.getIndexList(undefined, to)).toEqual(expected); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts new file mode 100644 index 0000000..6f10375 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts @@ -0,0 +1,752 @@ +import { ElasticQueryBuilder } from '../query_builder'; +import { ElasticsearchQuery } from '../types'; + +describe('ElasticQueryBuilder', () => { + const builder = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 2 }); + const builder5x = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 5 }); + const builder56 = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 56 }); + const builder6x = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 60 }); + const builder7x = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 70 }); + + const allBuilders = [builder, builder5x, builder56, builder6x, builder7x]; + + allBuilders.forEach((builder) => { + describe(`version ${builder.esVersion}`, () => { + it('should return query with defaults', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ type: 'count', id: '0' }], + timeField: '@timestamp', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], + }); + + expect(query.query.bool.filter[0].range['@timestamp'].gte).toBe('$timeFrom'); + expect(query.aggs['1'].date_histogram.extended_bounds.min).toBe('$timeFrom'); + }); + + it('should clean settings from null values', () => { + const query = builder.build({ + refId: 'A', + // The following `missing: null as any` is because previous versions of the DS where + // storing null in the query model when inputting an empty string, + // which were then removed in the query builder. + // The new version doesn't store empty strings at all. This tests ensures backward compatinility. + metrics: [{ type: 'avg', id: '0', settings: { missing: null as any, script: '1' } }], + timeField: '@timestamp', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], + }); + + expect(query.aggs['1'].aggs['0'].avg.missing).not.toBeDefined(); + expect(query.aggs['1'].aggs['0'].avg.script).toBeDefined(); + }); + + it('with multiple bucket aggs', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + timeField: '@timestamp', + bucketAggs: [ + { type: 'terms', field: '@host', id: '2' }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }); + + expect(query.aggs['2'].terms.field).toBe('@host'); + expect(query.aggs['2'].aggs['3'].date_histogram.field).toBe('@timestamp'); + }); + + it('with select field', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'avg', field: '@value', id: '1' }], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + }, + 100, + '1000' + ); + + const aggs = query.aggs['2'].aggs; + expect(aggs['1'].avg.field).toBe('@value'); + }); + + it('term agg and order by term', () => { + const target: ElasticsearchQuery = { + refId: 'A', + metrics: [ + { type: 'count', id: '1' }, + { type: 'avg', field: '@value', id: '5' }, + ], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { size: '5', order: 'asc', orderBy: '_term' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }; + + const query = builder.build(target, 100, '1000'); + const firstLevel = query.aggs['2']; + + if (builder.esVersion >= 60) { + expect(firstLevel.terms.order._key).toBe('asc'); + } else { + expect(firstLevel.terms.order._term).toBe('asc'); + } + }); + + it('with term agg and order by metric agg', () => { + const query = builder.build( + { + refId: 'A', + metrics: [ + { type: 'count', id: '1' }, + { type: 'avg', field: '@value', id: '5' }, + ], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { size: '5', order: 'asc', orderBy: '5' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['2']; + const secondLevel = firstLevel.aggs['3']; + + expect(firstLevel.aggs['5'].avg.field).toBe('@value'); + expect(secondLevel.aggs['5'].avg.field).toBe('@value'); + }); + + it('with term agg and order by count agg', () => { + const query = builder.build( + { + refId: 'A', + metrics: [ + { type: 'count', id: '1' }, + { type: 'avg', field: '@value', id: '5' }, + ], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { size: '5', order: 'asc', orderBy: '1' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + expect(query.aggs['2'].terms.order._count).toEqual('asc'); + expect(query.aggs['2'].aggs).not.toHaveProperty('1'); + }); + + it('with term agg and order by extended_stats agg', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'extended_stats', id: '1', field: '@value', meta: { std_deviation: true } }], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { size: '5', order: 'asc', orderBy: '1[std_deviation]' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['2']; + const secondLevel = firstLevel.aggs['3']; + + expect(firstLevel.aggs['1'].extended_stats.field).toBe('@value'); + expect(secondLevel.aggs['1'].extended_stats.field).toBe('@value'); + }); + + it('with term agg and order by percentiles agg', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'percentiles', id: '1', field: '@value', settings: { percents: ['95', '99'] } }], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { size: '5', order: 'asc', orderBy: '1[95.0]' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['2']; + const secondLevel = firstLevel.aggs['3']; + + expect(firstLevel.aggs['1'].percentiles.field).toBe('@value'); + expect(secondLevel.aggs['1'].percentiles.field).toBe('@value'); + }); + + it('with term agg and valid min_doc_count', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { min_doc_count: '1' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['2']; + expect(firstLevel.terms.min_doc_count).toBe(1); + }); + + it('with term agg and variable as min_doc_count', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + bucketAggs: [ + { + type: 'terms', + field: '@host', + settings: { min_doc_count: '$min_doc_count' }, + id: '2', + }, + { type: 'date_histogram', field: '@timestamp', id: '3' }, + ], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['2']; + expect(firstLevel.terms.min_doc_count).toBe('$min_doc_count'); + }); + + it('with metric percentiles', () => { + const percents = ['1', '2', '3', '4']; + const field = '@load_time'; + + const query = builder.build( + { + refId: 'A', + metrics: [ + { + id: '1', + type: 'percentiles', + field, + settings: { + percents, + }, + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }, + 100, + '1000' + ); + + const firstLevel = query.aggs['3']; + + expect(firstLevel.aggs['1'].percentiles.field).toBe(field); + expect(firstLevel.aggs['1'].percentiles.percents).toEqual(percents); + }); + + it('with filters aggs', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ type: 'count', id: '1' }], + timeField: '@timestamp', + bucketAggs: [ + { + id: '2', + type: 'filters', + settings: { + filters: [ + { query: '@metric:cpu', label: '' }, + { query: '@metric:logins.count', label: '' }, + ], + }, + }, + { type: 'date_histogram', field: '@timestamp', id: '4' }, + ], + }); + + expect(query.aggs['2'].filters.filters['@metric:cpu'].query_string.query).toBe('@metric:cpu'); + expect(query.aggs['2'].filters.filters['@metric:logins.count'].query_string.query).toBe('@metric:logins.count'); + expect(query.aggs['2'].aggs['4'].date_histogram.field).toBe('@timestamp'); + }); + + it('should return correct query for raw_document metric', () => { + const target: ElasticsearchQuery = { + refId: 'A', + metrics: [{ type: 'raw_document', id: '1', settings: {} }], + timeField: '@timestamp', + bucketAggs: [] as any[], + }; + + const query = builder.build(target); + expect(query).toMatchObject({ + size: 500, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + format: 'epoch_millis', + gte: '$timeFrom', + lte: '$timeTo', + }, + }, + }, + { + query_string: { + analyze_wildcard: true, + query: undefined, + }, + }, + ], + }, + }, + sort: [{ '@timestamp': { order: 'desc', unmapped_type: 'boolean' } }, { _doc: { order: 'desc' } }], + script_fields: {}, + }); + }); + + it('should set query size from settings when raw_documents', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ type: 'raw_document', id: '1', settings: { size: '1337' } }], + timeField: '@timestamp', + bucketAggs: [], + }); + + expect(query.size).toBe(1337); + }); + + it('with moving average', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'sum', + field: '@value', + }, + { + id: '2', + type: 'moving_avg', + field: '3', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }); + + const firstLevel = query.aggs['3']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg.buckets_path).toBe('3'); + }); + + it('with moving average doc count', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'count', + }, + { + id: '2', + type: 'moving_avg', + field: '3', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }], + }); + + const firstLevel = query.aggs['4']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg.buckets_path).toBe('_count'); + }); + + it('with broken moving average', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'sum', + field: '@value', + }, + { + id: '2', + type: 'moving_avg', + field: '3', + }, + { + id: '4', + type: 'moving_avg', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }); + + const firstLevel = query.aggs['3']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg).not.toBe(undefined); + expect(firstLevel.aggs['2'].moving_avg.buckets_path).toBe('3'); + expect(firstLevel.aggs['4']).toBe(undefined); + }); + + it('with derivative', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'sum', + field: '@value', + }, + { + id: '2', + type: 'derivative', + field: '3', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }); + + const firstLevel = query.aggs['3']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].derivative).not.toBe(undefined); + expect(firstLevel.aggs['2'].derivative.buckets_path).toBe('3'); + }); + + it('with derivative doc count', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'count', + }, + { + id: '2', + type: 'derivative', + field: '3', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }], + }); + + const firstLevel = query.aggs['4']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].derivative).not.toBe(undefined); + expect(firstLevel.aggs['2'].derivative.buckets_path).toBe('_count'); + }); + + it('with serial_diff', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'max', + field: '@value', + }, + { + id: '2', + type: 'serial_diff', + field: '3', + settings: { + lag: 5, + }, + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }); + + const firstLevel = query.aggs['3']; + + expect(firstLevel.aggs['2']).not.toBe(undefined); + expect(firstLevel.aggs['2'].serial_diff).not.toBe(undefined); + expect(firstLevel.aggs['2'].serial_diff.buckets_path).toBe('3'); + expect(firstLevel.aggs['2'].serial_diff.lag).toBe(5); + }); + + it('with bucket_script', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '1', + type: 'sum', + field: '@value', + }, + { + id: '3', + type: 'max', + field: '@value', + }, + { + id: '4', + pipelineVariables: [ + { + name: 'var1', + pipelineAgg: '1', + }, + { + name: 'var2', + pipelineAgg: '3', + }, + ], + settings: { + script: 'params.var1 * params.var2', + }, + type: 'bucket_script', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + }); + + const firstLevel = query.aggs['2']; + + expect(firstLevel.aggs['4']).not.toBe(undefined); + expect(firstLevel.aggs['4'].bucket_script).not.toBe(undefined); + expect(firstLevel.aggs['4'].bucket_script.buckets_path).toMatchObject({ var1: '1', var2: '3' }); + }); + + it('with bucket_script doc count', () => { + const query = builder.build({ + refId: 'A', + metrics: [ + { + id: '3', + type: 'count', + }, + { + id: '4', + pipelineVariables: [ + { + name: 'var1', + pipelineAgg: '3', + }, + ], + settings: { + script: 'params.var1 * 1000', + }, + type: 'bucket_script', + }, + ], + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], + }); + + const firstLevel = query.aggs['2']; + + expect(firstLevel.aggs['4']).not.toBe(undefined); + expect(firstLevel.aggs['4'].bucket_script).not.toBe(undefined); + expect(firstLevel.aggs['4'].bucket_script.buckets_path).toMatchObject({ var1: '_count' }); + }); + + it('with histogram', () => { + const query = builder.build({ + refId: 'A', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [ + { + type: 'histogram', + field: 'bytes', + id: '3', + settings: { + interval: '10', + min_doc_count: '2', + }, + }, + ], + }); + + const firstLevel = query.aggs['3']; + expect(firstLevel.histogram.field).toBe('bytes'); + expect(firstLevel.histogram.interval).toBe('10'); + expect(firstLevel.histogram.min_doc_count).toBe('2'); + }); + + it('with adhoc filters', () => { + const query = builder.build( + { + refId: 'A', + metrics: [{ type: 'count', id: '0' }], + timeField: '@timestamp', + bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], + }, + [ + { key: 'key1', operator: '=', value: 'value1' }, + { key: 'key2', operator: '=', value: 'value2' }, + { key: 'key2', operator: '!=', value: 'value2' }, + { key: 'key3', operator: '<', value: 'value3' }, + { key: 'key4', operator: '>', value: 'value4' }, + { key: 'key5', operator: '=~', value: 'value5' }, + { key: 'key6', operator: '!~', value: 'value6' }, + ] + ); + + expect(query.query.bool.must[0].match_phrase['key1'].query).toBe('value1'); + expect(query.query.bool.must[1].match_phrase['key2'].query).toBe('value2'); + expect(query.query.bool.must_not[0].match_phrase['key2'].query).toBe('value2'); + expect(query.query.bool.filter[2].range['key3'].lt).toBe('value3'); + expect(query.query.bool.filter[3].range['key4'].gt).toBe('value4'); + expect(query.query.bool.filter[4].regexp['key5']).toBe('value5'); + expect(query.query.bool.filter[5].bool.must_not.regexp['key6']).toBe('value6'); + }); + + describe('getTermsQuery', () => { + function testGetTermsQuery(queryDef: any) { + const query = builder.getTermsQuery(queryDef); + return query.aggs['1'].terms.order; + } + + function checkSort(order: any, expected: string) { + if (builder.esVersion < 60) { + expect(order._term).toBe(expected); + expect(order._key).toBeUndefined(); + } else { + expect(order._term).toBeUndefined(); + expect(order._key).toBe(expected); + } + } + + it('should set correct default sorting', () => { + const order = testGetTermsQuery({}); + checkSort(order, 'asc'); + expect(order._count).toBeUndefined(); + }); + + it('should set correct explicit sorting', () => { + const order = testGetTermsQuery({ order: 'desc' }); + checkSort(order, 'desc'); + expect(order._count).toBeUndefined(); + }); + + it('getTermsQuery(orderBy:doc_count) should set desc sorting on _count', () => { + const query = builder.getTermsQuery({ orderBy: 'doc_count' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('desc'); + }); + + it('getTermsQuery(orderBy:doc_count, order:asc) should set asc sorting on _count', () => { + const query = builder.getTermsQuery({ orderBy: 'doc_count', order: 'asc' }); + expect(query.aggs['1'].terms.order._term).toBeUndefined(); + expect(query.aggs['1'].terms.order._key).toBeUndefined(); + expect(query.aggs['1'].terms.order._count).toBe('asc'); + }); + }); + + describe('getLogsQuery', () => { + it('should return query with defaults', () => { + const query = builder.getLogsQuery({ refId: 'A' }, null, '*'); + + expect(query.size).toEqual(500); + + const expectedQuery = { + bool: { + filter: [{ range: { '@timestamp': { gte: '$timeFrom', lte: '$timeTo', format: 'epoch_millis' } } }], + }, + }; + expect(query.query).toEqual(expectedQuery); + + expect(query.sort).toEqual([ + { '@timestamp': { order: 'desc', unmapped_type: 'boolean' } }, + { _doc: { order: 'desc' } }, + ]); + + const expectedAggs = { + // FIXME: It's pretty weak to include this '1' in the test as it's not part of what we are testing here and + // might change as a cause of unrelated changes + 1: { + aggs: {}, + date_histogram: { + extended_bounds: { max: '$timeTo', min: '$timeFrom' }, + field: '@timestamp', + format: 'epoch_millis', + interval: '$__interval', + min_doc_count: 0, + }, + }, + }; + expect(query.aggs).toMatchObject(expectedAggs); + }); + + it('with querystring', () => { + const query = builder.getLogsQuery({ refId: 'A', query: 'foo' }, null, 'foo'); + + const expectedQuery = { + bool: { + filter: [ + { range: { '@timestamp': { gte: '$timeFrom', lte: '$timeTo', format: 'epoch_millis' } } }, + { query_string: { analyze_wildcard: true, query: 'foo' } }, + ], + }, + }; + expect(query.query).toEqual(expectedQuery); + }); + + it('with adhoc filters', () => { + // TODO: Types for AdHocFilters + const adhocFilters = [ + { key: 'key1', operator: '=', value: 'value1' }, + { key: 'key2', operator: '!=', value: 'value2' }, + { key: 'key3', operator: '<', value: 'value3' }, + { key: 'key4', operator: '>', value: 'value4' }, + { key: 'key5', operator: '=~', value: 'value5' }, + { key: 'key6', operator: '!~', value: 'value6' }, + ]; + const query = builder.getLogsQuery({ refId: 'A' }, adhocFilters, '*'); + + expect(query.query.bool.must[0].match_phrase['key1'].query).toBe('value1'); + expect(query.query.bool.must_not[0].match_phrase['key2'].query).toBe('value2'); + expect(query.query.bool.filter[1].range['key3'].lt).toBe('value3'); + expect(query.query.bool.filter[2].range['key4'].gt).toBe('value4'); + expect(query.query.bool.filter[3].regexp['key5']).toBe('value5'); + expect(query.query.bool.filter[4].bool.must_not.regexp['key6']).toBe('value6'); + }); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts b/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts new file mode 100644 index 0000000..ac77d82 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts @@ -0,0 +1,39 @@ +import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from '../query_def'; + +describe('ElasticQueryDef', () => { + describe('isPipelineMetric', () => { + describe('moving_avg', () => { + const result = isPipelineAgg('moving_avg'); + + test('is pipe line metric', () => { + expect(result).toBe(true); + }); + }); + + describe('count', () => { + const result = isPipelineAgg('count'); + + test('is not pipe line metric', () => { + expect(result).toBe(false); + }); + }); + }); + + describe('isPipelineAggWithMultipleBucketPaths', () => { + describe('bucket_script', () => { + const result = isPipelineAggWithMultipleBucketPaths('bucket_script'); + + test('should have multiple bucket paths support', () => { + expect(result).toBe(true); + }); + }); + + describe('moving_avg', () => { + const result = isPipelineAggWithMultipleBucketPaths('moving_avg'); + + test('should not have multiple bucket paths support', () => { + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts new file mode 100644 index 0000000..6f9b633 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -0,0 +1,73 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import { + BucketAggregation, + BucketAggregationType, +} from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + MetricAggregation, + MetricAggregationType, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; + +export type Interval = 'Hourly' | 'Daily' | 'Weekly' | 'Monthly' | 'Yearly'; + +export interface ElasticsearchOptions extends DataSourceJsonData { + timeField: string; + esVersion: number; + interval?: Interval; + timeInterval: string; + maxConcurrentShardRequests?: number; + logMessageField?: string; + logLevelField?: string; + dataLinks?: DataLinkConfig[]; +} + +interface MetricConfiguration { + label: string; + requiresField: boolean; + supportsInlineScript: boolean; + supportsMissing: boolean; + isPipelineAgg: boolean; + minVersion?: number; + maxVersion?: number; + supportsMultipleBucketPaths: boolean; + isSingleMetric?: boolean; + hasSettings: boolean; + hasMeta: boolean; + defaults: Omit, 'id' | 'type'>; +} + +type BucketConfiguration = { + label: string; + requiresField: boolean; + defaultSettings: Extract['settings']; +}; + +export type MetricsConfiguration = { + [P in MetricAggregationType]: MetricConfiguration

    ; +}; + +export type BucketsConfiguration = { + [P in BucketAggregationType]: BucketConfiguration

    ; +}; + +export interface ElasticsearchAggregation { + id: string; + type: MetricAggregationType | BucketAggregationType; + settings?: unknown; + field?: string; + hide: boolean; +} + +export interface ElasticsearchQuery extends DataQuery { + alias?: string; + query?: string; + bucketAggs?: BucketAggregation[]; + metrics?: MetricAggregation[]; + timeField?: string; +} + +export type DataLinkConfig = { + field: string; + url: string; + datasourceUid?: string; +}; diff --git a/public/app/plugins/datasource/elasticsearch/utils.test.ts b/public/app/plugins/datasource/elasticsearch/utils.test.ts new file mode 100644 index 0000000..22f396b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/utils.test.ts @@ -0,0 +1,36 @@ +import { removeEmpty } from './utils'; + +describe('removeEmpty', () => { + it('Should remove all empty', () => { + const original = { + stringsShouldBeKept: 'Something', + unlessTheyAreEmpty: '', + nullToBeRemoved: null, + undefinedToBeRemoved: null, + zeroShouldBeKept: 0, + booleansShouldBeKept: false, + emptyObjectsShouldBeRemoved: {}, + emptyArrayShouldBeRemoved: [], + nonEmptyArraysShouldBeKept: [1, 2, 3], + nestedObjToBeRemoved: { + toBeRemoved: undefined, + }, + nestedObjectToKeep: { + thisShouldBeRemoved: null, + thisShouldBeKept: 'Hello, Grafana', + }, + }; + + const expectedResult = { + stringsShouldBeKept: 'Something', + zeroShouldBeKept: 0, + booleansShouldBeKept: false, + nonEmptyArraysShouldBeKept: [1, 2, 3], + nestedObjectToKeep: { + thisShouldBeKept: 'Hello, Grafana', + }, + }; + + expect(removeEmpty(original)).toStrictEqual(expectedResult); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/utils.ts b/public/app/plugins/datasource/elasticsearch/utils.ts new file mode 100644 index 0000000..ad6de6a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/utils.ts @@ -0,0 +1,93 @@ +import { + isMetricAggregationWithField, + MetricAggregation, + MetricAggregationWithInlineScript, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; + +export const describeMetric = (metric: MetricAggregation) => { + if (!isMetricAggregationWithField(metric)) { + return metricAggregationConfig[metric.type].label; + } + + // TODO: field might be undefined + return `${metricAggregationConfig[metric.type].label} ${metric.field}`; +}; + +/** + * Utility function to clean up aggregations settings objects. + * It removes nullish values and empty strings, array and objects + * recursing over nested objects (not arrays). + * @param obj + */ +export const removeEmpty = (obj: T): Partial => + Object.entries(obj).reduce((acc, [key, value]) => { + // Removing nullish values (null & undefined) + if (value == null) { + return { ...acc }; + } + + // Removing empty arrays (This won't recurse the array) + if (Array.isArray(value) && value.length === 0) { + return { ...acc }; + } + + // Removing empty strings + if (value?.length === 0) { + return { ...acc }; + } + + // Recursing over nested objects + if (!Array.isArray(value) && typeof value === 'object') { + const cleanObj = removeEmpty(value); + + if (Object.keys(cleanObj).length === 0) { + return { ...acc }; + } + + return { ...acc, [key]: cleanObj }; + } + + return { + ...acc, + [key]: value, + }; + }, {}); + +/** + * This function converts an order by string to the correct metric id For example, + * if the user uses the standard deviation extended stat for the order by, + * the value would be "1[std_deviation]" and this would return "1" + */ +export const convertOrderByToMetricId = (orderBy: string): string | undefined => { + const metricIdMatches = orderBy.match(/^(\d+)/); + return metricIdMatches ? metricIdMatches[1] : void 0; +}; + +/** Gets the actual script value for metrics that support inline scripts. + * + * This is needed because the `script` is a bit polymorphic. + * when creating a query with Grafana < 7.4 it was stored as: + * ```json + * { + * "settings": { + * "script": { + * "inline": "value" + * } + * } + * } + * ``` + * + * while from 7.4 it's stored as + * ```json + * { + * "settings": { + * "script": "value" + * } + * } + * ``` + * + * This allows us to access both formats and support both queries created before 7.4 and after. + */ +export const getScriptValue = (metric: MetricAggregationWithInlineScript) => + (typeof metric.settings?.script === 'object' ? metric.settings?.script?.inline : metric.settings?.script) || ''; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts new file mode 100644 index 0000000..f5dc9b2 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts @@ -0,0 +1,18 @@ +import { auto } from 'angular'; + +export class QueryCtrl { + target: any; + datasource: any; + panelCtrl: any; + panel: any; + hasRawMode: boolean; + error: string; + + constructor(public $scope: any, _$injector: auto.IInjectorService) { + this.panelCtrl = this.panelCtrl || { panel: {} }; + this.target = this.target || { target: '' }; + this.panel = this.panelCtrl.panel; + } + + refresh() {} +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/sdk.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/sdk.ts new file mode 100644 index 0000000..b8e8501 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/sdk.ts @@ -0,0 +1,3 @@ +import { QueryCtrl } from './query_ctrl'; + +export { QueryCtrl }; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/annotations_query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/annotations_query_ctrl.ts new file mode 100644 index 0000000..8b68db4 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/annotations_query_ctrl.ts @@ -0,0 +1,78 @@ +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; + +export class AzureMonitorAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + datasource: any; + annotation: any; + workspaces: any[]; + subscriptions: Array<{ text: string; value: string }>; + + defaultQuery = + '\n| where $__timeFilter() \n| project TimeGenerated, Text=YourTitleColumn, Tags="tag1,tag2"'; + + constructor(private templateSrv: TemplateSrv = getTemplateSrv()) { + this.annotation.queryType = this.annotation.queryType || 'Azure Log Analytics'; + this.annotation.rawQuery = this.annotation.rawQuery || this.defaultQuery; + this.initDropdowns(); + } + + async initDropdowns() { + await this.getSubscriptions(); + await this.getWorkspaces(); + } + + async getSubscriptions() { + if (!this.datasource.azureMonitorDatasource.isConfigured()) { + return; + } + + return this.datasource.azureMonitorDatasource.getSubscriptions().then((subs: any[]) => { + this.subscriptions = subs; + + if (!this.annotation.subscription && this.annotation.queryType === 'Azure Log Analytics') { + this.annotation.subscription = this.datasource.azureLogAnalyticsDatasource.subscriptionId; + } + + if (!this.annotation.subscription && this.subscriptions.length > 0) { + this.annotation.subscription = this.subscriptions[0].value; + } + }); + } + + async getWorkspaces(bustCache?: boolean) { + if (!bustCache && this.workspaces && this.workspaces.length > 0) { + return this.workspaces; + } + + return this.datasource + .getAzureLogAnalyticsWorkspaces(this.annotation.subscription) + .then((list: any[]) => { + this.workspaces = list; + if (list.length > 0 && !this.annotation.workspace) { + this.annotation.workspace = list[0].value; + } + return this.workspaces; + }) + .catch(() => {}); + } + + getAzureLogAnalyticsSchema = () => { + return this.getWorkspaces() + .then(() => { + return this.datasource.azureLogAnalyticsDatasource.getSchema(this.annotation.workspace); + }) + .catch(() => {}); + }; + + onSubscriptionChange = () => { + this.getWorkspaces(true); + }; + + onLogAnalyticsQueryChange = (nextQuery: string) => { + this.annotation.rawQuery = nextQuery; + }; + + get templateVariables() { + return this.templateSrv.getVariables().map((t: any) => '$' + t.name); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.test.ts new file mode 100644 index 0000000..02608d1 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.test.ts @@ -0,0 +1,544 @@ +import { DataFrame, getFrameDisplayName, toUtc } from '@grafana/data'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { setBackendSrv } from '@grafana/runtime'; +import AppInsightsDatasource from './app_insights_datasource'; +import { of } from 'rxjs'; + +const templateSrv = new TemplateSrv(); + +jest.mock('app/core/services/backend_srv'); +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, + getTemplateSrv: () => templateSrv, +})); + +describe('AppInsightsDatasource', () => { + const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + const ctx: any = {}; + + beforeEach(() => { + jest.clearAllMocks(); + setBackendSrv(backendSrv); + + ctx.instanceSettings = { + jsonData: { appInsightsAppId: '3ad4400f-ea7d-465d-a8fb-43fb20555d85' }, + url: 'http://appinsightsapi', + }; + + ctx.ds = new AppInsightsDatasource(ctx.instanceSettings); + }); + + describe('When performing testDatasource', () => { + describe('and a list of metrics is returned', () => { + const response = { + metrics: { + 'requests/count': { + displayName: 'Server requests', + defaultAggregation: 'sum', + }, + 'requests/duration': { + displayName: 'Server requests', + defaultAggregation: 'sum', + }, + }, + dimensions: { + 'request/source': { + displayName: 'Request source', + }, + }, + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => { + return Promise.resolve({ data: response, status: 200 }); + }); + }); + + it('should return success status', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('success'); + }); + }); + }); + + describe('and a PathNotFoundError error is returned', () => { + const error = { + data: { + error: { + code: 'PathNotFoundError', + message: `An error message.`, + }, + }, + status: 404, + statusText: 'Not Found', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => { + return Promise.reject(error); + }); + }); + + it.skip('should return error status and a detailed error message', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('error'); + expect(results.message).toEqual( + '1. Application Insights: Not Found: Invalid Application Id for Application Insights service. ' + ); + }); + }); + }); + + describe('and an error is returned', () => { + const error = { + data: { + error: { + code: 'SomeOtherError', + message: `An error message.`, + }, + }, + status: 500, + statusText: 'Error', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => { + return Promise.reject(error); + }); + }); + + it.skip('should return error status and a detailed error message', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('error'); + expect(results.message).toEqual('1. Application Insights: Error: SomeOtherError. An error message. '); + }); + }); + }); + }); + + describe('When performing raw query', () => { + const queryString = + 'metrics ' + + '| where $__timeFilter(timestamp) ' + + '| where name == "testMetrics" ' + + '| summarize max=max(valueMax) by bin(timestamp, $__interval), partition'; + + const options = { + range: { + from: toUtc('2017-08-22T20:00:00Z'), + to: toUtc('2017-08-22T23:59:00Z'), + }, + targets: [ + { + apiVersion: '2016-09-01', + refId: 'A', + queryType: 'Application Insights', + appInsights: { + rawQuery: true, + rawQueryString: queryString, + timeColumn: 'timestamp', + valueColumn: 'max', + segmentColumn: (undefined as unknown) as string, + }, + }, + ], + }; + + describe('with no grouping', () => { + const response: any = { + results: { + A: { + refId: 'A', + meta: {}, + series: [ + { + name: 'PrimaryResult', + points: [[2.2075, 1558278660000]], + }, + ], + tables: null, + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation((options: any) => { + expect(options.url).toContain('/api/ds/query'); + expect(options.data.queries.length).toBe(1); + expect(options.data.queries[0].refId).toBe('A'); + return of({ data: response, status: 200 } as any); + }); + }); + + it('should return a list of datapoints', () => { + return ctx.ds + .query(options) + .toPromise() + .then((results: any) => { + expect(results.data.length).toBe(1); + const data = results.data[0] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('PrimaryResult'); + expect(data.fields[0].values.length).toEqual(1); + expect(data.fields[0].values.get(0)).toEqual(1558278660000); + expect(data.fields[1].values.get(0)).toEqual(2.2075); + }); + }); + }); + + describe('with grouping', () => { + const response: any = { + results: { + A: { + refId: 'A', + meta: {}, + series: [ + { + name: 'paritionA', + points: [[2.2075, 1558278660000]], + }, + ], + tables: null, + }, + }, + }; + + beforeEach(() => { + options.targets[0].appInsights.segmentColumn = 'partition'; + fetchMock.mockImplementation((options: any) => { + expect(options.url).toContain('/api/ds/query'); + expect(options.data.queries.length).toBe(1); + expect(options.data.queries[0].refId).toBe('A'); + return of({ data: response, status: 200 } as any); + }); + }); + + it('should return a list of datapoints', () => { + return ctx.ds + .query(options) + .toPromise() + .then((results: any) => { + expect(results.data.length).toBe(1); + const data = results.data[0] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('paritionA'); + expect(data.fields[0].values.length).toEqual(1); + expect(data.fields[0].values.get(0)).toEqual(1558278660000); + expect(data.fields[1].values.get(0)).toEqual(2.2075); + }); + }); + }); + }); + + describe('When performing metric query', () => { + const options = { + range: { + from: toUtc('2017-08-22T20:00:00Z'), + to: toUtc('2017-08-22T23:59:00Z'), + }, + targets: [ + { + apiVersion: '2016-09-01', + refId: 'A', + queryType: 'Application Insights', + appInsights: { + metricName: 'exceptions/server', + dimension: '', + timeGrain: 'none', + }, + }, + ], + }; + + describe('and with a single value', () => { + const response: any = { + results: { + A: { + refId: 'A', + meta: {}, + series: [ + { + name: 'exceptions/server', + points: [[2.2075, 1558278660000]], + }, + ], + tables: null, + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation((options: any) => { + expect(options.url).toContain('/api/ds/query'); + expect(options.data.queries.length).toBe(1); + expect(options.data.queries[0].refId).toBe('A'); + expect(options.data.queries[0].appInsights.rawQueryString).toBeUndefined(); + expect(options.data.queries[0].appInsights.metricName).toBe('exceptions/server'); + return of({ data: response, status: 200 } as any); + }); + }); + + it('should return a single datapoint', () => { + return ctx.ds + .query(options) + .toPromise() + .then((results: any) => { + expect(results.data.length).toBe(1); + const data = results.data[0] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('exceptions/server'); + expect(data.fields[0].values.get(0)).toEqual(1558278660000); + expect(data.fields[1].values.get(0)).toEqual(2.2075); + }); + }); + }); + + describe('and with an interval group and without a segment group by', () => { + const response: any = { + results: { + A: { + refId: 'A', + meta: {}, + series: [ + { + name: 'exceptions/server', + points: [ + [3, 1504108800000], + [6, 1504112400000], + ], + }, + ], + tables: null, + }, + }, + }; + + beforeEach(() => { + options.targets[0].appInsights.timeGrain = 'PT30M'; + fetchMock.mockImplementation((options: any) => { + expect(options.url).toContain('/api/ds/query'); + expect(options.data.queries[0].refId).toBe('A'); + expect(options.data.queries[0].appInsights.query).toBeUndefined(); + expect(options.data.queries[0].appInsights.metricName).toBe('exceptions/server'); + expect(options.data.queries[0].appInsights.timeGrain).toBe('PT30M'); + return of({ data: response, status: 200 } as any); + }); + }); + + it('should return a list of datapoints', () => { + return ctx.ds + .query(options) + .toPromise() + .then((results: any) => { + expect(results.data.length).toBe(1); + const data = results.data[0] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('exceptions/server'); + expect(data.fields[0].values.length).toEqual(2); + expect(data.fields[0].values.get(0)).toEqual(1504108800000); + expect(data.fields[1].values.get(0)).toEqual(3); + expect(data.fields[0].values.get(1)).toEqual(1504112400000); + expect(data.fields[1].values.get(1)).toEqual(6); + }); + }); + }); + + describe('and with a group by', () => { + const response: any = { + results: { + A: { + refId: 'A', + meta: {}, + series: [ + { + name: 'exceptions/server{client/city="Miami"}', + points: [ + [10, 1504108800000], + [20, 1504112400000], + ], + }, + { + name: 'exceptions/server{client/city="San Antonio"}', + points: [ + [1, 1504108800000], + [2, 1504112400000], + ], + }, + ], + tables: null, + }, + }, + }; + + describe('and with no alias specified', () => { + beforeEach(() => { + options.targets[0].appInsights.dimension = 'client/city'; + + fetchMock.mockImplementation((options: any) => { + expect(options.url).toContain('/api/ds/query'); + expect(options.data.queries[0].appInsights.rawQueryString).toBeUndefined(); + expect(options.data.queries[0].appInsights.metricName).toBe('exceptions/server'); + expect([...options.data.queries[0].appInsights.dimension]).toMatchObject(['client/city']); + return of({ data: response, status: 200 } as any); + }); + }); + + it('should return a list of datapoints', () => { + return ctx.ds + .query(options) + .toPromise() + .then((results: any) => { + expect(results.data.length).toBe(2); + let data = results.data[0] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('exceptions/server{client/city="Miami"}'); + expect(data.fields[1].values.length).toEqual(2); + expect(data.fields[0].values.get(0)).toEqual(1504108800000); + expect(data.fields[1].values.get(0)).toEqual(10); + expect(data.fields[0].values.get(1)).toEqual(1504112400000); + expect(data.fields[1].values.get(1)).toEqual(20); + data = results.data[1] as DataFrame; + expect(getFrameDisplayName(data)).toEqual('exceptions/server{client/city="San Antonio"}'); + expect(data.fields[1].values.length).toEqual(2); + expect(data.fields[0].values.get(0)).toEqual(1504108800000); + expect(data.fields[1].values.get(0)).toEqual(1); + expect(data.fields[0].values.get(1)).toEqual(1504112400000); + expect(data.fields[1].values.get(1)).toEqual(2); + }); + }); + }); + }); + }); + + describe('When performing metricFindQuery', () => { + describe('with a metric names query', () => { + const response = { + metrics: { + 'exceptions/server': {}, + 'requests/count': {}, + }, + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('/metrics/metadata'); + return Promise.resolve({ data: response, status: 200 }); + }); + }); + + it('should return a list of metric names', () => { + return ctx.ds.metricFindQueryInternal('appInsightsMetricNames()').then((results: any) => { + expect(results.length).toBe(2); + expect(results[0].text).toBe('exceptions/server'); + expect(results[0].value).toBe('exceptions/server'); + expect(results[1].text).toBe('requests/count'); + expect(results[1].value).toBe('requests/count'); + }); + }); + }); + + describe('with metadata group by query', () => { + const response = { + metrics: { + 'exceptions/server': { + supportedAggregations: ['sum'], + supportedGroupBy: { + all: ['client/os', 'client/city', 'client/browser'], + }, + defaultAggregation: 'sum', + }, + 'requests/count': { + supportedAggregations: ['avg', 'sum', 'total'], + supportedGroupBy: { + all: ['client/os', 'client/city', 'client/browser'], + }, + defaultAggregation: 'avg', + }, + }, + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('/metrics/metadata'); + return Promise.resolve({ data: response, status: 200 }); + }); + }); + + it('should return a list of group bys', () => { + return ctx.ds.metricFindQueryInternal('appInsightsGroupBys(requests/count)').then((results: any) => { + expect(results[0].text).toContain('client/os'); + expect(results[0].value).toContain('client/os'); + expect(results[1].text).toContain('client/city'); + expect(results[1].value).toContain('client/city'); + expect(results[2].text).toContain('client/browser'); + expect(results[2].value).toContain('client/browser'); + }); + }); + }); + }); + + describe('When getting Metric Names', () => { + const response = { + metrics: { + 'exceptions/server': {}, + 'requests/count': {}, + }, + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('/metrics/metadata'); + return Promise.resolve({ data: response, status: 200 }); + }); + }); + + it.skip('should return a list of metric names', () => { + return ctx.ds.getAppInsightsMetricNames().then((results: any) => { + expect(results.length).toBe(2); + expect(results[0].text).toBe('exceptions/server'); + expect(results[0].value).toBe('exceptions/server'); + expect(results[1].text).toBe('requests/count'); + expect(results[1].value).toBe('requests/count'); + }); + }); + }); + + describe('When getting Metric Metadata', () => { + const response = { + metrics: { + 'exceptions/server': { + supportedAggregations: ['sum'], + supportedGroupBy: { + all: ['client/os', 'client/city', 'client/browser'], + }, + defaultAggregation: 'sum', + }, + 'requests/count': { + supportedAggregations: ['avg', 'sum', 'total'], + supportedGroupBy: { + all: ['client/os', 'client/city', 'client/browser'], + }, + defaultAggregation: 'avg', + }, + }, + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('/metrics/metadata'); + return Promise.resolve({ data: response, status: 200 }); + }); + }); + + it.skip('should return a list of group bys', () => { + return ctx.ds.getAppInsightsMetricMetadata('requests/count').then((results: any) => { + expect(results.primaryAggType).toEqual('avg'); + expect(results.supportedAggTypes).toContain('avg'); + expect(results.supportedAggTypes).toContain('sum'); + expect(results.supportedAggTypes).toContain('total'); + expect(results.supportedGroupBy).toContain('client/os'); + expect(results.supportedGroupBy).toContain('client/city'); + expect(results.supportedGroupBy).toContain('client/browser'); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts new file mode 100644 index 0000000..cfa6fd4 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts @@ -0,0 +1,222 @@ +import { DataQueryRequest, DataSourceInstanceSettings, ScopedVars, MetricFindValue } from '@grafana/data'; +import { getBackendSrv, getTemplateSrv, DataSourceWithBackend } from '@grafana/runtime'; +import _, { isString } from 'lodash'; + +import TimegrainConverter from '../time_grain_converter'; +import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType } from '../types'; +import ResponseParser from './response_parser'; + +export interface LogAnalyticsColumn { + text: string; + value: string; +} +export default class AppInsightsDatasource extends DataSourceWithBackend { + url: string; + baseUrl: string; + version = 'beta'; + applicationId: string; + logAnalyticsColumns: { [key: string]: LogAnalyticsColumn[] } = {}; + + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.applicationId = instanceSettings.jsonData.appInsightsAppId || ''; + + switch (instanceSettings.jsonData?.cloudName) { + // Azure US Government + case 'govazuremonitor': + break; + // Azure Germany + case 'germanyazuremonitor': + break; + // Azue China + case 'chinaazuremonitor': + this.baseUrl = `/chinaappinsights/${this.version}/apps/${this.applicationId}`; + break; + // Azure Global + default: + this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`; + } + + this.url = instanceSettings.url || ''; + } + + isConfigured(): boolean { + return !!this.applicationId && this.applicationId.length > 0; + } + + createRawQueryRequest(item: any, options: DataQueryRequest, target: AzureMonitorQuery) { + if (item.xaxis && !item.timeColumn) { + item.timeColumn = item.xaxis; + } + + if (item.yaxis && !item.valueColumn) { + item.valueColumn = item.yaxis; + } + + if (item.spliton && !item.segmentColumn) { + item.segmentColumn = item.spliton; + } + + return { + type: 'timeSeriesQuery', + raw: false, + appInsights: { + rawQuery: true, + rawQueryString: getTemplateSrv().replace(item.rawQueryString, options.scopedVars), + timeColumn: item.timeColumn, + valueColumn: item.valueColumn, + segmentColumn: item.segmentColumn, + }, + }; + } + + applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record { + const item = target.appInsights!; + + const old: any = item; + // fix for timeGrainUnit which is a deprecated/removed field name + if (old.timeGrainCount) { + item.timeGrain = TimegrainConverter.createISO8601Duration(old.timeGrainCount, item.timeGrainUnit); + } else if (item.timeGrainUnit && item.timeGrain !== 'auto') { + item.timeGrain = TimegrainConverter.createISO8601Duration(item.timeGrain, item.timeGrainUnit); + } + + // migration for non-standard names + if (old.groupBy && !item.dimension) { + item.dimension = [old.groupBy]; + } + if (old.filter && !item.dimensionFilter) { + item.dimensionFilter = old.filter; + } + + // Migrate single dimension string to array + if (isString(item.dimension)) { + if (item.dimension === 'None') { + item.dimension = []; + } else { + item.dimension = [item.dimension as string]; + } + } + if (!item.dimension) { + item.dimension = []; + } + + const templateSrv = getTemplateSrv(); + + return { + type: 'timeSeriesQuery', + refId: target.refId, + format: target.format, + queryType: AzureQueryType.ApplicationInsights, + appInsights: { + timeGrain: templateSrv.replace((item.timeGrain || '').toString(), scopedVars), + allowedTimeGrainsMs: item.allowedTimeGrainsMs, + metricName: templateSrv.replace(item.metricName, scopedVars), + aggregation: templateSrv.replace(item.aggregation, scopedVars), + dimension: item.dimension.map((d) => templateSrv.replace(d, scopedVars)), + dimensionFilter: templateSrv.replace(item.dimensionFilter, scopedVars), + alias: item.alias, + format: target.format, + }, + }; + } + + /** + * This is named differently than DataSourceApi.metricFindQuery + * because it's not exposed to Grafana like the main AzureMonitorDataSource. + * And some of the azure internal data sources return null in this function, which the + * external interface does not support + */ + metricFindQueryInternal(query: string): Promise | null { + const appInsightsMetricNameQuery = query.match(/^AppInsightsMetricNames\(\)/i); + if (appInsightsMetricNameQuery) { + return this.getMetricNames(); + } + + const appInsightsGroupByQuery = query.match(/^AppInsightsGroupBys\(([^\)]+?)(,\s?([^,]+?))?\)/i); + if (appInsightsGroupByQuery) { + const metricName = appInsightsGroupByQuery[1]; + return this.getGroupBys(getTemplateSrv().replace(metricName)); + } + + return null; + } + + testDatasource() { + const url = `${this.baseUrl}/metrics/metadata`; + return this.doRequest(url) + .then((response: any) => { + if (response.status === 200) { + return { + status: 'success', + message: 'Successfully queried the Application Insights service.', + title: 'Success', + }; + } + + return { + status: 'error', + message: 'Application Insights: Returned http status code ' + response.status, + }; + }) + .catch((error: any) => { + let message = 'Application Insights: '; + message += error.statusText ? error.statusText + ': ' : ''; + + if (error.data && error.data.error && error.data.error.code === 'PathNotFoundError') { + message += 'Invalid Application Id for Application Insights service.'; + } else if (error.data && error.data.error) { + message += error.data.error.code + '. ' + error.data.error.message; + } else { + message += 'Cannot connect to Application Insights REST API.'; + } + + return { + status: 'error', + message: message, + }; + }); + } + + doRequest(url: any, maxRetries = 1): Promise { + return getBackendSrv() + .datasourceRequest({ + url: this.url + url, + method: 'GET', + }) + .catch((error: any) => { + if (maxRetries > 0) { + return this.doRequest(url, maxRetries - 1); + } + + throw error; + }); + } + + getMetricNames() { + const url = `${this.baseUrl}/metrics/metadata`; + return this.doRequest(url).then(ResponseParser.parseMetricNames); + } + + getMetricMetadata(metricName: string) { + const url = `${this.baseUrl}/metrics/metadata`; + return this.doRequest(url).then((result: any) => { + return new ResponseParser(result).parseMetadata(metricName); + }); + } + + getGroupBys(metricName: string) { + return this.getMetricMetadata(metricName).then((result: any) => { + return new ResponseParser(result).parseGroupBys(); + }); + } + + getQuerySchema() { + const url = `${this.baseUrl}/query/schema`; + return this.doRequest(url).then((result: any) => { + const schema = new ResponseParser(result).parseQuerySchema(); + // console.log(schema); + return schema; + }); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/response_parser.ts new file mode 100644 index 0000000..9e3bf94 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/response_parser.ts @@ -0,0 +1,238 @@ +import _ from 'lodash'; +import { dateTime } from '@grafana/data'; + +export default class ResponseParser { + constructor(private results: any) {} + + parseQueryResult() { + let data: any = []; + let columns: any = []; + for (let i = 0; i < this.results.length; i++) { + if (this.results[i].query.raw) { + const xaxis = this.results[i].query.xaxis; + const yaxises = this.results[i].query.yaxis; + const spliton = this.results[i].query.spliton; + columns = this.results[i].result.data.Tables[0].Columns; + const rows = this.results[i].result.data.Tables[0].Rows; + data = _.concat( + data, + this.parseRawQueryResultRow(this.results[i].query, columns, rows, xaxis, yaxises, spliton) + ); + } else { + const value = this.results[i].result.data.value; + const alias = this.results[i].query.alias; + data = _.concat(data, this.parseQueryResultRow(this.results[i].query, value, alias)); + } + } + return data; + } + + parseRawQueryResultRow(query: any, columns: any, rows: any, xaxis: string, yaxises: string, spliton: string) { + const data: any[] = []; + const columnsForDropdown = _.map(columns, (column) => ({ text: column.ColumnName, value: column.ColumnName })); + + const xaxisColumn = columns.findIndex((column: any) => column.ColumnName === xaxis); + const yaxisesSplit = yaxises.split(','); + const yaxisColumns: any = {}; + _.forEach(yaxisesSplit, (yaxis) => { + yaxisColumns[yaxis] = columns.findIndex((column: any) => column.ColumnName === yaxis); + }); + const splitonColumn = columns.findIndex((column: any) => column.ColumnName === spliton); + const convertTimestamp = xaxis === 'timestamp'; + + _.forEach(rows, (row) => { + _.forEach(yaxisColumns, (yaxisColumn, yaxisName) => { + const bucket = + splitonColumn === -1 + ? ResponseParser.findOrCreateBucket(data, yaxisName) + : ResponseParser.findOrCreateBucket(data, row[splitonColumn]); + const epoch = convertTimestamp ? ResponseParser.dateTimeToEpoch(row[xaxisColumn]) : row[xaxisColumn]; + bucket.datapoints.push([row[yaxisColumn], epoch]); + bucket.refId = query.refId; + bucket.query = query.query; + bucket.columnsForDropdown = columnsForDropdown; + }); + }); + + return data; + } + + parseQueryResultRow(query: any, value: any, alias: string) { + const data: any[] = []; + + if (ResponseParser.isSingleValue(value)) { + const metricName = ResponseParser.getMetricFieldKey(value); + const aggField = ResponseParser.getKeyForAggregationField(value[metricName]); + const epoch = ResponseParser.dateTimeToEpoch(value.end); + data.push({ + target: metricName, + datapoints: [[value[metricName][aggField], epoch]], + refId: query.refId, + query: query.query, + }); + return data; + } + + const groupedBy = ResponseParser.hasSegmentsField(value.segments[0]); + if (!groupedBy) { + const metricName = ResponseParser.getMetricFieldKey(value.segments[0]); + const dataTarget = ResponseParser.findOrCreateBucket(data, metricName); + + for (let i = 0; i < value.segments.length; i++) { + const epoch = ResponseParser.dateTimeToEpoch(value.segments[i].end); + const aggField: string = ResponseParser.getKeyForAggregationField(value.segments[i][metricName]); + + dataTarget.datapoints.push([value.segments[i][metricName][aggField], epoch]); + } + dataTarget.refId = query.refId; + dataTarget.query = query.query; + } else { + for (let i = 0; i < value.segments.length; i++) { + const epoch = ResponseParser.dateTimeToEpoch(value.segments[i].end); + + for (let j = 0; j < value.segments[i].segments.length; j++) { + const metricName = ResponseParser.getMetricFieldKey(value.segments[i].segments[j]); + const aggField = ResponseParser.getKeyForAggregationField(value.segments[i].segments[j][metricName]); + const target = this.getTargetName(value.segments[i].segments[j], alias); + + const bucket = ResponseParser.findOrCreateBucket(data, target); + bucket.datapoints.push([value.segments[i].segments[j][metricName][aggField], epoch]); + bucket.refId = query.refId; + bucket.meta = { + query: query.query, + }; + } + } + } + + return data; + } + + getTargetName(segment: { [x: string]: string }, alias: string) { + let metric = ''; + let segmentName = ''; + let segmentValue = ''; + for (const prop in segment) { + if (_.isObject(segment[prop])) { + metric = prop; + } else { + segmentName = prop; + segmentValue = segment[prop]; + } + } + + if (alias) { + const regex = /\{\{([\s\S]+?)\}\}/g; + return alias.replace(regex, (match, g1, g2) => { + const group = g1 || g2; + + if (group === 'metric') { + return metric; + } else if (group === 'groupbyname') { + return segmentName; + } else if (group === 'groupbyvalue') { + return segmentValue; + } + + return match; + }); + } + + return metric + `{${segmentName}="${segmentValue}"}`; + } + + static isSingleValue(value: any) { + return !ResponseParser.hasSegmentsField(value); + } + + static findOrCreateBucket(data: any[], target: string) { + let dataTarget: any = _.find(data, ['target', target]); + if (!dataTarget) { + dataTarget = { target: target, datapoints: [] }; + data.push(dataTarget); + } + + return dataTarget; + } + + static hasSegmentsField(obj: any) { + const keys = _.keys(obj); + return _.indexOf(keys, 'segments') > -1; + } + + static getMetricFieldKey(segment: { [x: string]: any }) { + const keys = _.keys(segment); + + return _.filter(_.without(keys, 'start', 'end'), (key) => { + return _.isObject(segment[key]); + })[0]; + } + + static getKeyForAggregationField(dataObj: any): string { + const keys = _.keys(dataObj); + return _.intersection(keys, ['sum', 'avg', 'min', 'max', 'count', 'unique'])[0]; + } + + static dateTimeToEpoch(dateTimeValue: any) { + return dateTime(dateTimeValue).valueOf(); + } + + static parseMetricNames(result: { data: { metrics: any } }) { + const keys = _.keys(result.data.metrics); + + return ResponseParser.toTextValueList(keys); + } + + parseMetadata(metricName: string) { + const metric = this.results.data.metrics[metricName]; + + if (!metric) { + throw Error('No data found for metric: ' + metricName); + } + + return { + primaryAggType: metric.defaultAggregation, + supportedAggTypes: metric.supportedAggregations, + supportedGroupBy: metric.supportedGroupBy.all, + }; + } + + parseGroupBys() { + return ResponseParser.toTextValueList(this.results.supportedGroupBy); + } + + parseQuerySchema() { + const result: any = { + Type: 'AppInsights', + Tables: {}, + }; + if (this.results && this.results.data && this.results.data.Tables) { + for (let i = 0; i < this.results.data.Tables[0].Rows.length; i++) { + const column = this.results.data.Tables[0].Rows[i]; + const columnTable = column[0]; + const columnName = column[1]; + const columnType = column[2]; + if (result.Tables[columnTable]) { + result.Tables[columnTable].OrderedColumns.push({ Name: columnName, Type: columnType }); + } else { + result.Tables[columnTable] = { + Name: columnTable, + OrderedColumns: [{ Name: columnName, Type: columnType }], + }; + } + } + } + return result; + } + + static toTextValueList(values: any) { + const list: any[] = []; + for (let i = 0; i < values.length; i++) { + list.push({ + text: values[i], + value: values[i], + }); + } + return list; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/__mocks__/schema.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/__mocks__/schema.ts new file mode 100644 index 0000000..e9f233f --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/__mocks__/schema.ts @@ -0,0 +1,305 @@ +export default class FakeSchemaData { + static getLogAnalyticsFakeSchema() { + return { + Tables: [ + { + TableName: 'Table_0', + Columns: [ + { + ColumnName: 'TableName', + DataType: 'String', + }, + { + ColumnName: 'ColumnName', + DataType: 'String', + }, + { + ColumnName: 'ColumnType', + DataType: 'String', + }, + ], + Rows: [ + ['AzureNetworkAnalytics_CL', 'SourceSystem', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ManagementGroupName', 'System.String'], + ['AzureNetworkAnalytics_CL', 'TimeGenerated', 'System.DateTime'], + ['AzureNetworkAnalytics_CL', 'Computer', 'System.String'], + ['AzureNetworkAnalytics_CL', 'FASchemaVersion_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'FlowType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SrcIP_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'DestIP_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VMIP_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'L4Protocol_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'L7Protocol_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'FlowDirection_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NSGList_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NSGRules_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'HopNSGList_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'HopNSGRules_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Region1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Region2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NIC_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NIC1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NIC2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VM_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VM1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VM2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subnet_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ConnectionName_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'S2SConnection_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'S2SConnectionType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Country_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'AzureRegion_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subscription1_g', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subscription2_g', 'System.String'], + ['AzureNetworkAnalytics_CL', 'FlowStartTime_t', 'System.DateTime'], + ['AzureNetworkAnalytics_CL', 'FlowEndTime_t', 'System.DateTime'], + ['AzureNetworkAnalytics_CL', 'DestPort_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'AllowedInFlows_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'DeniedInFlows_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'AllowedOutFlows_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'DeniedOutFlows_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'DeniedInFlowsAtHops_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'DeniedOutFlowsAtHops_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'FlowCount_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'NextHopIP_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'IsVirtualAppliance_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'AddressPrefix_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'NextHopType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'RouteTable_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subnet1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subnet2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SubnetRegion1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SubnetRegion2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualAppliances_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'AllowForwardedTraffic_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'AllowGatewayTransit_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'AllowVirtualNetworkAccess_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'UseRemoteGateways_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'NSG_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'PrivateIPAddresses_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'PublicIPAddresses_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subnetwork_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualMachine_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'MACAddress_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'AddressPrefixes_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ConnectingVirtualNetwork_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'RemoteVirtualNetworkGateway_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'IsFlowEnabled_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'GatewayType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SKU_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VIPAddress_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualSubnetwork_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'BGPEnabled_b', 'System.Boolean'], + ['AzureNetworkAnalytics_CL', 'ConnectionStatus_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ConnectionType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'GatewayConnectionType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'LocalNetworkGateway_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetwork1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetwork2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetworkGateway1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetworkGateway2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetworkRegion1_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'VirtualNetworkRegion2_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'EgressBytesTransferred_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'IngressBytesTransferred_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'RoutingWeight_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'FrontendSubnet_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'LoadBalancerType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Access_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Description_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'DestinationAddressPrefix_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'DestinationPortRange_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Direction_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Protocol_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'RuleType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SourceAddressPrefix_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SourcePortRange_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Priority_d', 'System.Double'], + ['AzureNetworkAnalytics_CL', 'IPAddress', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SubnetPrefixes_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SchemaVersion_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Name_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Region_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'AppGatewayType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'BackendSubnets_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'FrontendIPs_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'GatewaySubnet_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ComponentType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'DiscoveryRegion_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'ResourceType', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Status_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'SubType_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'TopologyVersion_s', 'System.String'], + ['AzureNetworkAnalytics_CL', 'Subscription_g', 'System.String'], + ['AzureNetworkAnalytics_CL', 'TimeProcessed_t', 'System.DateTime'], + ['AzureNetworkAnalytics_CL', 'Type', 'System.String'], + ], + KqlPrimaryTimestampColumnName: 'TimeGenerated', + }, + { + TableName: 'Table_1', + Columns: [ + { + ColumnName: 'TableType', + DataType: 'String', + }, + { + ColumnName: 'TableName', + DataType: 'String', + }, + { + ColumnName: 'PrimaryTimestampColumnName', + DataType: 'String', + }, + { + ColumnName: 'Solutions', + DataType: 'String', + }, + ], + Rows: [['oms', 'AzureNetworkAnalytics_CL', 'TimeGenerated', 'LogManagement']], + }, + ], + }; + } + + static getlogAnalyticsFakeMetadata(): any { + return { + tables: [ + { + id: 't/Alert', + name: 'Alert', + timespanColumn: 'TimeGenerated', + columns: [ + { name: 'TimeGenerated', type: 'datetime' }, + { name: 'AlertSeverity', type: 'string' }, + { name: 'SourceDisplayName', type: 'string' }, + { name: 'AlertName', type: 'string' }, + { name: 'AlertDescription', type: 'string' }, + { name: 'SourceSystem', type: 'string' }, + { name: 'QueryExecutionStartTime', type: 'datetime' }, + { name: 'QueryExecutionEndTime', type: 'datetime' }, + { name: 'Query', type: 'string' }, + { name: 'RemediationJobId', type: 'string' }, + { name: 'RemediationRunbookName', type: 'string' }, + { name: 'AlertRuleId', type: 'string' }, + { name: 'AlertRuleInstanceId', type: 'string' }, + { name: 'ThresholdOperator', type: 'string' }, + { name: 'ThresholdValue', type: 'int' }, + { name: 'LinkToSearchResults', type: 'string' }, + { name: 'ServiceDeskConnectionName', type: 'string' }, + { name: 'ServiceDeskId', type: 'string' }, + { name: 'ServiceDeskWorkItemLink', type: 'string' }, + { name: 'ServiceDeskWorkItemType', type: 'string' }, + { name: 'ResourceId', type: 'string' }, + { name: 'ResourceType', type: 'string' }, + { name: 'ResourceValue', type: 'string' }, + { name: 'RootObjectName', type: 'string' }, + { name: 'ObjectDisplayName', type: 'string' }, + { name: 'Computer', type: 'string' }, + { name: 'AlertPriority', type: 'string' }, + { name: 'SourceFullName', type: 'string' }, + { name: 'AlertId', type: 'string' }, + { name: 'RepeatCount', type: 'int' }, + { name: 'AlertState', type: 'string' }, + { name: 'ResolvedBy', type: 'string' }, + { name: 'LastModifiedBy', type: 'string' }, + { name: 'TimeRaised', type: 'datetime' }, + { name: 'TimeResolved', type: 'datetime' }, + { name: 'TimeLastModified', type: 'datetime' }, + { name: 'AlertContext', type: 'string' }, + { name: 'TicketId', type: 'string' }, + { name: 'Custom1', type: 'string' }, + { name: 'Custom2', type: 'string' }, + { name: 'Custom3', type: 'string' }, + { name: 'Custom4', type: 'string' }, + { name: 'Custom5', type: 'string' }, + { name: 'Custom6', type: 'string' }, + { name: 'Custom7', type: 'string' }, + { name: 'Custom8', type: 'string' }, + { name: 'Custom9', type: 'string' }, + { name: 'Custom10', type: 'string' }, + { name: 'ManagementGroupName', type: 'string' }, + { name: 'PriorityNumber', type: 'int' }, + { name: 'HostName', type: 'string' }, + { name: 'StateType', type: 'string' }, + { name: 'AlertTypeDescription', type: 'string' }, + { name: 'AlertTypeNumber', type: 'int' }, + { name: 'AlertError', type: 'string' }, + { name: 'StatusDescription', type: 'string' }, + { name: 'AlertStatus', type: 'int' }, + { name: 'TriggerId', type: 'string' }, + { name: 'Url', type: 'string' }, + { name: 'ValueDescription', type: 'string' }, + { name: 'AlertValue', type: 'int' }, + { name: 'Comments', type: 'string' }, + { name: 'TemplateId', type: 'string' }, + { name: 'FlagsDescription', type: 'string' }, + { name: 'Flags', type: 'int' }, + { name: 'ValueFlagsDescription', type: 'string' }, + { name: 'ValueFlags', type: 'int' }, + { name: 'Expression', type: 'string' }, + { name: 'Type', type: 'string' }, + ], + }, + { + id: 't/AzureActivity', + name: 'AzureActivity', + timespanColumn: 'TimeGenerated', + columns: [ + { name: 'OperationName', type: 'string' }, + { name: 'Level', type: 'string' }, + { name: 'ActivityStatus', type: 'string' }, + { name: 'ActivitySubstatus', type: 'string' }, + { name: 'ResourceGroup', type: 'string' }, + { name: 'SubscriptionId', type: 'string' }, + { name: 'CorrelationId', type: 'string' }, + { name: 'Caller', type: 'string' }, + { name: 'CallerIpAddress', type: 'string' }, + { name: 'Category', type: 'string' }, + { name: 'HTTPRequest', type: 'string' }, + { name: 'Properties', type: 'string' }, + { name: 'EventSubmissionTimestamp', type: 'datetime' }, + { name: 'Authorization', type: 'string' }, + { name: 'ResourceId', type: 'string' }, + { name: 'OperationId', type: 'string' }, + { name: 'ResourceProvider', type: 'string' }, + { name: 'Resource', type: 'string' }, + { name: 'TimeGenerated', type: 'datetime' }, + { name: 'SourceSystem', type: 'string' }, + { name: 'Type', type: 'string' }, + ], + }, + ], + tableGroups: [ + { + id: 'oms/LogManagement', + name: 'LogManagement', + source: 'oms', + tables: ['t/Alert', 't/AzureActivity'], + }, + ], + functions: [ + { + id: 'f/Func1', + name: 'Func1', + displayName: 'Func1', + body: 'AzureActivity\n| where ActivityStatus == "" \n', + category: 'test', + }, + ], + applications: [], + workspaces: [ + { + id: 'a2c1b44e-3e57-4410-b027-999999999999', + name: 'danieltest', + resourceId: + '/subscriptions/44693801-6ee6-49de-9b2d-999999999999/resourceGroups/danieltest/providers/' + + 'microsoft.operationalinsights/workspaces/danieltest', + tables: [], + tableGroups: ['oms/LogManagement'], + functions: ['f/Func1'], + }, + ], + }; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts new file mode 100644 index 0000000..609f11f --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.test.ts @@ -0,0 +1,354 @@ +import AzureMonitorDatasource from '../datasource'; +import FakeSchemaData from './__mocks__/schema'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { AzureLogsVariable, KustoSchema } from '../types'; +import { toUtc } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; + +const templateSrv = new TemplateSrv(); + +jest.mock('app/core/services/backend_srv'); +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, + getTemplateSrv: () => templateSrv, +})); + +describe('AzureLogAnalyticsDatasource', () => { + const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + + beforeEach(() => { + jest.clearAllMocks(); + datasourceRequestMock.mockImplementation(jest.fn()); + }); + + const ctx: any = {}; + + beforeEach(() => { + ctx.instanceSettings = { + jsonData: { logAnalyticsSubscriptionId: 'xxx' }, + url: 'http://azureloganalyticsapi', + }; + + ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings); + }); + + describe('When the config option "Same as Azure Monitor" has been chosen', () => { + const tableResponseWithOneColumn = { + tables: [ + { + name: 'PrimaryResult', + columns: [ + { + name: 'Category', + type: 'string', + }, + ], + rows: [['Administrative'], ['Policy']], + }, + ], + }; + + const workspaceResponse = { + value: [ + { + name: 'aworkspace', + properties: { + source: 'Azure', + customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', + }, + }, + ], + }; + + let workspacesUrl: string; + let azureLogAnalyticsUrl: string; + + beforeEach(async () => { + ctx.instanceSettings.jsonData.subscriptionId = 'xxx'; + ctx.instanceSettings.jsonData.tenantId = 'xxx'; + ctx.instanceSettings.jsonData.clientId = 'xxx'; + ctx.instanceSettings.jsonData.azureLogAnalyticsSameAs = true; + ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings); + + datasourceRequestMock.mockImplementation((options: { url: string }) => { + if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) { + workspacesUrl = options.url; + return Promise.resolve({ data: workspaceResponse, status: 200 }); + } else { + azureLogAnalyticsUrl = options.url; + return Promise.resolve({ data: tableResponseWithOneColumn, status: 200 }); + } + }); + + await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category'); + }); + + it('should use the loganalyticsazure plugin route', () => { + expect(workspacesUrl).toContain('azuremonitor'); + expect(azureLogAnalyticsUrl).toContain('loganalyticsazure'); + }); + }); + + describe('When performing testDatasource', () => { + describe('and an error is returned', () => { + const error = { + data: { + error: { + code: 'InvalidApiVersionParameter', + message: `An error message.`, + }, + }, + status: 400, + statusText: 'Bad Request', + }; + + beforeEach(() => { + ctx.instanceSettings.jsonData.logAnalyticsSubscriptionId = 'xxx'; + ctx.instanceSettings.jsonData.logAnalyticsTenantId = 'xxx'; + ctx.instanceSettings.jsonData.logAnalyticsClientId = 'xxx'; + datasourceRequestMock.mockImplementation(() => Promise.reject(error)); + }); + + it('should return error status and a detailed error message', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('error'); + expect(results.message).toEqual( + '1. Azure Log Analytics: Bad Request: InvalidApiVersionParameter. An error message. ' + ); + }); + }); + }); + }); + + describe('When performing getSchema', () => { + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('metadata'); + return Promise.resolve({ data: FakeSchemaData.getlogAnalyticsFakeMetadata(), status: 200 }); + }); + }); + + it('should return a schema with a table and rows', () => { + return ctx.ds.azureLogAnalyticsDatasource.getSchema('myWorkspace').then((result: KustoSchema) => { + expect(Object.keys(result.Databases.Default.Tables).length).toBe(2); + expect(result.Databases.Default.Tables.Alert.Name).toBe('Alert'); + expect(result.Databases.Default.Tables.AzureActivity.Name).toBe('AzureActivity'); + expect(result.Databases.Default.Tables.Alert.OrderedColumns.length).toBe(69); + expect(result.Databases.Default.Tables.AzureActivity.OrderedColumns.length).toBe(21); + expect(result.Databases.Default.Tables.Alert.OrderedColumns[0].Name).toBe('TimeGenerated'); + expect(result.Databases.Default.Tables.Alert.OrderedColumns[0].Type).toBe('datetime'); + + expect(Object.keys(result.Databases.Default.Functions).length).toBe(1); + expect(result.Databases.Default.Functions.Func1.Name).toBe('Func1'); + }); + }); + }); + + describe('When performing metricFindQuery', () => { + let queryResults: AzureLogsVariable[]; + + const workspacesResponse = { + value: [ + { + name: 'workspace1', + properties: { + customerId: 'eeee4fde-1aaa-4d60-9974-eeee562ffaa1', + }, + }, + { + name: 'workspace2', + properties: { + customerId: 'eeee4fde-1aaa-4d60-9974-eeee562ffaa2', + }, + }, + ], + }; + + describe('and is the workspaces() macro', () => { + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('xxx'); + return Promise.resolve({ data: workspacesResponse, status: 200 }); + }); + + queryResults = await ctx.ds.metricFindQuery('workspaces()'); + }); + + it('should return a list of workspaces', () => { + expect(queryResults.length).toBe(2); + expect(queryResults[0].text).toBe('workspace1'); + expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1'); + expect(queryResults[1].text).toBe('workspace2'); + expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2'); + }); + }); + + describe('and is the workspaces() macro with the subscription parameter', () => { + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('11112222-eeee-4949-9b2d-9106972f9123'); + return Promise.resolve({ data: workspacesResponse, status: 200 }); + }); + + queryResults = await ctx.ds.metricFindQuery('workspaces(11112222-eeee-4949-9b2d-9106972f9123)'); + }); + + it('should return a list of workspaces', () => { + expect(queryResults.length).toBe(2); + expect(queryResults[0].text).toBe('workspace1'); + expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1'); + expect(queryResults[1].text).toBe('workspace2'); + expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2'); + }); + }); + + describe('and is the workspaces() macro with the subscription parameter quoted', () => { + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('11112222-eeee-4949-9b2d-9106972f9123'); + return Promise.resolve({ data: workspacesResponse, status: 200 }); + }); + + queryResults = await ctx.ds.metricFindQuery('workspaces("11112222-eeee-4949-9b2d-9106972f9123")'); + }); + + it('should return a list of workspaces', () => { + expect(queryResults.length).toBe(2); + expect(queryResults[0].text).toBe('workspace1'); + expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1'); + expect(queryResults[1].text).toBe('workspace2'); + expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2'); + }); + }); + + describe('and is a custom query', () => { + const tableResponseWithOneColumn = { + tables: [ + { + name: 'PrimaryResult', + columns: [ + { + name: 'Category', + type: 'string', + }, + ], + rows: [['Administrative'], ['Policy']], + }, + ], + }; + + const workspaceResponse = { + value: [ + { + name: 'aworkspace', + properties: { + source: 'Azure', + customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', + }, + }, + ], + }; + + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) { + return Promise.resolve({ data: workspaceResponse, status: 200 }); + } else { + return Promise.resolve({ data: tableResponseWithOneColumn, status: 200 }); + } + }); + + queryResults = await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category'); + }); + + it('should return a list of categories in the correct format', () => { + expect(queryResults.length).toBe(2); + expect(queryResults[0].text).toBe('Administrative'); + expect(queryResults[0].value).toBe('Administrative'); + expect(queryResults[1].text).toBe('Policy'); + expect(queryResults[1].value).toBe('Policy'); + }); + }); + }); + + describe('When performing annotationQuery', () => { + const tableResponse = { + tables: [ + { + name: 'PrimaryResult', + columns: [ + { + name: 'TimeGenerated', + type: 'datetime', + }, + { + name: 'Text', + type: 'string', + }, + { + name: 'Tags', + type: 'string', + }, + ], + rows: [ + ['2018-06-02T20:20:00Z', 'Computer1', 'tag1,tag2'], + ['2018-06-02T20:28:00Z', 'Computer2', 'tag2'], + ], + }, + ], + }; + + const workspaceResponse = { + value: [ + { + name: 'aworkspace', + properties: { + source: 'Azure', + customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', + }, + }, + ], + }; + + let annotationResults: any[]; + + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) { + return Promise.resolve({ data: workspaceResponse, status: 200 }); + } else { + return Promise.resolve({ data: tableResponse, status: 200 }); + } + }); + + annotationResults = await ctx.ds.annotationQuery({ + annotation: { + rawQuery: 'Heartbeat | where $__timeFilter()| project TimeGenerated, Text=Computer, tags="test"', + workspace: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', + }, + range: { + from: toUtc('2017-08-22T20:00:00Z'), + to: toUtc('2017-08-22T23:59:00Z'), + }, + rangeRaw: { + from: 'now-4h', + to: 'now', + }, + }); + }); + + it('should return a list of categories in the correct format', () => { + expect(annotationResults.length).toBe(2); + + expect(annotationResults[0].time).toBe(1527970800000); + expect(annotationResults[0].text).toBe('Computer1'); + expect(annotationResults[0].tags[0]).toBe('tag1'); + expect(annotationResults[0].tags[1]).toBe('tag2'); + + expect(annotationResults[1].time).toBe(1527971280000); + expect(annotationResults[1].text).toBe('Computer2'); + expect(annotationResults[1].tags[0]).toBe('tag2'); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts new file mode 100644 index 0000000..50690fd --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts @@ -0,0 +1,456 @@ +import _ from 'lodash'; +import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder'; +import ResponseParser from './response_parser'; +import { AzureMonitorQuery, AzureDataSourceJsonData, AzureLogsVariable, AzureQueryType } from '../types'; +import { + DataQueryRequest, + DataQueryResponse, + ScopedVars, + DataSourceInstanceSettings, + MetricFindValue, +} from '@grafana/data'; +import { getBackendSrv, getTemplateSrv, DataSourceWithBackend } from '@grafana/runtime'; +import { Observable, from } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend< + AzureMonitorQuery, + AzureDataSourceJsonData +> { + url: string; + baseUrl: string; + applicationId: string; + azureMonitorUrl: string; + defaultOrFirstWorkspace: string; + subscriptionId: string; + cache: Map; + + constructor(private instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.cache = new Map(); + + switch (this.instanceSettings.jsonData.cloudName) { + case 'govazuremonitor': // Azure US Government + this.baseUrl = '/govloganalyticsazure'; + break; + case 'germanyazuremonitor': // Azure Germany + break; + case 'chinaazuremonitor': // Azure China + this.baseUrl = '/chinaloganalyticsazure'; + break; + default: + // Azure Global + this.baseUrl = '/loganalyticsazure'; + } + + this.url = instanceSettings.url || ''; + this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace || ''; + + this.setWorkspaceUrl(); + } + + isConfigured(): boolean { + return ( + (!!this.instanceSettings.jsonData.logAnalyticsSubscriptionId && + this.instanceSettings.jsonData.logAnalyticsSubscriptionId.length > 0) || + !!this.instanceSettings.jsonData.azureLogAnalyticsSameAs + ); + } + + setWorkspaceUrl() { + if (!!this.instanceSettings.jsonData.subscriptionId || !!this.instanceSettings.jsonData.azureLogAnalyticsSameAs) { + this.subscriptionId = this.instanceSettings.jsonData.subscriptionId; + const azureCloud = this.instanceSettings.jsonData.cloudName || 'azuremonitor'; + this.azureMonitorUrl = `/${azureCloud}/subscriptions`; + } else { + this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId || ''; + + switch (this.instanceSettings.jsonData.cloudName) { + case 'govazuremonitor': // Azure US Government + this.azureMonitorUrl = `/govworkspacesloganalytics/subscriptions`; + break; + case 'germanyazuremonitor': // Azure Germany + break; + case 'chinaazuremonitor': // Azure China + this.azureMonitorUrl = `/chinaworkspacesloganalytics/subscriptions`; + break; + default: + // Azure Global + this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`; + } + } + } + + async getWorkspaces(subscription: string): Promise { + const response = await this.getWorkspaceList(subscription); + + return ( + _.map(response.data.value, (val: any) => { + return { text: val.name, value: val.properties.customerId }; + }) || [] + ); + } + + getWorkspaceList(subscription: string): Promise { + const subscriptionId = getTemplateSrv().replace(subscription || this.subscriptionId); + + const workspaceListUrl = + this.azureMonitorUrl + + `/${subscriptionId}/providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview`; + return this.doRequest(workspaceListUrl, true); + } + + getSchema(workspace: string) { + if (!workspace) { + return Promise.resolve(); + } + const url = `${this.baseUrl}/${getTemplateSrv().replace(workspace, {})}/metadata`; + + return this.doRequest(url).then((response: any) => { + return new ResponseParser(response.data).parseSchemaResult(); + }); + } + + applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record { + const item = target.azureLogAnalytics; + + const templateSrv = getTemplateSrv(); + let workspace = templateSrv.replace(item.workspace, scopedVars); + + if (!workspace && this.defaultOrFirstWorkspace) { + workspace = this.defaultOrFirstWorkspace; + } + + const subscriptionId = templateSrv.replace(target.subscription || this.subscriptionId, scopedVars); + const query = templateSrv.replace(item.query, scopedVars, this.interpolateVariable); + + return { + refId: target.refId, + format: target.format, + queryType: AzureQueryType.LogAnalytics, + subscriptionId: subscriptionId, + azureLogAnalytics: { + resultFormat: item.resultFormat, + query: query, + workspace: workspace, + }, + }; + } + + /** + * Augment the results with links back to the azure console + */ + query(request: DataQueryRequest): Observable { + return super.query(request).pipe( + mergeMap((res: DataQueryResponse) => { + return from(this.processResponse(res)); + }) + ); + } + + async processResponse(res: DataQueryResponse): Promise { + if (res.data) { + for (const df of res.data) { + const encodedQuery = df.meta?.custom?.encodedQuery; + if (encodedQuery && encodedQuery.length > 0) { + const url = await this.buildDeepLink(df.meta.custom); + if (url?.length) { + for (const field of df.fields) { + field.config.links = [ + { + url: url, + title: 'View in Azure Portal', + targetBlank: true, + }, + ]; + } + } + } + } + } + return res; + } + + private async buildDeepLink(customMeta: Record) { + const base64Enc = encodeURIComponent(customMeta.encodedQuery); + const workspaceId = customMeta.workspace; + const subscription = customMeta.subscription; + + const details = await this.getWorkspaceDetails(workspaceId); + if (!details.workspace || !details.resourceGroup) { + return ''; + } + + const url = + `https://portal.azure.com/#blade/Microsoft_OperationsManagementSuite_Workspace/` + + `AnalyticsBlade/initiator/AnalyticsShareLinkToQuery/isQueryEditorVisible/true/scope/` + + `%7B%22resources%22%3A%5B%7B%22resourceId%22%3A%22%2Fsubscriptions%2F${subscription}` + + `%2Fresourcegroups%2F${details.resourceGroup}%2Fproviders%2Fmicrosoft.operationalinsights%2Fworkspaces%2F${details.workspace}` + + `%22%7D%5D%7D/query/${base64Enc}/isQueryBase64Compressed/true/timespanInIsoFormat/P1D`; + return url; + } + + async getWorkspaceDetails(workspaceId: string) { + const response = await this.getWorkspaceList(this.subscriptionId); + + const details = response.data.value.find((o: any) => { + return o.properties.customerId === workspaceId; + }); + + if (!details) { + return {}; + } + + const regex = /.*resourcegroups\/(.*)\/providers.*/; + const results = regex.exec(details.id); + if (!results || results.length < 2) { + return {}; + } + + return { + workspace: details.name, + resourceGroup: results[1], + }; + } + + /** + * This is named differently than DataSourceApi.metricFindQuery + * because it's not exposed to Grafana like the main AzureMonitorDataSource. + * And some of the azure internal data sources return null in this function, which the + * external interface does not support + */ + metricFindQueryInternal(query: string): Promise { + const workspacesQuery = query.match(/^workspaces\(\)/i); + if (workspacesQuery) { + return this.getWorkspaces(this.subscriptionId); + } + + const workspacesQueryWithSub = query.match(/^workspaces\(["']?([^\)]+?)["']?\)/i); + if (workspacesQueryWithSub) { + return this.getWorkspaces((workspacesQueryWithSub[1] || '').trim()); + } + + return this.getDefaultOrFirstWorkspace().then((workspace: any) => { + const queries: any[] = this.buildQuery(query, null, workspace); + + const promises = this.doQueries(queries); + + return Promise.all(promises) + .then((results) => { + return new ResponseParser(results).parseToVariables(); + }) + .catch((err) => { + if ( + err.error && + err.error.data && + err.error.data.error && + err.error.data.error.innererror && + err.error.data.error.innererror.innererror + ) { + throw { message: err.error.data.error.innererror.innererror.message }; + } else if (err.error && err.error.data && err.error.data.error) { + throw { message: err.error.data.error.message }; + } + }); + }) as Promise; + } + + private buildQuery(query: string, options: any, workspace: any) { + const querystringBuilder = new LogAnalyticsQuerystringBuilder( + getTemplateSrv().replace(query, {}, this.interpolateVariable), + options, + 'TimeGenerated' + ); + const querystring = querystringBuilder.generate().uriString; + const url = `${this.baseUrl}/${workspace}/query?${querystring}`; + const queries: any[] = []; + queries.push({ + datasourceId: this.id, + url: url, + resultFormat: 'table', + }); + return queries; + } + + interpolateVariable(value: string, variable: { multi: any; includeAll: any }) { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + return "'" + value + "'"; + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _.map(value, (val) => { + if (typeof value === 'number') { + return value; + } + + return "'" + val + "'"; + }); + return quotedValues.join(','); + } + + getDefaultOrFirstWorkspace() { + if (this.defaultOrFirstWorkspace) { + return Promise.resolve(this.defaultOrFirstWorkspace); + } + + return this.getWorkspaces(this.subscriptionId).then((workspaces: any[]) => { + this.defaultOrFirstWorkspace = workspaces[0].value; + return this.defaultOrFirstWorkspace; + }); + } + + annotationQuery(options: any) { + if (!options.annotation.rawQuery) { + return Promise.reject({ + message: 'Query missing in annotation definition', + }); + } + + const queries: any[] = this.buildQuery(options.annotation.rawQuery, options, options.annotation.workspace); + + const promises = this.doQueries(queries); + + return Promise.all(promises).then((results) => { + const annotations = new ResponseParser(results).transformToAnnotations(options); + return annotations; + }); + } + + doQueries(queries: any[]) { + return _.map(queries, (query) => { + return this.doRequest(query.url) + .then((result: any) => { + return { + result: result, + query: query, + }; + }) + .catch((err: any) => { + throw { + error: err, + query: query, + }; + }); + }); + } + + async doRequest(url: string, useCache = false, maxRetries = 1): Promise { + try { + if (useCache && this.cache.has(url)) { + return this.cache.get(url); + } + + const res = await getBackendSrv().datasourceRequest({ + url: this.url + url, + method: 'GET', + }); + + if (useCache) { + this.cache.set(url, res); + } + + return res; + } catch (error) { + if (maxRetries > 0) { + return this.doRequest(url, useCache, maxRetries - 1); + } + + throw error; + } + } + + testDatasource(): Promise { + const validationError = this.isValidConfig(); + if (validationError) { + return Promise.resolve(validationError); + } + + return this.getDefaultOrFirstWorkspace() + .then((ws: any) => { + const url = `${this.baseUrl}/${ws}/metadata`; + + return this.doRequest(url); + }) + .then((response: any) => { + if (response.status === 200) { + return { + status: 'success', + message: 'Successfully queried the Azure Log Analytics service.', + title: 'Success', + }; + } + + return { + status: 'error', + message: 'Returned http status code ' + response.status, + }; + }) + .catch((error: any) => { + let message = 'Azure Log Analytics: '; + if (error.config && error.config.url && error.config.url.indexOf('workspacesloganalytics') > -1) { + message = 'Azure Log Analytics requires access to Azure Monitor but had the following error: '; + } + + message = this.getErrorMessage(message, error); + + return { + status: 'error', + message: message, + }; + }); + } + + private getErrorMessage(message: string, error: any) { + message += error.statusText ? error.statusText + ': ' : ''; + if (error.data && error.data.error && error.data.error.code) { + message += error.data.error.code + '. ' + error.data.error.message; + } else if (error.data && error.data.error) { + message += error.data.error; + } else if (error.data) { + message += error.data; + } else { + message += 'Cannot connect to Azure Log Analytics REST API.'; + } + return message; + } + + isValidConfig() { + if (this.instanceSettings.jsonData.azureLogAnalyticsSameAs) { + return undefined; + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsSubscriptionId)) { + return { + status: 'error', + message: 'The Subscription Id field is required.', + }; + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsTenantId)) { + return { + status: 'error', + message: 'The Tenant Id field is required.', + }; + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsClientId)) { + return { + status: 'error', + message: 'The Client Id field is required.', + }; + } + + return undefined; + } + + isValidConfigField(field: string | undefined) { + return field && field.length > 0; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.test.ts new file mode 100644 index 0000000..7de47e8 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.test.ts @@ -0,0 +1,37 @@ +import ResponseParser from './response_parser'; +import { expect } from '../../../../../test/lib/common'; + +describe('createSchemaFunctions', () => { + describe('when called and results have functions', () => { + it('then it should return correct result', () => { + const functions = [ + { name: 'some name', body: 'some body', displayName: 'some displayName', category: 'some category' }, + ]; + const parser = new ResponseParser({ functions }); + + const results = parser.createSchemaFunctions(); + + expect(results).toEqual({ + ['some name']: { + Body: 'some body', + DocString: 'some displayName', + Folder: 'some category', + FunctionKind: 'Unknown', + InputParameters: [], + Name: 'some name', + OutputColumns: [], + }, + }); + }); + }); + + describe('when called and results have no functions', () => { + it('then it should return an empty object', () => { + const parser = new ResponseParser({}); + + const results = parser.createSchemaFunctions(); + + expect(results).toEqual({}); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts new file mode 100644 index 0000000..1eeb16c --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/response_parser.ts @@ -0,0 +1,224 @@ +import _ from 'lodash'; +import { AnnotationEvent, dateTime, TimeSeries } from '@grafana/data'; +import { + AzureLogsTableData, + AzureLogsVariable, + KustoColumn, + KustoDatabase, + KustoFunction, + KustoSchema, + KustoTable, +} from '../types'; + +export default class ResponseParser { + columns: string[]; + constructor(private results: any) {} + + parseQueryResult(): any { + let data: any[] = []; + let columns: any[] = []; + for (let i = 0; i < this.results.length; i++) { + if (this.results[i].result.data.tables.length === 0) { + continue; + } + columns = this.results[i].result.data.tables[0].columns; + const rows = this.results[i].result.data.tables[0].rows; + + if (this.results[i].query.resultFormat === 'time_series') { + data = _.concat(data, this.parseTimeSeriesResult(this.results[i].query, columns, rows)); + } else { + data = _.concat(data, this.parseTableResult(this.results[i].query, columns, rows)); + } + } + + return data; + } + + parseTimeSeriesResult(query: { refId: string; query: any }, columns: any[], rows: any): TimeSeries[] { + const data: TimeSeries[] = []; + let timeIndex = -1; + let metricIndex = -1; + let valueIndex = -1; + + for (let i = 0; i < columns.length; i++) { + if (timeIndex === -1 && columns[i].type === 'datetime') { + timeIndex = i; + } + + if (metricIndex === -1 && columns[i].type === 'string') { + metricIndex = i; + } + + if (valueIndex === -1 && ['int', 'long', 'real', 'double'].indexOf(columns[i].type) > -1) { + valueIndex = i; + } + } + + if (timeIndex === -1) { + throw new Error('No datetime column found in the result. The Time Series format requires a time column.'); + } + + _.forEach(rows, (row) => { + const epoch = ResponseParser.dateTimeToEpoch(row[timeIndex]); + const metricName = metricIndex > -1 ? row[metricIndex] : columns[valueIndex].name; + const bucket = ResponseParser.findOrCreateBucket(data, metricName); + bucket.datapoints.push([row[valueIndex], epoch]); + bucket.refId = query.refId; + bucket.meta = { + executedQueryString: query.query, + }; + }); + + return data; + } + + parseTableResult(query: { refId: string; query: string }, columns: any[], rows: any[]): AzureLogsTableData { + const tableResult: AzureLogsTableData = { + type: 'table', + columns: _.map(columns, (col) => { + return { text: col.name, type: col.type }; + }), + rows: rows, + refId: query.refId, + meta: { + executedQueryString: query.query, + }, + }; + + return tableResult; + } + + parseToVariables(): AzureLogsVariable[] { + const queryResult = this.parseQueryResult(); + + const variables: AzureLogsVariable[] = []; + _.forEach(queryResult, (result) => { + _.forEach(_.flattenDeep(result.rows), (row) => { + variables.push({ + text: row, + value: row, + } as AzureLogsVariable); + }); + }); + + return variables; + } + + transformToAnnotations(options: any) { + const queryResult = this.parseQueryResult(); + + const list: AnnotationEvent[] = []; + + _.forEach(queryResult, (result) => { + let timeIndex = -1; + let textIndex = -1; + let tagsIndex = -1; + + for (let i = 0; i < result.columns.length; i++) { + if (timeIndex === -1 && result.columns[i].type === 'datetime') { + timeIndex = i; + } + + if (textIndex === -1 && result.columns[i].text.toLowerCase() === 'text') { + textIndex = i; + } + + if (tagsIndex === -1 && result.columns[i].text.toLowerCase() === 'tags') { + tagsIndex = i; + } + } + + _.forEach(result.rows, (row) => { + list.push({ + annotation: options.annotation, + time: Math.floor(ResponseParser.dateTimeToEpoch(row[timeIndex])), + text: row[textIndex] ? row[textIndex].toString() : '', + tags: row[tagsIndex] ? row[tagsIndex].trim().split(/\s*,\s*/) : [], + }); + }); + }); + + return list; + } + + parseSchemaResult(): KustoSchema { + return { + Plugins: [ + { + Name: 'pivot', + }, + ], + Databases: this.createSchemaDatabaseWithTables(), + }; + } + + createSchemaDatabaseWithTables(): { [key: string]: KustoDatabase } { + const databases = { + Default: { + Name: 'Default', + Tables: this.createSchemaTables(), + Functions: this.createSchemaFunctions(), + }, + }; + + return databases; + } + + createSchemaTables(): { [key: string]: KustoTable } { + const tables: { [key: string]: KustoTable } = {}; + + for (const table of this.results.tables) { + tables[table.name] = { + Name: table.name, + OrderedColumns: [], + }; + for (const col of table.columns) { + tables[table.name].OrderedColumns.push(this.convertToKustoColumn(col)); + } + } + + return tables; + } + + convertToKustoColumn(col: any): KustoColumn { + return { + Name: col.name, + Type: col.type, + }; + } + + createSchemaFunctions(): { [key: string]: KustoFunction } { + const functions: { [key: string]: KustoFunction } = {}; + if (!this.results.functions) { + return functions; + } + + for (const func of this.results.functions) { + functions[func.name] = { + Name: func.name, + Body: func.body, + DocString: func.displayName, + Folder: func.category, + FunctionKind: 'Unknown', + InputParameters: [], + OutputColumns: [], + }; + } + + return functions; + } + + static findOrCreateBucket(data: TimeSeries[], target: any): TimeSeries { + let dataTarget: any = _.find(data, ['target', target]); + if (!dataTarget) { + dataTarget = { target: target, datapoints: [], refId: '', query: '' }; + data.push(dataTarget); + } + + return dataTarget; + } + + static dateTimeToEpoch(dateTimeValue: any) { + return dateTime(dateTimeValue).valueOf(); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts new file mode 100644 index 0000000..f705441 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts @@ -0,0 +1,968 @@ +import AzureMonitorDatasource from '../datasource'; + +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { DataSourceInstanceSettings } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { AzureDataSourceJsonData } from '../types'; + +const templateSrv = new TemplateSrv(); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, + getTemplateSrv: () => templateSrv, +})); + +interface TestContext { + instanceSettings: DataSourceInstanceSettings; + ds: AzureMonitorDatasource; +} + +describe('AzureMonitorDatasource', () => { + const ctx: TestContext = {} as TestContext; + const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + + beforeEach(() => { + jest.clearAllMocks(); + ctx.instanceSettings = ({ + name: 'test', + url: 'http://azuremonitor.com', + jsonData: { subscriptionId: '9935389e-9122-4ef9-95f9-1513dd24753f' }, + cloudName: 'azuremonitor', + } as unknown) as DataSourceInstanceSettings; + ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings); + }); + + describe('When performing testDatasource', () => { + describe('and an error is returned', () => { + const error = { + data: { + error: { + code: 'InvalidApiVersionParameter', + message: `An error message.`, + }, + }, + status: 400, + statusText: 'Bad Request', + }; + + beforeEach(() => { + ctx.instanceSettings.jsonData.tenantId = 'xxx'; + ctx.instanceSettings.jsonData.clientId = 'xxx'; + datasourceRequestMock.mockImplementation(() => Promise.reject(error)); + }); + + it('should return error status and a detailed error message', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('error'); + expect(results.message).toEqual( + '1. Azure Monitor: Bad Request: InvalidApiVersionParameter. An error message. ' + ); + }); + }); + }); + + describe('and a list of resource groups is returned', () => { + const response = { + data: { + value: [{ name: 'grp1' }, { name: 'grp2' }], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + ctx.instanceSettings.jsonData.tenantId = 'xxx'; + ctx.instanceSettings.jsonData.clientId = 'xxx'; + datasourceRequestMock.mockImplementation(() => Promise.resolve({ data: response, status: 200 })); + }); + + it('should return success status', () => { + return ctx.ds.testDatasource().then((results: any) => { + expect(results.status).toEqual('success'); + }); + }); + }); + }); + + describe('When performing metricFindQuery', () => { + describe('with a subscriptions query', () => { + const response = { + data: { + value: [ + { displayName: 'Primary', subscriptionId: 'sub1' }, + { displayName: 'Secondary', subscriptionId: 'sub2' }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => Promise.resolve(response)); + }); + + it('should return a list of subscriptions', () => { + return ctx.ds.metricFindQuery('subscriptions()').then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toBe(2); + expect(results[0].text).toBe('Primary - sub1'); + expect(results[0].value).toBe('sub1'); + expect(results[1].text).toBe('Secondary - sub2'); + expect(results[1].value).toBe('sub2'); + }); + }); + }); + + describe('with a resource groups query', () => { + const response = { + data: { + value: [{ name: 'grp1' }, { name: 'grp2' }], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => Promise.resolve(response)); + }); + + it('should return a list of resource groups', () => { + return ctx.ds.metricFindQuery('ResourceGroups()').then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toBe(2); + expect(results[0].text).toBe('grp1'); + expect(results[0].value).toBe('grp1'); + expect(results[1].text).toBe('grp2'); + expect(results[1].value).toBe('grp2'); + }); + }); + }); + + describe('with a resource groups query that specifies a subscription id', () => { + const response = { + data: { + value: [{ name: 'grp1' }, { name: 'grp2' }], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + expect(options.url).toContain('11112222-eeee-4949-9b2d-9106972f9123'); + return Promise.resolve(response); + }); + }); + + it('should return a list of resource groups', () => { + return ctx.ds + .metricFindQuery('ResourceGroups(11112222-eeee-4949-9b2d-9106972f9123)') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toBe(2); + expect(results[0].text).toBe('grp1'); + expect(results[0].value).toBe('grp1'); + expect(results[1].text).toBe('grp2'); + expect(results[1].value).toBe('grp2'); + }); + }); + }); + + describe('with namespaces query', () => { + const response = { + data: { + value: [ + { + name: 'test', + type: 'Microsoft.Network/networkInterfaces', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodesapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return a list of namespaces', () => { + return ctx.ds + .metricFindQuery('Namespaces(nodesapp)') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('Microsoft.Network/networkInterfaces'); + expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces'); + }); + }); + }); + + describe('with namespaces query that specifies a subscription id', () => { + const response = { + data: { + value: [ + { + name: 'test', + type: 'Microsoft.Network/networkInterfaces', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/11112222-eeee-4949-9b2d-9106972f9123/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodesapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return a list of namespaces', () => { + return ctx.ds + .metricFindQuery('namespaces(11112222-eeee-4949-9b2d-9106972f9123, nodesapp)') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('Microsoft.Network/networkInterfaces'); + expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces'); + }); + }); + }); + + describe('with resource names query', () => { + const response = { + data: { + value: [ + { + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', + }, + { + name: 'nodeapp', + type: 'microsoft.insights/components', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return a list of resource names', () => { + return ctx.ds + .metricFindQuery('resourceNames(nodeapp, microsoft.insights/components )') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + }); + }); + }); + + describe('with resource names query and that specifies a subscription id', () => { + const response = { + data: { + value: [ + { + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', + }, + { + name: 'nodeapp', + type: 'microsoft.insights/components', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/11112222-eeee-4949-9b2d-9106972f9123/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return a list of resource names', () => { + return ctx.ds + .metricFindQuery( + 'resourceNames(11112222-eeee-4949-9b2d-9106972f9123, nodeapp, microsoft.insights/components )' + ) + .then((results: any) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + }); + }); + }); + + describe('with metric names query', () => { + const response = { + data: { + value: [ + { + name: { + value: 'Percentage CPU', + localizedValue: 'Percentage CPU', + }, + }, + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe( + baseUrl + + '/nodeapp/providers/microsoft.insights/components/rn/providers/microsoft.insights/' + + 'metricdefinitions?api-version=2018-01-01&metricnamespace=default' + ); + return Promise.resolve(response); + }); + }); + + it('should return a list of metric names', () => { + return ctx.ds + .metricFindQuery('Metricnames(nodeapp, microsoft.insights/components, rn, default)') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Percentage CPU'); + expect(results[0].value).toEqual('Percentage CPU'); + + expect(results[1].text).toEqual('Used capacity'); + expect(results[1].value).toEqual('UsedCapacity'); + }); + }); + }); + + describe('with metric names query and specifies a subscription id', () => { + const response = { + data: { + value: [ + { + name: { + value: 'Percentage CPU', + localizedValue: 'Percentage CPU', + }, + }, + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/11112222-eeee-4949-9b2d-9106972f9123/resourceGroups'; + expect(options.url).toBe( + baseUrl + + '/nodeapp/providers/microsoft.insights/components/rn/providers/microsoft.insights/' + + 'metricdefinitions?api-version=2018-01-01&metricnamespace=default' + ); + return Promise.resolve(response); + }); + }); + + it('should return a list of metric names', () => { + return ctx.ds + .metricFindQuery( + 'Metricnames(11112222-eeee-4949-9b2d-9106972f9123, nodeapp, microsoft.insights/components, rn, default)' + ) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Percentage CPU'); + expect(results[0].value).toEqual('Percentage CPU'); + + expect(results[1].text).toEqual('Used capacity'); + expect(results[1].value).toEqual('UsedCapacity'); + }); + }); + }); + + describe('with metric namespace query', () => { + const response = { + data: { + value: [ + { + name: 'Microsoft.Compute-virtualMachines', + properties: { + metricNamespaceName: 'Microsoft.Compute/virtualMachines', + }, + }, + { + name: 'Telegraf-mem', + properties: { + metricNamespaceName: 'Telegraf/mem', + }, + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe( + baseUrl + + '/nodeapp/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview' + ); + return Promise.resolve(response); + }); + }); + + it('should return a list of metric names', () => { + return ctx.ds + .metricFindQuery('Metricnamespace(nodeapp, Microsoft.Compute/virtualMachines, rn)') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Microsoft.Compute-virtualMachines'); + expect(results[0].value).toEqual('Microsoft.Compute/virtualMachines'); + + expect(results[1].text).toEqual('Telegraf-mem'); + expect(results[1].value).toEqual('Telegraf/mem'); + }); + }); + }); + + describe('with metric namespace query and specifies a subscription id', () => { + const response = { + data: { + value: [ + { + name: 'Microsoft.Compute-virtualMachines', + properties: { + metricNamespaceName: 'Microsoft.Compute/virtualMachines', + }, + }, + { + name: 'Telegraf-mem', + properties: { + metricNamespaceName: 'Telegraf/mem', + }, + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/11112222-eeee-4949-9b2d-9106972f9123/resourceGroups'; + expect(options.url).toBe( + baseUrl + + '/nodeapp/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview' + ); + return Promise.resolve(response); + }); + }); + + it('should return a list of metric namespaces', () => { + return ctx.ds + .metricFindQuery( + 'Metricnamespace(11112222-eeee-4949-9b2d-9106972f9123, nodeapp, Microsoft.Compute/virtualMachines, rn)' + ) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Microsoft.Compute-virtualMachines'); + expect(results[0].value).toEqual('Microsoft.Compute/virtualMachines'); + + expect(results[1].text).toEqual('Telegraf-mem'); + expect(results[1].value).toEqual('Telegraf/mem'); + }); + }); + }); + }); + + describe('When performing getSubscriptions', () => { + const response = { + data: { + value: [ + { + id: '/subscriptions/99999999-cccc-bbbb-aaaa-9106972f9572', + subscriptionId: '99999999-cccc-bbbb-aaaa-9106972f9572', + tenantId: '99999999-aaaa-bbbb-cccc-51c4f982ec48', + displayName: 'Primary Subscription', + state: 'Enabled', + subscriptionPolicies: { + locationPlacementId: 'Public_2014-09-01', + quotaId: 'PayAsYouGo_2014-09-01', + spendingLimit: 'Off', + }, + authorizationSource: 'RoleBased', + }, + ], + count: { + type: 'Total', + value: 1, + }, + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => Promise.resolve(response)); + }); + + it('should return list of Resource Groups', () => { + return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('Primary Subscription - 99999999-cccc-bbbb-aaaa-9106972f9572'); + expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572'); + }); + }); + }); + + describe('When performing getResourceGroups', () => { + const response = { + data: { + value: [{ name: 'grp1' }, { name: 'grp2' }], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation(() => Promise.resolve(response)); + }); + + it('should return list of Resource Groups', () => { + return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('grp1'); + expect(results[0].value).toEqual('grp1'); + expect(results[1].text).toEqual('grp2'); + expect(results[1].value).toEqual('grp2'); + }); + }); + }); + + describe('When performing getMetricDefinitions', () => { + const response = { + data: { + value: [ + { + name: 'test', + type: 'Microsoft.Network/networkInterfaces', + }, + { + location: 'northeurope', + name: 'northeur', + type: 'Microsoft.Compute/virtualMachines', + }, + { + location: 'westcentralus', + name: 'us', + type: 'Microsoft.Compute/virtualMachines', + }, + { + name: 'IHaveNoMetrics', + type: 'IShouldBeFilteredOut', + }, + { + name: 'storageTest', + type: 'Microsoft.Storage/storageAccounts', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodesapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => { + return ctx.ds + .getMetricDefinitions('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodesapp') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(7); + expect(results[0].text).toEqual('Microsoft.Network/networkInterfaces'); + expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces'); + expect(results[1].text).toEqual('Microsoft.Compute/virtualMachines'); + expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines'); + expect(results[2].text).toEqual('Microsoft.Storage/storageAccounts'); + expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts'); + expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices'); + expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices'); + expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices'); + expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices'); + expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices'); + expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices'); + expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices'); + expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices'); + }); + }); + }); + + describe('When performing getResourceNames', () => { + describe('and there are no special cases', () => { + const response = { + data: { + value: [ + { + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', + }, + { + name: 'nodeapp', + type: 'microsoft.insights/components', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return list of Resource Names', () => { + return ctx.ds + .getResourceNames('9935389e-9122-4ef9-95f9-1513dd24753f', 'nodeapp', 'microsoft.insights/components') + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('nodeapp'); + expect(results[0].value).toEqual('nodeapp'); + }); + }); + }); + + describe('and the metric definition is blobServices', () => { + const response = { + data: { + value: [ + { + name: 'Failure Anomalies - nodeapp', + type: 'microsoft.insights/alertrules', + }, + { + name: 'storagetest', + type: 'Microsoft.Storage/storageAccounts', + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups'; + expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01'); + return Promise.resolve(response); + }); + }); + + it('should return list of Resource Names', () => { + return ctx.ds + .getResourceNames( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'Microsoft.Storage/storageAccounts/blobServices' + ) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(1); + expect(results[0].text).toEqual('storagetest/default'); + expect(results[0].value).toEqual('storagetest/default'); + }); + }); + }); + }); + + describe('When performing getMetricNames', () => { + const response = { + data: { + value: [ + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + baseUrl + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(options.url).toBe(expected); + return Promise.resolve(response); + }); + }); + + it('should return list of Metric Definitions', () => { + return ctx.ds + .getMetricNames( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default' + ) + .then((results: Array<{ text: string; value: string }>) => { + expect(results.length).toEqual(2); + expect(results[0].text).toEqual('Used capacity'); + expect(results[0].value).toEqual('UsedCapacity'); + expect(results[1].text).toEqual('Free capacity'); + expect(results[1].value).toEqual('FreeCapacity'); + }); + }); + }); + + describe('When performing getMetricMetadata', () => { + const response = { + data: { + value: [ + { + name: { + value: 'UsedCapacity', + localizedValue: 'Used capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + metricAvailabilities: [ + { timeGrain: 'PT1H', retention: 'P93D' }, + { timeGrain: 'PT6H', retention: 'P93D' }, + { timeGrain: 'PT12H', retention: 'P93D' }, + { timeGrain: 'P1D', retention: 'P93D' }, + ], + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + baseUrl + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(options.url).toBe(expected); + return Promise.resolve(response); + }); + }); + + it('should return Aggregation metadata for a Metric', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'UsedCapacity' + ) + .then((results: any) => { + expect(results.primaryAggType).toEqual('Total'); + expect(results.supportedAggTypes.length).toEqual(6); + expect(results.supportedTimeGrains.length).toEqual(4); + }); + }); + }); + + describe('When performing getMetricMetadata on metrics with dimensions', () => { + const response = { + data: { + value: [ + { + name: { + value: 'Transactions', + localizedValue: 'Transactions', + }, + unit: 'Count', + primaryAggregationType: 'Total', + supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'], + isDimensionRequired: false, + dimensions: [ + { + value: 'ResponseType', + localizedValue: 'Response type', + }, + { + value: 'GeoType', + localizedValue: 'Geo type', + }, + { + value: 'ApiName', + localizedValue: 'API name', + }, + ], + }, + { + name: { + value: 'FreeCapacity', + localizedValue: 'Free capacity', + }, + unit: 'CountPerSecond', + primaryAggregationType: 'Average', + supportedAggregationTypes: ['None', 'Average'], + }, + ], + }, + status: 200, + statusText: 'OK', + }; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: { url: string }) => { + const baseUrl = + 'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp'; + const expected = + baseUrl + + '/providers/microsoft.insights/components/resource1' + + '/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01&metricnamespace=default'; + expect(options.url).toBe(expected); + return Promise.resolve(response); + }); + }); + + it('should return dimensions for a Metric that has dimensions', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'Transactions' + ) + .then((results: any) => { + expect(results.dimensions).toMatchInlineSnapshot(` + Array [ + Object { + "text": "Response type", + "value": "ResponseType", + }, + Object { + "text": "Geo type", + "value": "GeoType", + }, + Object { + "text": "API name", + "value": "ApiName", + }, + ] + `); + }); + }); + + it('should return an empty array for a Metric that does not have dimensions', () => { + return ctx.ds + .getMetricMetadata( + '9935389e-9122-4ef9-95f9-1513dd24753f', + 'nodeapp', + 'microsoft.insights/components', + 'resource1', + 'default', + 'FreeCapacity' + ) + .then((results: any) => { + expect(results.dimensions.length).toEqual(0); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts new file mode 100644 index 0000000..d8c3866 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -0,0 +1,417 @@ +import { filter, startsWith } from 'lodash'; +import UrlBuilder from './url_builder'; +import ResponseParser from './response_parser'; +import SupportedNamespaces from './supported_namespaces'; +import TimegrainConverter from '../time_grain_converter'; +import { + AzureMonitorQuery, + AzureDataSourceJsonData, + AzureMonitorMetricDefinitionsResponse, + AzureMonitorResourceGroupsResponse, + AzureQueryType, +} from '../types'; +import { DataSourceInstanceSettings, ScopedVars, MetricFindValue } from '@grafana/data'; +import { getBackendSrv, DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; + +const defaultDropdownValue = 'select'; + +export default class AzureMonitorDatasource extends DataSourceWithBackend { + apiVersion = '2018-01-01'; + apiPreviewVersion = '2017-12-01-preview'; + subscriptionId: string; + baseUrl: string; + resourceGroup: string; + resourceName: string; + url: string; + cloudName: string; + supportedMetricNamespaces: string[] = []; + + constructor(private instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + + this.subscriptionId = instanceSettings.jsonData.subscriptionId; + this.cloudName = instanceSettings.jsonData.cloudName || 'azuremonitor'; + this.baseUrl = `/${this.cloudName}/subscriptions`; + this.url = instanceSettings.url!; + this.supportedMetricNamespaces = new SupportedNamespaces(this.cloudName).get(); + } + + isConfigured(): boolean { + return !!this.subscriptionId && this.subscriptionId.length > 0; + } + + filterQuery(item: AzureMonitorQuery): boolean { + return ( + item.hide !== true && + item.azureMonitor.resourceGroup !== defaultDropdownValue && + item.azureMonitor.resourceName !== defaultDropdownValue && + item.azureMonitor.metricDefinition !== defaultDropdownValue && + item.azureMonitor.metricName !== defaultDropdownValue + ); + } + + applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record { + const item = target.azureMonitor; + + // fix for timeGrainUnit which is a deprecated/removed field name + if (item.timeGrainUnit && item.timeGrain !== 'auto') { + item.timeGrain = TimegrainConverter.createISO8601Duration(item.timeGrain, item.timeGrainUnit); + } + + const templateSrv = getTemplateSrv(); + + const subscriptionId = templateSrv.replace(target.subscription || this.subscriptionId, scopedVars); + const resourceGroup = templateSrv.replace(item.resourceGroup, scopedVars); + const resourceName = templateSrv.replace(item.resourceName, scopedVars); + const metricNamespace = templateSrv.replace(item.metricNamespace, scopedVars); + const metricDefinition = templateSrv.replace(item.metricDefinition, scopedVars); + const timeGrain = templateSrv.replace((item.timeGrain || '').toString(), scopedVars); + const aggregation = templateSrv.replace(item.aggregation, scopedVars); + const top = templateSrv.replace(item.top || '', scopedVars); + + const dimensionFilters = item.dimensionFilters + .filter((f) => f.dimension && f.dimension !== 'None') + .map((f) => { + const filter = templateSrv.replace(f.filter ?? '', scopedVars); + return { + dimension: templateSrv.replace(f.dimension, scopedVars), + operator: f.operator || 'eq', + filter: filter || '*', // send * when empty + }; + }); + + return { + refId: target.refId, + subscription: subscriptionId, + queryType: AzureQueryType.AzureMonitor, + azureMonitor: { + resourceGroup, + resourceName, + metricDefinition, + timeGrain, + allowedTimeGrainsMs: item.allowedTimeGrainsMs, + metricName: templateSrv.replace(item.metricName, scopedVars), + metricNamespace: + metricNamespace && metricNamespace !== defaultDropdownValue ? metricNamespace : metricDefinition, + aggregation: aggregation, + dimensionFilters, + top: top || '10', + alias: item.alias, + format: target.format, + }, + }; + } + + /** + * This is named differently than DataSourceApi.metricFindQuery + * because it's not exposed to Grafana like the main AzureMonitorDataSource. + * And some of the azure internal data sources return null in this function, which the + * external interface does not support + */ + metricFindQueryInternal(query: string): Promise | null { + const subscriptionsQuery = query.match(/^Subscriptions\(\)/i); + if (subscriptionsQuery) { + return this.getSubscriptions(); + } + + const resourceGroupsQuery = query.match(/^ResourceGroups\(\)/i); + if (resourceGroupsQuery) { + return this.getResourceGroups(this.subscriptionId); + } + + const resourceGroupsQueryWithSub = query.match(/^ResourceGroups\(([^\)]+?)(,\s?([^,]+?))?\)/i); + if (resourceGroupsQueryWithSub) { + return this.getResourceGroups(this.toVariable(resourceGroupsQueryWithSub[1])); + } + + const metricDefinitionsQuery = query.match(/^Namespaces\(([^\)]+?)(,\s?([^,]+?))?\)/i); + if (metricDefinitionsQuery) { + if (!metricDefinitionsQuery[3]) { + return this.getMetricDefinitions(this.subscriptionId, this.toVariable(metricDefinitionsQuery[1])); + } + } + + const metricDefinitionsQueryWithSub = query.match(/^Namespaces\(([^,]+?),\s?([^,]+?)\)/i); + if (metricDefinitionsQueryWithSub) { + return this.getMetricDefinitions( + this.toVariable(metricDefinitionsQueryWithSub[1]), + this.toVariable(metricDefinitionsQueryWithSub[2]) + ); + } + + const resourceNamesQuery = query.match(/^ResourceNames\(([^,]+?),\s?([^,]+?)\)/i); + if (resourceNamesQuery) { + const resourceGroup = this.toVariable(resourceNamesQuery[1]); + const metricDefinition = this.toVariable(resourceNamesQuery[2]); + return this.getResourceNames(this.subscriptionId, resourceGroup, metricDefinition); + } + + const resourceNamesQueryWithSub = query.match(/^ResourceNames\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/i); + if (resourceNamesQueryWithSub) { + const subscription = this.toVariable(resourceNamesQueryWithSub[1]); + const resourceGroup = this.toVariable(resourceNamesQueryWithSub[2]); + const metricDefinition = this.toVariable(resourceNamesQueryWithSub[3]); + return this.getResourceNames(subscription, resourceGroup, metricDefinition); + } + + const metricNamespaceQuery = query.match(/^MetricNamespace\(([^,]+?),\s?([^,]+?),\s?([^,]+?)\)/i); + if (metricNamespaceQuery) { + const resourceGroup = this.toVariable(metricNamespaceQuery[1]); + const metricDefinition = this.toVariable(metricNamespaceQuery[2]); + const resourceName = this.toVariable(metricNamespaceQuery[3]); + return this.getMetricNamespaces(this.subscriptionId, resourceGroup, metricDefinition, resourceName); + } + + const metricNamespaceQueryWithSub = query.match( + /^metricnamespace\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)\)/i + ); + if (metricNamespaceQueryWithSub) { + const subscription = this.toVariable(metricNamespaceQueryWithSub[1]); + const resourceGroup = this.toVariable(metricNamespaceQueryWithSub[2]); + const metricDefinition = this.toVariable(metricNamespaceQueryWithSub[3]); + const resourceName = this.toVariable(metricNamespaceQueryWithSub[4]); + return this.getMetricNamespaces(subscription, resourceGroup, metricDefinition, resourceName); + } + + const metricNamesQuery = query.match(/^MetricNames\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)\)/i); + if (metricNamesQuery) { + if (metricNamesQuery[3].indexOf(',') === -1) { + const resourceGroup = this.toVariable(metricNamesQuery[1]); + const metricDefinition = this.toVariable(metricNamesQuery[2]); + const resourceName = this.toVariable(metricNamesQuery[3]); + const metricNamespace = this.toVariable(metricNamesQuery[4]); + return this.getMetricNames(this.subscriptionId, resourceGroup, metricDefinition, resourceName, metricNamespace); + } + } + + const metricNamesQueryWithSub = query.match( + /^MetricNames\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?(.+?)\)/i + ); + + if (metricNamesQueryWithSub) { + const subscription = this.toVariable(metricNamesQueryWithSub[1]); + const resourceGroup = this.toVariable(metricNamesQueryWithSub[2]); + const metricDefinition = this.toVariable(metricNamesQueryWithSub[3]); + const resourceName = this.toVariable(metricNamesQueryWithSub[4]); + const metricNamespace = this.toVariable(metricNamesQueryWithSub[5]); + return this.getMetricNames(subscription, resourceGroup, metricDefinition, resourceName, metricNamespace); + } + + return null; + } + + toVariable(metric: string) { + return getTemplateSrv().replace((metric || '').trim()); + } + + getSubscriptions(route?: string) { + const url = `/${route || this.cloudName}/subscriptions?api-version=2019-03-01`; + return this.doRequest(url).then((result: any) => { + return ResponseParser.parseSubscriptions(result); + }); + } + + getResourceGroups(subscriptionId: string) { + const url = `${this.baseUrl}/${subscriptionId}/resourceGroups?api-version=${this.apiVersion}`; + return this.doRequest(url).then((result: AzureMonitorResourceGroupsResponse) => { + return ResponseParser.parseResponseValues(result, 'name', 'name'); + }); + } + + getMetricDefinitions(subscriptionId: string, resourceGroup: string) { + const url = `${this.baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/resources?api-version=${this.apiVersion}`; + return this.doRequest(url) + .then((result: AzureMonitorMetricDefinitionsResponse) => { + return ResponseParser.parseResponseValues(result, 'type', 'type'); + }) + .then((result: any) => { + return filter(result, (t) => { + for (let i = 0; i < this.supportedMetricNamespaces.length; i++) { + if (t.value.toLowerCase() === this.supportedMetricNamespaces[i].toLowerCase()) { + return true; + } + } + + return false; + }); + }) + .then((result: any) => { + let shouldHardcodeBlobStorage = false; + for (let i = 0; i < result.length; i++) { + if (result[i].value === 'Microsoft.Storage/storageAccounts') { + shouldHardcodeBlobStorage = true; + break; + } + } + + if (shouldHardcodeBlobStorage) { + result.push({ + text: 'Microsoft.Storage/storageAccounts/blobServices', + value: 'Microsoft.Storage/storageAccounts/blobServices', + }); + result.push({ + text: 'Microsoft.Storage/storageAccounts/fileServices', + value: 'Microsoft.Storage/storageAccounts/fileServices', + }); + result.push({ + text: 'Microsoft.Storage/storageAccounts/tableServices', + value: 'Microsoft.Storage/storageAccounts/tableServices', + }); + result.push({ + text: 'Microsoft.Storage/storageAccounts/queueServices', + value: 'Microsoft.Storage/storageAccounts/queueServices', + }); + } + + return result; + }); + } + + getResourceNames(subscriptionId: string, resourceGroup: string, metricDefinition: string) { + const url = `${this.baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/resources?api-version=${this.apiVersion}`; + + return this.doRequest(url).then((result: any) => { + if (!startsWith(metricDefinition, 'Microsoft.Storage/storageAccounts/')) { + return ResponseParser.parseResourceNames(result, metricDefinition); + } + + const list = ResponseParser.parseResourceNames(result, 'Microsoft.Storage/storageAccounts'); + for (let i = 0; i < list.length; i++) { + list[i].text += '/default'; + list[i].value += '/default'; + } + + return list; + }); + } + + getMetricNamespaces(subscriptionId: string, resourceGroup: string, metricDefinition: string, resourceName: string) { + const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( + this.baseUrl, + subscriptionId, + resourceGroup, + metricDefinition, + resourceName, + this.apiPreviewVersion + ); + + return this.doRequest(url).then((result: any) => { + return ResponseParser.parseResponseValues(result, 'name', 'properties.metricNamespaceName'); + }); + } + + getMetricNames( + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + metricNamespace: string + ) { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + this.baseUrl, + subscriptionId, + resourceGroup, + metricDefinition, + resourceName, + metricNamespace, + this.apiVersion + ); + + return this.doRequest(url).then((result: any) => { + return ResponseParser.parseResponseValues(result, 'name.localizedValue', 'name.value'); + }); + } + + getMetricMetadata( + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + metricNamespace: string, + metricName: string + ) { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + this.baseUrl, + subscriptionId, + resourceGroup, + metricDefinition, + resourceName, + metricNamespace, + this.apiVersion + ); + + return this.doRequest(url).then((result: any) => { + return ResponseParser.parseMetadata(result, metricName); + }); + } + + testDatasource(): Promise { + if (!this.isValidConfigField(this.instanceSettings.jsonData.tenantId)) { + return Promise.resolve({ + status: 'error', + message: 'The Tenant Id field is required.', + }); + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.clientId)) { + return Promise.resolve({ + status: 'error', + message: 'The Client Id field is required.', + }); + } + + const url = `/${this.cloudName}/subscriptions?api-version=2019-03-01`; + return this.doRequest(url) + .then((response: any) => { + if (response.status === 200) { + return { + status: 'success', + message: 'Successfully queried the Azure Monitor service.', + title: 'Success', + }; + } + + return { + status: 'error', + message: 'Returned http status code ' + response.status, + }; + }) + .catch((error: any) => { + let message = 'Azure Monitor: '; + message += error.statusText ? error.statusText + ': ' : ''; + + if (error.data && error.data.error && error.data.error.code) { + message += error.data.error.code + '. ' + error.data.error.message; + } else if (error.data && error.data.error) { + message += error.data.error; + } else if (error.data) { + message += error.data; + } else { + message += 'Cannot connect to Azure Monitor REST API.'; + } + return { + status: 'error', + message: message, + }; + }); + } + + isValidConfigField(field?: string) { + return field && field.length > 0; + } + + doRequest(url: string, maxRetries = 1): Promise { + return getBackendSrv() + .datasourceRequest({ + url: this.url + url, + method: 'GET', + }) + .catch((error: any) => { + if (maxRetries > 0) { + return this.doRequest(url, maxRetries - 1); + } + + throw error; + }); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts new file mode 100644 index 0000000..bcfeb9d --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import TimeGrainConverter from '../time_grain_converter'; +export default class ResponseParser { + static parseResponseValues( + result: any, + textFieldName: string, + valueFieldName: string + ): Array<{ text: string; value: string }> { + const list: Array<{ text: string; value: string }> = []; + + if (!result) { + return list; + } + + for (let i = 0; i < result.data.value.length; i++) { + if (!_.find(list, ['value', _.get(result.data.value[i], valueFieldName)])) { + const value = _.get(result.data.value[i], valueFieldName); + const text = _.get(result.data.value[i], textFieldName, value); + + list.push({ + text: text, + value: value, + }); + } + } + return list; + } + + static parseResourceNames(result: any, metricDefinition: string): Array<{ text: string; value: string }> { + const list: Array<{ text: string; value: string }> = []; + + if (!result) { + return list; + } + + for (let i = 0; i < result.data.value.length; i++) { + if (result.data.value[i].type === metricDefinition) { + list.push({ + text: result.data.value[i].name, + value: result.data.value[i].name, + }); + } + } + + return list; + } + + static parseMetadata(result: any, metricName: string) { + const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count']; + + if (!result) { + return { + primaryAggType: '', + supportedAggTypes: defaultAggTypes, + supportedTimeGrains: [], + dimensions: [], + }; + } + + const metricData: any = _.find(result.data.value, (o) => { + return _.get(o, 'name.value') === metricName; + }); + + return { + primaryAggType: metricData.primaryAggregationType, + supportedAggTypes: metricData.supportedAggregationTypes || defaultAggTypes, + supportedTimeGrains: ResponseParser.parseTimeGrains(metricData.metricAvailabilities || []), + dimensions: ResponseParser.parseDimensions(metricData), + }; + } + + static parseTimeGrains(metricAvailabilities: any[]): Array<{ text: string; value: string }> { + const timeGrains: any[] = []; + if (!metricAvailabilities) { + return timeGrains; + } + + metricAvailabilities.forEach((avail) => { + if (avail.timeGrain) { + timeGrains.push({ + text: TimeGrainConverter.createTimeGrainFromISO8601Duration(avail.timeGrain), + value: avail.timeGrain, + }); + } + }); + return timeGrains; + } + + static parseDimensions(metricData: any): Array<{ text: string; value: string }> { + const dimensions: Array<{ text: string; value: string }> = []; + if (!metricData.dimensions || metricData.dimensions.length === 0) { + return dimensions; + } + + for (let i = 0; i < metricData.dimensions.length; i++) { + const text = metricData.dimensions[i].localizedValue; + const value = metricData.dimensions[i].value; + + dimensions.push({ + text: !text ? value : text, + value: value, + }); + } + return dimensions; + } + + static parseSubscriptions(result: any): Array<{ text: string; value: string }> { + const list: Array<{ text: string; value: string }> = []; + + if (!result) { + return list; + } + + const valueFieldName = 'subscriptionId'; + const textFieldName = 'displayName'; + for (let i = 0; i < result.data.value.length; i++) { + if (!_.find(list, ['value', _.get(result.data.value[i], valueFieldName)])) { + list.push({ + text: `${_.get(result.data.value[i], textFieldName)} - ${_.get(result.data.value[i], valueFieldName)}`, + value: _.get(result.data.value[i], valueFieldName), + }); + } + } + + return list; + } + + static parseSubscriptionsForSelect(result: any): Array<{ label: string; value: string }> { + const list: Array<{ label: string; value: string }> = []; + + if (!result) { + return list; + } + + const valueFieldName = 'subscriptionId'; + const textFieldName = 'displayName'; + for (let i = 0; i < result.data.value.length; i++) { + if (!_.find(list, ['value', _.get(result.data.value[i], valueFieldName)])) { + list.push({ + label: `${_.get(result.data.value[i], textFieldName)} - ${_.get(result.data.value[i], valueFieldName)}`, + value: _.get(result.data.value[i], valueFieldName), + }); + } + } + + return list; + } + + static parseWorkspacesForSelect(result: any): Array<{ label: string; value: string }> { + const list: Array<{ label: string; value: string }> = []; + + if (!result) { + return list; + } + + const valueFieldName = 'customerId'; + const textFieldName = 'name'; + for (let i = 0; i < result.data.value.length; i++) { + if (!_.find(list, ['value', _.get(result.data.value[i].properties, valueFieldName)])) { + list.push({ + label: _.get(result.data.value[i], textFieldName), + value: _.get(result.data.value[i].properties, valueFieldName), + }); + } + } + + return list; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts new file mode 100644 index 0000000..95e5c92 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts @@ -0,0 +1,247 @@ +export default class SupportedNamespaces { + supportedMetricNamespaces: any = { + azuremonitor: [ + 'Microsoft.AnalysisServices/servers', + 'Microsoft.ApiManagement/service', + 'Microsoft.AppConfiguration/configurationStores', + 'Microsoft.Automation/automationAccounts', + 'Microsoft.Batch/batchAccounts', + 'Microsoft.Cache/redis', + 'Microsoft.ClassicCompute/virtualMachines', + 'Microsoft.ClassicCompute/domainNames/slots/roles', + 'Microsoft.CognitiveServices/accounts', + 'Microsoft.Compute/virtualMachines', + 'Microsoft.Compute/virtualMachineScaleSets', + 'Microsoft.ContainerInstance/containerGroups', + 'Microsoft.ContainerRegistry/registries', + 'Microsoft.ContainerService/managedClusters', + 'Microsoft.CustomerInsights/hubs', + 'Microsoft.DataBoxEdge/dataBoxEdgeDevices', + 'Microsoft.DataFactory/datafactories', + 'Microsoft.DataFactory/factories', + 'Microsoft.DataLakeAnalytics/accounts', + 'Microsoft.DataLakeStore/accounts', + 'Microsoft.DBforMariaDB/servers', + 'Microsoft.DBforMySQL/servers', + 'Microsoft.DBforPostgreSQL/servers', + 'Microsoft.Devices/IotHubs', + 'Microsoft.Devices/provisioningServices', + 'Microsoft.DocumentDB/databaseAccounts', + 'Microsoft.EventGrid/topics', + 'Microsoft.EventGrid/eventSubscriptions', + 'Microsoft.EventGrid/extensionTopics', + 'Microsoft.EventHub/namespaces', + 'Microsoft.EventHub/clusters', + 'Microsoft.HDInsight/clusters', + 'Microsoft.Insights/AutoscaleSettings', + 'Microsoft.Insights/components', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Kusto/clusters', + 'Microsoft.LocationBasedServices/accounts', + 'Microsoft.Logic/workflows', + 'Microsoft.Logic/integrationServiceEnvironments', + 'Microsoft.NetApp/netAppAccounts/capacityPools', + 'Microsoft.NetApp/netAppAccounts/capacityPools/volumes', + 'Microsoft.Network/networkInterfaces', + 'Microsoft.Network/loadBalancers', + 'Microsoft.Network/dnsZones', + 'Microsoft.Network/publicIPAddresses', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/applicationGateways', + 'Microsoft.Network/virtualNetworkGateways', + 'Microsoft.Network/expressRouteCircuits', + 'Microsoft.Network/expressRouteCircuits/Peerings', + 'Microsoft.Network/connections', + 'Microsoft.Network/trafficManagerProfiles', + 'Microsoft.Network/networkWatchers/connectionMonitors', + 'Microsoft.Network/frontdoors', + 'Microsoft.Network/natGateways', + 'Microsoft.Network/vpngateways', + 'Microsoft.Network/virtualNetworkGateways', + 'Microsoft.NotificationHubs/namespaces/notificationHubs', + 'Microsoft.OperationalInsights/workspaces', + 'Microsoft.PowerBIDedicated/capacities', + 'Microsoft.Relay/namespaces', + 'Microsoft.Search/searchServices', + 'Microsoft.ServiceBus/namespaces', + 'Microsoft.Sql/servers/databases', + 'Microsoft.Sql/servers/elasticPools', + 'Microsoft.Sql/managedInstances', + 'Microsoft.Storage/storageAccounts', + 'Microsoft.Storage/storageAccounts/blobServices', + 'Microsoft.Storage/storageAccounts/fileServices', + 'Microsoft.Storage/storageAccounts/queueServices', + 'Microsoft.Storage/storageAccounts/tableServices', + 'Microsoft.StorageSync/storageSyncServices', + 'Microsoft.StorageSync/storageSyncServices/syncGroups', + 'Microsoft.StorageSync/storageSyncServices/syncGroups/serverEndpoints', + 'Microsoft.StorageSync/storageSyncServices/registeredServers', + 'Microsoft.StreamAnalytics/streamingJobs', + 'Microsoft.Web/serverfarms', + 'Microsoft.Web/sites', + 'Microsoft.Web/sites/slots', + 'Microsoft.Web/hostingEnvironments/multiRolePools', + 'Microsoft.Web/hostingEnvironments/workerPools', + ], + govazuremonitor: [ + 'Microsoft.AnalysisServices/servers', + 'Microsoft.ApiManagement/service', + 'Microsoft.Batch/batchAccounts', + 'Microsoft.Cache/redis', + 'Microsoft.ClassicCompute/virtualMachines', + 'Microsoft.ClassicCompute/domainNames/slots/roles', + 'Microsoft.CognitiveServices/accounts', + 'Microsoft.Compute/virtualMachines', + 'Microsoft.Compute/virtualMachineScaleSets', + 'Microsoft.ContainerRegistry/registries', + 'Microsoft.DBforMySQL/servers', + 'Microsoft.DBforPostgreSQL/servers', + 'Microsoft.Devices/IotHubs', + 'Microsoft.Devices/provisioningServices', + 'Microsoft.EventGrid/topics', + 'Microsoft.EventGrid/eventSubscriptions', + 'Microsoft.EventGrid/extensionTopics', + 'Microsoft.EventHub/namespaces', + 'Microsoft.EventHub/clusters', + 'Microsoft.Insights/AutoscaleSettings', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Logic/workflows', + 'Microsoft.Network/networkInterfaces', + 'Microsoft.Network/loadBalancers', + 'Microsoft.Network/dnsZones', + 'Microsoft.Network/publicIPAddresses', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/applicationGateways', + 'Microsoft.Network/virtualNetworkGateways', + 'Microsoft.Network/expressRouteCircuits', + 'Microsoft.Network/expressRouteCircuits/Peerings', + 'Microsoft.Network/connections', + 'Microsoft.Network/trafficManagerProfiles', + 'Microsoft.Network/networkWatchers/connectionMonitors', + 'Microsoft.Network/frontdoors', + 'Microsoft.NotificationHubs/namespaces/notificationHubs', + 'Microsoft.OperationalInsights/workspaces', + 'Microsoft.PowerBIDedicated/capacities', + 'Microsoft.Relay/namespaces', + 'Microsoft.ServiceBus/namespaces', + 'Microsoft.Sql/servers/databases', + 'Microsoft.Sql/servers/elasticPools', + 'Microsoft.Sql/managedInstances', + 'Microsoft.Storage/storageAccounts', + 'Microsoft.Storage/storageAccounts/blobServices', + 'Microsoft.Storage/storageAccounts/fileServices', + 'Microsoft.Storage/storageAccounts/queueServices', + 'Microsoft.Storage/storageAccounts/tableServices', + 'Microsoft.Web/serverfarms', + 'Microsoft.Web/sites', + 'Microsoft.Web/sites/slots', + 'Microsoft.Web/hostingEnvironments/multiRolePools', + 'Microsoft.Web/hostingEnvironments/workerPools', + ], + germanyazuremonitor: [ + 'Microsoft.AnalysisServices/servers', + 'Microsoft.Batch/batchAccounts', + 'Microsoft.Cache/redis', + 'Microsoft.ClassicCompute/virtualMachines', + 'Microsoft.ClassicCompute/domainNames/slots/roles', + 'Microsoft.Compute/virtualMachines', + 'Microsoft.Compute/virtualMachineScaleSets', + 'Microsoft.DBforMySQL/servers', + 'Microsoft.DBforPostgreSQL/servers', + 'Microsoft.Devices/IotHubs', + 'Microsoft.Devices/provisioningServices', + 'Microsoft.EventHub/namespaces', + 'Microsoft.EventHub/clusters', + 'Microsoft.Insights/AutoscaleSettings', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Network/networkInterfaces', + 'Microsoft.Network/loadBalancers', + 'Microsoft.Network/dnsZones', + 'Microsoft.Network/publicIPAddresses', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/applicationGateways', + 'Microsoft.Network/virtualNetworkGateways', + 'Microsoft.Network/expressRouteCircuits', + 'Microsoft.Network/expressRouteCircuits/Peerings', + 'Microsoft.Network/connections', + 'Microsoft.Network/trafficManagerProfiles', + 'Microsoft.Network/networkWatchers/connectionMonitors', + 'Microsoft.Network/frontdoors', + 'Microsoft.NotificationHubs/namespaces/notificationHubs', + 'Microsoft.OperationalInsights/workspaces', + 'Microsoft.PowerBIDedicated/capacities', + 'Microsoft.Relay/namespaces', + 'Microsoft.ServiceBus/namespaces', + 'Microsoft.Sql/servers/databases', + 'Microsoft.Sql/servers/elasticPools', + 'Microsoft.Sql/managedInstances', + 'Microsoft.Storage/storageAccounts', + 'Microsoft.Storage/storageAccounts/blobServices', + 'Microsoft.Storage/storageAccounts/fileServices', + 'Microsoft.Storage/storageAccounts/queueServices', + 'Microsoft.Storage/storageAccounts/tableServices', + 'Microsoft.StreamAnalytics/streamingJobs', + 'Microsoft.Web/serverfarms', + 'Microsoft.Web/sites', + 'Microsoft.Web/sites/slots', + 'Microsoft.Web/hostingEnvironments/multiRolePools', + 'Microsoft.Web/hostingEnvironments/workerPools', + ], + chinaazuremonitor: [ + 'Microsoft.AnalysisServices/servers', + 'Microsoft.Batch/batchAccounts', + 'Microsoft.Cache/redis', + 'Microsoft.ClassicCompute/virtualMachines', + 'Microsoft.ClassicCompute/domainNames/slots/roles', + 'Microsoft.CognitiveServices/accounts', + 'Microsoft.Compute/virtualMachines', + 'Microsoft.Compute/virtualMachineScaleSets', + 'Microsoft.ContainerRegistry/registries', + 'Microsoft.DBforMySQL/servers', + 'Microsoft.DBforPostgreSQL/servers', + 'Microsoft.Devices/IotHubs', + 'Microsoft.Devices/provisioningServices', + 'Microsoft.EventHub/namespaces', + 'Microsoft.Insights/AutoscaleSettings', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Logic/workflows', + 'Microsoft.Network/networkInterfaces', + 'Microsoft.Network/loadBalancers', + 'Microsoft.Network/dnsZones', + 'Microsoft.Network/publicIPAddresses', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/applicationGateways', + 'Microsoft.Network/virtualNetworkGateways', + 'Microsoft.Network/expressRouteCircuits', + 'Microsoft.Network/expressRouteCircuits/Peerings', + 'Microsoft.Network/connections', + 'Microsoft.Network/trafficManagerProfiles', + 'Microsoft.Network/networkWatchers/connectionMonitors', + 'Microsoft.Network/frontdoors', + 'Microsoft.NotificationHubs/namespaces/notificationHubs', + 'Microsoft.PowerBIDedicated/capacities', + 'Microsoft.Relay/namespaces', + 'Microsoft.ServiceBus/namespaces', + 'Microsoft.Sql/servers/databases', + 'Microsoft.Sql/servers/elasticPools', + 'Microsoft.Sql/managedInstances', + 'Microsoft.Storage/storageAccounts', + 'Microsoft.Storage/storageAccounts/blobServices', + 'Microsoft.Storage/storageAccounts/fileServices', + 'Microsoft.Storage/storageAccounts/queueServices', + 'Microsoft.Storage/storageAccounts/tableServices', + 'Microsoft.StreamAnalytics/streamingJobs', + 'Microsoft.Web/serverfarms', + 'Microsoft.Web/sites', + 'Microsoft.Web/sites/slots', + 'Microsoft.Web/hostingEnvironments/multiRolePools', + 'Microsoft.Web/hostingEnvironments/workerPools', + ], + }; + + constructor(private cloudName: string) {} + + get(): string[] { + return this.supportedMetricNamespaces[this.cloudName]; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts new file mode 100644 index 0000000..0762266 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts @@ -0,0 +1,111 @@ +import UrlBuilder from './url_builder'; + +describe('AzureMonitorUrlBuilder', () => { + describe('when metric definition is Microsoft.Sql/servers/databases', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers/databases', + 'rn1/rn2', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Sql/servers', () => { + it('should build the getMetricNames url in the shorter format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Sql/servers', + 'rn', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/blobServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/fileServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/tableServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); + + describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices', () => { + it('should build the getMetricNames url in the longer format', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/queueServices', + 'rn1/default', + 'default', + '2017-05-01-preview' + ); + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview&metricnamespace=default' + ); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts new file mode 100644 index 0000000..e8cb3af --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts @@ -0,0 +1,54 @@ +export default class UrlBuilder { + static buildAzureMonitorGetMetricNamespacesUrl( + baseUrl: string, + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + apiVersion: string + ) { + if ((metricDefinition.match(/\//g) || []).length > 1) { + const rn = resourceName.split('/'); + const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1); + const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/')); + return ( + `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` + + `/providers/microsoft.insights/metricNamespaces?api-version=${apiVersion}` + ); + } + + return ( + `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${metricDefinition}/${resourceName}` + + `/providers/microsoft.insights/metricNamespaces?api-version=${apiVersion}` + ); + } + + static buildAzureMonitorGetMetricNamesUrl( + baseUrl: string, + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + metricNamespace: string, + apiVersion: string + ) { + if ((metricDefinition.match(/\//g) || []).length > 1) { + const rn = resourceName.split('/'); + const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1); + const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/')); + return ( + `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` + + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( + metricNamespace + )}` + ); + } + + return ( + `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${metricDefinition}/${resourceName}` + + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( + metricNamespace + )}` + ); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx new file mode 100644 index 0000000..5ac8d71 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import AnalyticsConfig, { Props } from './AnalyticsConfig'; + +const setup = (propOverrides?: object) => { + const props: Props = { + options: { + id: 21, + orgId: 1, + name: 'Azure Monitor-10-10', + type: 'grafana-azure-monitor-datasource', + typeName: 'Azure', + typeLogoUrl: '', + access: 'proxy', + url: '', + password: '', + user: '', + database: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + isDefault: false, + secureJsonFields: { + logAnalyticsClientSecret: false, + }, + jsonData: { + cloudName: '', + subscriptionId: '', + azureLogAnalyticsSameAs: false, + logAnalyticsDefaultWorkspace: '', + logAnalyticsTenantId: '', + }, + secureJsonData: { + logAnalyticsClientSecret: '', + }, + version: 1, + readOnly: false, + }, + subscriptions: [], + workspaces: [], + makeSameAs: jest.fn(), + onUpdateDatasourceOptions: jest.fn(), + onUpdateJsonDataOption: jest.fn(), + onUpdateSecureJsonDataOption: jest.fn(), + onResetOptionKey: jest.fn(), + onLoadSubscriptions: jest.fn(), + onLoadWorkspaces: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should disable log analytics credentials form', () => { + const wrapper = setup({ + jsonData: { + azureLogAnalyticsSameAs: true, + }, + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should enable azure log analytics load workspaces button', () => { + const wrapper = setup({ + jsonData: { + logAnalyticsDefaultWorkspace: '', + logAnalyticsTenantId: 'e7f3f661-a933-4b3f-8176-51c4f982ec48', + logAnalyticsClientId: '44693801-6ee6-49de-9b2d-9106972f9572', + logAnalyticsSubscriptionId: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2', + logAnalyticsClientSecret: 'cddcc020-2c94-460a-a3d0-df3147ffa792', + }, + }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx new file mode 100644 index 0000000..ed27e06 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx @@ -0,0 +1,209 @@ +import React, { PureComponent, ChangeEvent } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { AzureCredentialsForm } from './AzureCredentialsForm'; +import { InlineFormLabel, LegacyForms, Button } from '@grafana/ui'; +const { Select, Switch } = LegacyForms; +import { AzureDataSourceSettings } from '../types'; + +export interface State { + sameAsSwitched: boolean; +} + +export interface Props { + options: AzureDataSourceSettings; + subscriptions: SelectableValue[]; + workspaces: SelectableValue[]; + makeSameAs: () => void; + onUpdateDatasourceOptions: (options: AzureDataSourceSettings) => void; + onUpdateJsonDataOption: (key: string, val: any) => void; + onUpdateSecureJsonDataOption: (key: string, val: any) => void; + onResetOptionKey: (key: string) => void; + onLoadSubscriptions: (type?: string) => void; + onLoadWorkspaces: (type?: string) => void; +} +export class AnalyticsConfig extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + sameAsSwitched: false, + }; + } + + onLogAnalyticsTenantIdChange = (event: ChangeEvent) => { + this.props.onUpdateJsonDataOption('logAnalyticsTenantId', event.target.value); + }; + + onLogAnalyticsClientIdChange = (event: ChangeEvent) => { + this.props.onUpdateJsonDataOption('logAnalyticsClientId', event.target.value); + }; + + onLogAnalyticsClientSecretChange = (event: ChangeEvent) => { + this.props.onUpdateSecureJsonDataOption('logAnalyticsClientSecret', event.target.value); + }; + + onLogAnalyticsSubscriptionSelect = (logAnalyticsSubscription: SelectableValue) => { + this.props.onUpdateJsonDataOption('logAnalyticsSubscriptionId', logAnalyticsSubscription.value); + }; + + onWorkspaceSelectChange = (logAnalyticsDefaultWorkspace: SelectableValue) => { + this.props.onUpdateJsonDataOption('logAnalyticsDefaultWorkspace', logAnalyticsDefaultWorkspace.value); + }; + + onAzureLogAnalyticsSameAsChange = () => { + const { options, onUpdateDatasourceOptions, makeSameAs } = this.props; + + if (!options.jsonData.azureLogAnalyticsSameAs && options.secureJsonData!.clientSecret) { + makeSameAs(); + } else if (!options.jsonData.azureLogAnalyticsSameAs) { + // if currently off, clear monitor secret + onUpdateDatasourceOptions({ + ...options, + jsonData: { + ...options.jsonData, + azureLogAnalyticsSameAs: !options.jsonData.azureLogAnalyticsSameAs, + }, + secureJsonData: { + ...options.secureJsonData, + clientSecret: '', + }, + secureJsonFields: { + clientSecret: false, + }, + }); + + this.setState({ + sameAsSwitched: true, + }); + } else { + this.props.onUpdateJsonDataOption('azureLogAnalyticsSameAs', !options.jsonData.azureLogAnalyticsSameAs); + } + }; + + onLogAnalyticsResetClientSecret = () => { + this.props.onResetOptionKey('logAnalyticsClientSecret'); + }; + + hasWorkspaceRequiredFields = () => { + const { + options: { jsonData, secureJsonData, secureJsonFields }, + } = this.props; + + if (jsonData.azureLogAnalyticsSameAs) { + return ( + jsonData.tenantId && + jsonData.clientId && + jsonData.subscriptionId && + (secureJsonData!.clientSecret || secureJsonFields.clientSecret) + ); + } + + return ( + jsonData.logAnalyticsTenantId && + jsonData.logAnalyticsTenantId.length && + jsonData.logAnalyticsClientId && + jsonData.logAnalyticsClientId.length && + jsonData.logAnalyticsSubscriptionId && + (secureJsonFields.logAnalyticsClientSecret || secureJsonData!.logAnalyticsClientSecret) + ); + }; + + render() { + const { + options: { jsonData, secureJsonData, secureJsonFields }, + subscriptions, + workspaces, + } = this.props; + + const { sameAsSwitched } = this.state; + + if (!jsonData.hasOwnProperty('azureLogAnalyticsSameAs')) { + jsonData.azureLogAnalyticsSameAs = true; + } + + const addtlAttrs = { + ...(jsonData.azureLogAnalyticsSameAs && { + tooltip: 'Workspaces are pulled from default subscription selected above.', + }), + }; + + const showSameAsHelpMsg = + sameAsSwitched && + jsonData.azureLogAnalyticsSameAs && + secureJsonFields && + !secureJsonFields.clientSecret && + !secureJsonData!.clientSecret; + + return ( + <> +

    Azure Monitor Logs Details

    + + {showSameAsHelpMsg && ( +
    +
    +

    Re-enter your Azure Monitor Client Secret to use this setting.

    +
    +
    + )} + {!jsonData.azureLogAnalyticsSameAs && ( + this.props.onLoadSubscriptions('workspacesloganalytics')} + /> + )} +
    +
    +
    + + Default Workspace + +
    + azureCloud.value === selectedAzureCloud)} + options={azureCloudOptions} + defaultValue={selectedAzureCloud} + onChange={onAzureCloudChange} + /> +
    +
    + )} +
    +
    + Directory (tenant) ID +
    + +
    +
    +
    +
    +
    + Application (client) ID +
    + +
    +
    +
    + {clientSecretConfigured ? ( +
    +
    + Client Secret + +
    +
    +
    + +
    +
    +
    + ) : ( +
    +
    + Client Secret +
    + +
    +
    +
    + )} + {hasSubscriptions && ( + <> +
    +
    + Default Subscription +
    + +
    +
    +
    + +
    +
    +
    + ) : ( +
    +
    + API Key +
    + +
    +
    +
    + )} +
    +
    + Application ID +
    + +
    +
    +
    +
    + + ); + } +} + +export default InsightsConfig; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MonitorConfig.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MonitorConfig.tsx new file mode 100644 index 0000000..9202808 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MonitorConfig.tsx @@ -0,0 +1,81 @@ +import React, { PureComponent, ChangeEvent } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { AzureCredentialsForm } from './AzureCredentialsForm'; +import { AzureDataSourceSettings } from '../types'; + +const azureClouds = [ + { value: 'azuremonitor', label: 'Azure' }, + { value: 'govazuremonitor', label: 'Azure US Government' }, + { value: 'germanyazuremonitor', label: 'Azure Germany' }, + { value: 'chinaazuremonitor', label: 'Azure China' }, +] as SelectableValue[]; + +export interface Props { + options: AzureDataSourceSettings; + subscriptions: SelectableValue[]; + makeSameAs: (updatedClientSecret?: string) => void; + onUpdateJsonDataOption: (key: string, val: any) => void; + onUpdateSecureJsonDataOption: (key: string, val: any) => void; + onResetOptionKey: (key: string) => void; + onLoadSubscriptions: () => void; +} + +export class MonitorConfig extends PureComponent { + onAzureCloudSelect = (cloudName: SelectableValue) => { + this.props.onUpdateJsonDataOption('cloudName', cloudName.value); + }; + + onTenantIdChange = (event: ChangeEvent) => { + this.props.onUpdateJsonDataOption('tenantId', event.target.value); + }; + + onClientIdChange = (event: ChangeEvent) => { + this.props.onUpdateJsonDataOption('clientId', event.target.value); + }; + + onClientSecretChange = (event: ChangeEvent) => { + const { options, makeSameAs } = this.props; + + if (options.jsonData.azureLogAnalyticsSameAs && event.target.value) { + makeSameAs(event.target.value); + } else { + this.props.onUpdateSecureJsonDataOption('clientSecret', event.target.value); + } + }; + + onResetClientSecret = () => { + this.props.onResetOptionKey('clientSecret'); + }; + + onSubscriptionSelect = (subscription: SelectableValue) => { + this.props.onUpdateJsonDataOption('subscriptionId', subscription.value); + }; + + render() { + const { options, subscriptions } = this.props; + return ( + <> +

    Azure Monitor Metrics Details

    + + + ); + } +} + +export default MonitorConfig; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap new file mode 100644 index 0000000..2f52d74 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap @@ -0,0 +1,289 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should disable log analytics credentials form 1`] = ` + +

    + Azure Monitor Logs Details +

    + + +
    +
    +
    + + Default Workspace + +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +`; + +exports[`Render should render component 1`] = ` + +

    + Azure Monitor Logs Details +

    + + +
    +
    +
    + + Default Workspace + +
    + +
    +
    +
    +
    + + Directory (tenant) ID + +
    + +
    +
    +
    +
    +
    + + Application (client) ID + +
    + +
    +
    +
    +
    +
    + + Client Secret + + +
    +
    +
    + +
    +
    +
    +
    +
    + + Default Subscription + +
    + +
    +
    +
    +
    + + Directory (tenant) ID + +
    + +
    +
    +
    +
    +
    + + Application (client) ID + +
    + +
    +
    +
    +
    +
    + + Client Secret + +
    + +
    +
    +
    +
    +
    + + Default Subscription + +
    + +
    +
    +
    +
    + + Directory (tenant) ID + +
    + +
    +
    +
    +
    +
    + + Application (client) ID + +
    + +
    +
    +
    +
    +
    + + Client Secret + +
    + +
    +
    +
    +
    +
    + + Default Subscription + +
    + +
    +
    +
    + +
    +
    +
    +
    +
    + + Application ID + +
    + +
    +
    +
    +
    + +`; + +exports[`Render should enable insights api key input 1`] = ` + +

    + Azure Application Insights Details +

    +
    +
    +
    + + API Key + +
    + +
    +
    +
    +
    +
    + + Application ID + +
    + +
    +
    +
    +
    +
    +`; + +exports[`Render should render component 1`] = ` + +

    + Azure Application Insights Details +

    +
    +
    +
    + + API Key + +
    + +
    +
    +
    +
    +
    + + Application ID + +
    + +
    +
    +
    +
    +
    +`; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/css/query_editor.css b/public/app/plugins/datasource/grafana-azure-monitor-datasource/css/query_editor.css new file mode 100644 index 0000000..cfd1aaf --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/css/query_editor.css @@ -0,0 +1,31 @@ +.min-width-10 { + min-width: 10rem; +} + +.min-width-12 { + min-width: 12rem; +} + +.min-width-20 { + min-width: 20rem; +} + +.gf-form-select-wrapper select.gf-form-input { + height: 2.64rem; +} + +.gf-form-select-wrapper--caret-indent.gf-form-select-wrapper::after { + right: 0.775rem; +} + +.service-dropdown { + width: 12rem; +} + +.aggregation-dropdown-wrapper { + max-width: 29.1rem; +} + +.timegrainunit-dropdown-wrapper { + width: 8rem; +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts new file mode 100644 index 0000000..f6a2cc5 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts @@ -0,0 +1,274 @@ +import _ from 'lodash'; +import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource'; +import AppInsightsDatasource from './app_insights/app_insights_datasource'; +import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource'; +import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType, InsightsAnalyticsQuery } from './types'; +import { + DataFrame, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + LoadingState, + ScopedVars, +} from '@grafana/data'; +import { forkJoin, Observable, of } from 'rxjs'; +import { DataSourceWithBackend } from '@grafana/runtime'; +import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource'; +import { migrateMetricsDimensionFilters } from './query_ctrl'; +import { map } from 'rxjs/operators'; + +export default class Datasource extends DataSourceApi { + azureMonitorDatasource: AzureMonitorDatasource; + appInsightsDatasource: AppInsightsDatasource; + azureLogAnalyticsDatasource: AzureLogAnalyticsDatasource; + insightsAnalyticsDatasource: InsightsAnalyticsDatasource; + + pseudoDatasource: Record; + optionsKey: Record; + + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.azureMonitorDatasource = new AzureMonitorDatasource(instanceSettings); + this.appInsightsDatasource = new AppInsightsDatasource(instanceSettings); + this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(instanceSettings); + this.insightsAnalyticsDatasource = new InsightsAnalyticsDatasource(instanceSettings); + + const pseudoDatasource: any = {}; + pseudoDatasource[AzureQueryType.ApplicationInsights] = this.appInsightsDatasource; + pseudoDatasource[AzureQueryType.AzureMonitor] = this.azureMonitorDatasource; + pseudoDatasource[AzureQueryType.InsightsAnalytics] = this.insightsAnalyticsDatasource; + pseudoDatasource[AzureQueryType.LogAnalytics] = this.azureLogAnalyticsDatasource; + this.pseudoDatasource = pseudoDatasource; + + const optionsKey: any = {}; + optionsKey[AzureQueryType.ApplicationInsights] = 'appInsights'; + optionsKey[AzureQueryType.AzureMonitor] = 'azureMonitor'; + optionsKey[AzureQueryType.InsightsAnalytics] = 'insightsAnalytics'; + optionsKey[AzureQueryType.LogAnalytics] = 'azureLogAnalytics'; + this.optionsKey = optionsKey; + } + + query(options: DataQueryRequest): Observable { + const byType: Record> = ({} as unknown) as Record< + AzureQueryType, + DataQueryRequest + >; + + for (const target of options.targets) { + // Migrate old query structure + if (target.queryType === AzureQueryType.ApplicationInsights) { + if ((target.appInsights as any).rawQuery) { + target.queryType = AzureQueryType.InsightsAnalytics; + target.insightsAnalytics = (target.appInsights as unknown) as InsightsAnalyticsQuery; + delete target.appInsights; + } + } + if (!target.queryType) { + target.queryType = AzureQueryType.AzureMonitor; + } + + if (target.queryType === AzureQueryType.AzureMonitor) { + migrateMetricsDimensionFilters(target.azureMonitor); + } + + // Check that we have options + const opts = (target as any)[this.optionsKey[target.queryType]]; + + // Skip hidden queries or ones without properties + if (target.hide || !opts) { + continue; + } + + // Initialize the list of queries + let q = byType[target.queryType]; + if (!q) { + q = _.cloneDeep(options); + q.requestId = `${q.requestId}-${target.refId}`; + q.targets = []; + byType[target.queryType] = q; + } + q.targets.push(target); + } + + // Distinct types are managed by distinct requests + const obs = Object.keys(byType).map((type: AzureQueryType) => { + const req = byType[type]; + return this.pseudoDatasource[type].query(req); + }); + // Single query can skip merge + if (obs.length === 1) { + return obs[0]; + } + + if (obs.length > 1) { + return forkJoin(obs).pipe( + map((results: DataQueryResponse[]) => { + const data: DataFrame[] = []; + for (const result of results) { + for (const frame of result.data) { + data.push(frame); + } + } + + return { state: LoadingState.Done, data }; + }) + ); + } + + return of({ state: LoadingState.Done, data: [] }); + } + + async annotationQuery(options: any) { + return this.azureLogAnalyticsDatasource.annotationQuery(options); + } + + async metricFindQuery(query: string) { + if (!query) { + return Promise.resolve([]); + } + + const aiResult = this.appInsightsDatasource.metricFindQueryInternal(query); + if (aiResult) { + return aiResult; + } + + const amResult = this.azureMonitorDatasource.metricFindQueryInternal(query); + if (amResult) { + return amResult; + } + + const alaResult = this.azureLogAnalyticsDatasource.metricFindQueryInternal(query); + if (alaResult) { + return alaResult; + } + + return Promise.resolve([]); + } + + async testDatasource() { + const promises: any[] = []; + + if (this.azureMonitorDatasource.isConfigured()) { + promises.push(this.azureMonitorDatasource.testDatasource()); + } + + if (this.appInsightsDatasource.isConfigured()) { + promises.push(this.appInsightsDatasource.testDatasource()); + } + + if (this.azureLogAnalyticsDatasource.isConfigured()) { + promises.push(this.azureLogAnalyticsDatasource.testDatasource()); + } + + if (promises.length === 0) { + return { + status: 'error', + message: `Nothing configured. At least one of the API's must be configured.`, + title: 'Error', + }; + } + + return Promise.all(promises).then((results) => { + let status = 'success'; + let message = ''; + + for (let i = 0; i < results.length; i++) { + if (results[i].status !== 'success') { + status = results[i].status; + } + message += `${i + 1}. ${results[i].message} `; + } + + return { + status: status, + message: message, + title: _.upperFirst(status), + }; + }); + } + + /* Azure Monitor REST API methods */ + getResourceGroups(subscriptionId: string) { + return this.azureMonitorDatasource.getResourceGroups(subscriptionId); + } + + getMetricDefinitions(subscriptionId: string, resourceGroup: string) { + return this.azureMonitorDatasource.getMetricDefinitions(subscriptionId, resourceGroup); + } + + getResourceNames(subscriptionId: string, resourceGroup: string, metricDefinition: string) { + return this.azureMonitorDatasource.getResourceNames(subscriptionId, resourceGroup, metricDefinition); + } + + getMetricNames( + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + metricNamespace: string + ) { + return this.azureMonitorDatasource.getMetricNames( + subscriptionId, + resourceGroup, + metricDefinition, + resourceName, + metricNamespace + ); + } + + getMetricNamespaces(subscriptionId: string, resourceGroup: string, metricDefinition: string, resourceName: string) { + return this.azureMonitorDatasource.getMetricNamespaces( + subscriptionId, + resourceGroup, + metricDefinition, + resourceName + ); + } + + getMetricMetadata( + subscriptionId: string, + resourceGroup: string, + metricDefinition: string, + resourceName: string, + metricNamespace: string, + metricName: string + ) { + return this.azureMonitorDatasource.getMetricMetadata( + subscriptionId, + resourceGroup, + metricDefinition, + resourceName, + metricNamespace, + metricName + ); + } + + /* Application Insights API method */ + getAppInsightsMetricNames() { + return this.appInsightsDatasource.getMetricNames(); + } + + getAppInsightsMetricMetadata(metricName: string) { + return this.appInsightsDatasource.getMetricMetadata(metricName); + } + + getAppInsightsColumns(refId: string | number) { + return this.appInsightsDatasource.logAnalyticsColumns[refId]; + } + + /*Azure Log Analytics */ + getAzureLogAnalyticsWorkspaces(subscriptionId: string) { + return this.azureLogAnalyticsDatasource.getWorkspaces(subscriptionId); + } + + getSubscriptions() { + return this.azureMonitorDatasource.getSubscriptions(); + } + + interpolateVariablesInQueries(queries: AzureMonitorQuery[], scopedVars: ScopedVars): AzureMonitorQuery[] { + return queries.map( + (query) => this.pseudoDatasource[query.queryType].applyTemplateVariables(query, scopedVars) as AzureMonitorQuery + ); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx new file mode 100644 index 0000000..f8a1625 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx @@ -0,0 +1,446 @@ +import _ from 'lodash'; +import Plain from 'slate-plain-serializer'; + +import QueryField from './query_field'; +import debounce from 'lodash/debounce'; +import { DOMUtil } from '@grafana/ui'; +import { Editor as CoreEditor } from 'slate'; + +import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto'; +// import '../sass/editor.base.scss'; + +const TYPEAHEAD_DELAY = 100; + +interface Suggestion { + text: string; + deleteBackwards?: number; + type?: string; +} + +interface SuggestionGroup { + label: string; + items: Suggestion[]; + prefixMatch?: boolean; + skipFilter?: boolean; +} + +interface KustoSchema { + Databases: { + Default: KustoDBSchema; + }; + Plugins?: any[]; +} + +interface KustoDBSchema { + Name?: string; + Functions?: any; + Tables?: any; +} + +const defaultSchema: any = () => ({ + Databases: { + Default: {}, + }, +}); + +const cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); +const wrapText = (text: string) => ({ text }); + +export default class KustoQueryField extends QueryField { + fields: any; + events: any; + schema: KustoSchema; + + constructor(props: any, context: any) { + super(props, context); + this.schema = defaultSchema(); + + this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY); + } + + componentDidMount() { + super.componentDidMount(); + this.fetchSchema(); + } + + onTypeahead = (force = false) => { + const selection = window.getSelection(); + + if (selection && selection.anchorNode) { + const wrapperNode = selection.anchorNode.parentElement; + if (wrapperNode === null) { + return; + } + const editorNode = wrapperNode.closest('.slate-query-field'); + if (!editorNode || this.state.value.isBlurred) { + // Not inside this editor + return; + } + + // DOM ranges + const range = selection.getRangeAt(0); + const text = selection.anchorNode.textContent; + if (text === null) { + return; + } + const offset = range.startOffset; + let prefix = cleanText(text.substr(0, offset)); + + // Model ranges + const modelOffset = this.state.value.anchorOffset; + const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset); + + // Determine candidates by context + let suggestionGroups: SuggestionGroup[] = []; + const wrapperClasses = wrapperNode.classList; + let typeaheadContext: string | null = null; + + // Built-in functions + if (wrapperClasses.contains('function-context')) { + typeaheadContext = 'context-function'; + suggestionGroups = this.getColumnSuggestions(); + + // where + } else if (modelPrefix.match(/(where\s(\w+\b)?$)/i)) { + typeaheadContext = 'context-where'; + suggestionGroups = this.getColumnSuggestions(); + + // summarize by + } else if (modelPrefix.match(/(summarize\s(\w+\b)?$)/i)) { + typeaheadContext = 'context-summarize'; + suggestionGroups = this.getFunctionSuggestions(); + } else if (modelPrefix.match(/(summarize\s(.+\s)?by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) { + typeaheadContext = 'context-summarize-by'; + suggestionGroups = this.getColumnSuggestions(); + + // order by, top X by, ... by ... + } else if (modelPrefix.match(/(by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) { + typeaheadContext = 'context-by'; + suggestionGroups = this.getColumnSuggestions(); + + // join + } else if (modelPrefix.match(/(on\s(.+\b)?$)/i)) { + typeaheadContext = 'context-join-on'; + suggestionGroups = this.getColumnSuggestions(); + } else if (modelPrefix.match(/(join\s+(\(\s+)?(\w+\b)?$)/i)) { + typeaheadContext = 'context-join'; + suggestionGroups = this.getTableSuggestions(); + + // distinct + } else if (modelPrefix.match(/(distinct\s(.+\b)?$)/i)) { + typeaheadContext = 'context-distinct'; + suggestionGroups = this.getColumnSuggestions(); + + // database() + } else if (modelPrefix.match(/(database\(\"(\w+)\"\)\.(.+\b)?$)/i)) { + typeaheadContext = 'context-database-table'; + const db = this.getDBFromDatabaseFunction(modelPrefix); + suggestionGroups = this.getTableSuggestions(db); + prefix = prefix.replace('.', ''); + + // new + } else if (normalizeQuery(Plain.serialize(this.state.value)).match(/^\s*\w*$/i)) { + typeaheadContext = 'context-new'; + if (this.schema) { + suggestionGroups = this.getInitialSuggestions(); + } else { + this.fetchSchema(); + setTimeout(this.onTypeahead, 0); + return; + } + + // built-in + } else if (prefix && !wrapperClasses.contains('argument') && !force) { + // Use only last typed word as a prefix for searching + if (modelPrefix.match(/\s$/i)) { + prefix = ''; + return; + } + prefix = getLastWord(prefix); + typeaheadContext = 'context-builtin'; + suggestionGroups = this.getKeywordSuggestions(); + } else if (force === true) { + typeaheadContext = 'context-builtin-forced'; + if (modelPrefix.match(/\s$/i)) { + prefix = ''; + } + suggestionGroups = this.getKeywordSuggestions(); + } + + let results = 0; + prefix = prefix.toLowerCase(); + const filteredSuggestions = suggestionGroups + .map((group) => { + if (group.items && prefix && !group.skipFilter) { + group.items = group.items.filter((c) => c.text.length >= prefix.length); + if (group.prefixMatch) { + group.items = group.items.filter((c) => c.text.toLowerCase().indexOf(prefix) === 0); + } else { + group.items = group.items.filter((c) => c.text.toLowerCase().indexOf(prefix) > -1); + } + } + results += group.items.length; + return group; + }) + .filter((group) => group.items.length > 0); + + // console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext); + // console.log('onTypeahead', prefix, typeaheadContext, force); + + this.setState({ + typeaheadPrefix: prefix, + typeaheadContext, + typeaheadText: text, + suggestions: results > 0 ? filteredSuggestions : [], + }); + } + }; + + applyTypeahead = (editor: CoreEditor, suggestion: { text: any; type: string; deleteBackwards: any }): CoreEditor => { + const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; + let suggestionText = suggestion.text || suggestion; + const move = 0; + + // Modify suggestion based on context + + const nextChar = DOMUtil.getNextCharacter(); + if (suggestion.type === 'function') { + if (!nextChar || nextChar !== '(') { + suggestionText += '('; + } + } else if (typeaheadContext === 'context-function') { + if (!nextChar || nextChar !== ')') { + suggestionText += ')'; + } + } else { + if (!nextChar || nextChar !== ' ') { + suggestionText += ' '; + } + } + + // Remove the current, incomplete text and replace it with the selected suggestion + const backward = suggestion.deleteBackwards || typeaheadPrefix.length; + const text = cleanText(typeaheadText); + const suffixLength = text.length - typeaheadPrefix.length; + const offset = typeaheadText.indexOf(typeaheadPrefix); + const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); + const forward = midWord ? suffixLength + offset : 0; + + this.resetTypeahead(() => + editor.deleteBackward(backward).deleteForward(forward).insertText(suggestionText).moveForward(move).focus() + ); + + return editor; + }; + + // private _getFieldsSuggestions(): SuggestionGroup[] { + // return [ + // { + // prefixMatch: true, + // label: 'Fields', + // items: this.fields.map(wrapText) + // }, + // { + // prefixMatch: true, + // label: 'Variables', + // items: this.props.templateVariables.map(wrapText) + // } + // ]; + // } + + // private _getAfterFromSuggestions(): SuggestionGroup[] { + // return [ + // { + // skipFilter: true, + // label: 'Events', + // items: this.events.map(wrapText) + // }, + // { + // prefixMatch: true, + // label: 'Variables', + // items: this.props.templateVariables + // .map(wrapText) + // .map(suggestion => { + // suggestion.deleteBackwards = 0; + // return suggestion; + // }) + // } + // ]; + // } + + // private _getAfterSelectSuggestions(): SuggestionGroup[] { + // return [ + // { + // prefixMatch: true, + // label: 'Fields', + // items: this.fields.map(wrapText) + // }, + // { + // prefixMatch: true, + // label: 'Functions', + // items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; }) + // }, + // { + // prefixMatch: true, + // label: 'Variables', + // items: this.props.templateVariables.map(wrapText) + // } + // ]; + // } + + private getInitialSuggestions(): SuggestionGroup[] { + return this.getTableSuggestions(); + } + + private getKeywordSuggestions(): SuggestionGroup[] { + return [ + { + prefixMatch: true, + label: 'Keywords', + items: KEYWORDS.map(wrapText), + }, + { + prefixMatch: true, + label: 'Operators', + items: operatorTokens, + }, + { + prefixMatch: true, + label: 'Functions', + items: functionTokens.map((s: any) => { + s.type = 'function'; + return s; + }), + }, + { + prefixMatch: true, + label: 'Macros', + items: grafanaMacros.map((s: any) => { + s.type = 'function'; + return s; + }), + }, + { + prefixMatch: true, + label: 'Tables', + items: _.map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })), + }, + ]; + } + + private getFunctionSuggestions(): SuggestionGroup[] { + return [ + { + prefixMatch: true, + label: 'Functions', + items: functionTokens.map((s: any) => { + s.type = 'function'; + return s; + }), + }, + { + prefixMatch: true, + label: 'Macros', + items: grafanaMacros.map((s: any) => { + s.type = 'function'; + return s; + }), + }, + ]; + } + + getTableSuggestions(db = 'Default'): SuggestionGroup[] { + // @ts-ignore + if (this.schema.Databases[db]) { + return [ + { + prefixMatch: true, + label: 'Tables', + // @ts-ignore + items: _.map(this.schema.Databases[db].Tables, (t: any) => ({ text: t.Name })), + }, + ]; + } else { + return []; + } + } + + private getColumnSuggestions(): SuggestionGroup[] { + const table = this.getTableFromContext(); + if (table) { + const tableSchema = this.schema.Databases.Default.Tables[table]; + if (tableSchema) { + return [ + { + prefixMatch: true, + label: 'Fields', + items: _.map(tableSchema.OrderedColumns, (f: any) => ({ + text: f.Name, + hint: f.Type, + })), + }, + ]; + } + } + return []; + } + + private getTableFromContext() { + const query = Plain.serialize(this.state.value); + const tablePattern = /^\s*(\w+)\s*|/g; + const normalizedQuery = normalizeQuery(query); + const match = tablePattern.exec(normalizedQuery); + if (match && match.length > 1 && match[0] && match[1]) { + return match[1]; + } else { + return null; + } + } + + private getDBFromDatabaseFunction(prefix: string) { + const databasePattern = /database\(\"(\w+)\"\)/gi; + const match = databasePattern.exec(prefix); + if (match && match.length > 1 && match[0] && match[1]) { + return match[1]; + } else { + return undefined; + } + } + + private async fetchSchema() { + let schema = await this.props.getSchema(); + if (schema) { + if (schema.Type === 'AppInsights') { + schema = castSchema(schema); + } + this.schema = schema; + } else { + this.schema = defaultSchema(); + } + } +} + +/** + * Cast schema from App Insights to default Kusto schema + */ +function castSchema(schema: any) { + const defaultSchemaTemplate = defaultSchema(); + defaultSchemaTemplate.Databases.Default = schema; + return defaultSchemaTemplate; +} + +function normalizeQuery(query: string): string { + const commentPattern = /\/\/.*$/gm; + let normalizedQuery = query.replace(commentPattern, ''); + normalizedQuery = normalizedQuery.replace('\n', ' '); + return normalizedQuery; +} + +function getLastWord(str: string): string { + const lastWordPattern = /(?:.*\s)?([^\s]+\s*)$/gi; + const match = lastWordPattern.exec(str); + if (match && match.length > 1) { + return match[1]; + } + return ''; +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/editor_component.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/editor_component.tsx new file mode 100644 index 0000000..355c354 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/editor_component.tsx @@ -0,0 +1,81 @@ +import KustoQueryField from './KustoQueryField'; +import Kusto from './kusto/kusto'; + +import React, { Component } from 'react'; +import coreModule from 'app/core/core_module'; + +interface EditorProps { + index: number; + placeholder?: string; + change: (value: string, index: number) => void; + variables: () => string[] | string[]; + getSchema?: () => Promise; + execute?: () => void; + query?: string; +} + +class Editor extends Component { + static defaultProps = { + placeholder: 'Enter a query', + }; + + constructor(props: EditorProps) { + super(props); + this.state = { + edited: false, + query: props.query || '', + }; + } + + onChangeQuery = (value: any) => { + const { index, change } = this.props; + const { query } = this.state; + const edited = query !== value; + this.setState({ edited, query: value }); + if (change) { + change(value, index); + } + }; + + onPressEnter = () => { + const { execute } = this.props; + if (execute) { + execute(); + } + }; + + render() { + const { variables, getSchema, placeholder } = this.props; + const { edited, query } = this.state; + + return ( +
    + +
    + ); + } +} + +coreModule.directive('kustoEditor', [ + 'reactDirective', + (reactDirective) => { + return reactDirective(Editor, [ + 'change', + 'database', + 'execute', + 'query', + 'variables', + 'placeholder', + ['getSchema', { watchDepth: 'reference' }], + ]); + }, +]); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/kusto/kusto.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/kusto/kusto.ts new file mode 100644 index 0000000..c809f7d --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/kusto/kusto.ts @@ -0,0 +1,720 @@ +/* eslint-disable max-len */ +export const operatorTokens = [ + { text: '!between', hint: 'Matches the input that is outside the inclusive range.' }, + { text: 'as', hint: "Binds a name to the operator's input tabular expression." }, + { text: 'between', hint: 'Matches the input that is inside the inclusive range.' }, + { + text: 'consume', + hint: + 'The `consume` operator consumes the tabular data stream handed to it. It is\r\nmostly used for triggering the query side-effect without actually returning\r\nthe results back to the caller.', + }, + { text: 'count', hint: 'Returns the number of records in the input record set.' }, + { text: 'datatable', hint: 'Returns a table whose schema and values are defined in the query itself.' }, + { + text: 'distinct', + hint: 'Produces a table with the distinct combination of the provided columns of the input table.', + }, + { text: 'evaluate', hint: 'Invokes a service-side query extension (plugin).' }, + { text: 'extend', hint: 'Create calculated columns and append them to the result set.' }, + { + text: 'externaldata', + hint: + 'Returns a table whose schema is defined in the query itself, and whose data is read from an external raw file.', + }, + { + text: 'facet', + hint: + 'Returns a set of tables, one for each specified column.\r\nEach table specifies the list of values taken by its column.\r\nAn additional table can be created by using the `with` clause.', + }, + { text: 'find', hint: 'Finds rows that match a predicate across a set of tables.' }, + { text: 'fork', hint: 'Runs multiple consumer operators in parallel.' }, + { text: 'getschema', hint: 'Produce a table that represents a tabular schema of the input.' }, + { text: 'in', hint: 'Filters a recordset based on the provided set of values.' }, + { text: 'invoke', hint: 'Invokes lambda that receives the source of `invoke` as tabular parameter argument.' }, + { + text: 'join', + hint: + 'Merge the rows of two tables to form a new table by matching values of the specified column(s) from each table.', + }, + { text: 'limit', hint: 'Return up to the specified number of rows.' }, + { text: 'make-series', hint: 'Create series of specified aggregated values along specified axis.' }, + { text: 'mvexpand', hint: 'Expands multi-value array or property bag.' }, + { text: 'order', hint: 'Sort the rows of the input table into order by one or more columns.' }, + { text: 'parse', hint: 'Evaluates a string expression and parses its value into one or more calculated columns.' }, + { + text: 'print', + hint: + 'Evaluates one or more scalar expressions and inserts the results (as a single-row table with as many columns as there are expressions) into the output.', + }, + { text: 'project', hint: 'Select the columns to include, rename or drop, and insert new computed columns.' }, + { text: 'project-away', hint: 'Select what columns to exclude from the input.' }, + { text: 'project-rename', hint: 'Renames columns in the result output.' }, + { text: 'range', hint: 'Generates a single-column table of values.' }, + { text: 'reduce', hint: 'Groups a set of strings together based on values similarity.' }, + { text: 'render', hint: 'Instructs the user agent to render the results of the query in a particular way.' }, + { text: 'sample', hint: 'Returns up to the specified number of random rows from the input table.' }, + { + text: 'sample-distinct', + hint: + 'Returns a single column that contains up to the specified number of distinct values of the requested column.', + }, + { text: 'search', hint: 'The search operator provides a multi-table/multi-column search experience.' }, + { text: 'serialize', hint: 'Marks that order of the input row set is safe for window functions usage.' }, + { text: 'sort', hint: 'Sort the rows of the input table into order by one or more columns.' }, + { text: 'summarize', hint: 'Produces a table that aggregates the content of the input table.' }, + { text: 'take', hint: 'Return up to the specified number of rows.' }, + { text: 'top', hint: 'Returns the first *N* records sorted by the specified columns.' }, + { + text: 'top-hitters', + hint: 'Returns an approximation of the first *N* results (assuming skewed distribution of the input).', + }, + { + text: 'top-nested', + hint: 'Produces hierarchical top results, where each level is a drill-down based on previous level values.', + }, + { text: 'union', hint: 'Takes two or more tables and returns the rows of all of them.' }, + { text: 'where', hint: 'Filters a table to the subset of rows that satisfy a predicate.' }, +]; + +export const functionTokens = [ + { text: 'abs', hint: 'Calculates the absolute value of the input.' }, + { + text: 'acos', + hint: + 'Returns the angle whose cosine is the specified number (the inverse operation of [`cos()`](cosfunction.md)) .', + }, + { text: 'ago', hint: 'Subtracts the given timespan from the current UTC clock time.' }, + { text: 'any', hint: 'Returns random non-empty value from the specified expression values.' }, + { + text: 'arg_max', + hint: + 'Finds a row in the group that maximizes *ExprToMaximize*, and returns the value of *ExprToReturn* (or `*` to return the entire row).', + }, + { + text: 'arg_min', + hint: + 'Finds a row in the group that minimizes *ExprToMinimize*, and returns the value of *ExprToReturn* (or `*` to return the entire row).', + }, + { + text: 'argmax', + hint: + 'Finds a row in the group that maximizes *ExprToMaximize*, and returns the value of *ExprToReturn* (or `*` to return the entire row).', + }, + { + text: 'argmin', + hint: + 'Finds a row in the group that minimizes *ExprToMinimize*, and returns the value of *ExprToReturn* (or `*` to return the entire row).', + }, + { text: 'array_concat', hint: 'Concatenates a number of dynamic arrays to a single array.' }, + { text: 'array_length', hint: 'Calculates the number of elements in a dynamic array.' }, + { text: 'array_slice', hint: 'Extracts a slice of a dynamic array.' }, + { + text: 'array_split', + hint: + 'Splits an array to multiple arrays according to the split indices and packs the generated array in a dynamic array.', + }, + { + text: 'asin', + hint: 'Returns the angle whose sine is the specified number (the inverse operation of [`sin()`](sinfunction.md)) .', + }, + { + text: 'assert', + hint: 'Checks for a condition; if the condition is false, outputs error messages and fails the query.', + }, + { + text: 'atan', + hint: + 'Returns the angle whose tangent is the specified number (the inverse operation of [`tan()`](tanfunction.md)) .', + }, + { + text: 'atan2', + hint: + 'Calculates the angle, in radians, between the positive x-axis and the ray from the origin to the point (y, x).', + }, + { text: 'avg', hint: 'Calculates the average of *Expr* across the group.' }, + { + text: 'avgif', + hint: + 'Calculates the [average](avg-aggfunction.md) of *Expr* across the group for which *Predicate* evaluates to `true`.', + }, + { text: 'bag_keys', hint: 'Enumerates all the root keys in a dynamic property-bag object.' }, + { text: 'base64_decodestring', hint: 'Decodes a base64 string to a UTF-8 string' }, + { text: 'base64_encodestring', hint: 'Encodes a string as base64 string' }, + { text: 'beta_cdf', hint: 'Returns the standard cumulative beta distribution function.' }, + { text: 'beta_inv', hint: 'Returns the inverse of the beta cumulative probability beta density function.' }, + { text: 'beta_pdf', hint: 'Returns the probability density beta function.' }, + { text: 'bin', hint: 'Rounds values down to an integer multiple of a given bin size.' }, + { + text: 'bin_at', + hint: + "Rounds values down to a fixed-size 'bin', with control over the bin's starting point.\r\n(See also [`bin function`](./binfunction.md).)", + }, + { + text: 'bin_auto', + hint: + "Rounds values down to a fixed-size 'bin', with control over the bin size and starting point provided by a query property.", + }, + { text: 'binary_and', hint: 'Returns a result of the bitwise `and` operation between two values.' }, + { text: 'binary_not', hint: 'Returns a bitwise negation of the input value.' }, + { text: 'binary_or', hint: 'Returns a result of the bitwise `or` operation of the two values.' }, + { text: 'binary_shift_left', hint: 'Returns binary shift left operation on a pair of numbers.' }, + { text: 'binary_shift_right', hint: 'Returns binary shift right operation on a pair of numbers.' }, + { text: 'binary_xor', hint: 'Returns a result of the bitwise `xor` operation of the two values.' }, + { text: 'buildschema', hint: 'Returns the minimal schema that admits all values of *DynamicExpr*.' }, + { + text: 'case', + hint: 'Evaluates a list of predicates and returns the first result expression whose predicate is satisfied.', + }, + { + text: 'ceiling', + hint: 'Calculates the smallest integer greater than, or equal to, the specified numeric expression.', + }, + { text: 'cluster', hint: 'Changes the reference of the query to a remote cluster.' }, + { + text: 'coalesce', + hint: 'Evaluates a list of expressions and returns the first non-null (or non-empty for string) expression.', + }, + { text: 'cos', hint: 'Returns the cosine function.' }, + { text: 'cot', hint: 'Calculates the trigonometric cotangent of the specified angle, in radians.' }, + { + text: 'count', + hint: + 'Returns a count of the records per summarization group (or in total if summarization is done without grouping).', + }, + { text: 'countif', hint: 'Returns a count of rows for which *Predicate* evaluates to `true`.' }, + { + text: 'countof', + hint: 'Counts occurrences of a substring in a string. Plain string matches may overlap; regex matches do not.', + }, + { text: 'current_principal', hint: 'Returns the current principal running this query.' }, + { + text: 'cursor_after', + hint: 'A predicate over the records of a table to compare their ingestion time\r\nagainst a database cursor.', + }, + { + text: 'cursor_before_or_at', + hint: 'A predicate over the records of a table to compare their ingestion time\r\nagainst a database cursor.', + }, + { text: 'database', hint: 'Changes the reference of the query to a specific database within the cluster scope.' }, + { + text: 'datetime_add', + hint: + 'Calculates a new [datetime](./scalar-data-types/datetime.md) from a specified datepart multiplied by a specified amount, added to a specified [datetime](./scalar-data-types/datetime.md).', + }, + { + text: 'datetime_diff', + hint: 'Calculates calendarian difference between two [datetime](./scalar-data-types/datetime.md) values.', + }, + { text: 'datetime_part', hint: 'Extracts the requested date part as an integer value.' }, + { text: 'dayofmonth', hint: 'Returns the integer number representing the day number of the given month' }, + { text: 'dayofweek', hint: 'Returns the integer number of days since the preceding Sunday, as a `timespan`.' }, + { text: 'dayofyear', hint: 'Returns the integer number represents the day number of the given year.' }, + { text: 'dcount', hint: 'Returns an estimate of the number of distinct values of *Expr* in the group.' }, + { + text: 'dcount_hll', + hint: + 'Calculates the dcount from hll results (which was generated by [hll](hll-aggfunction.md) or [hll_merge](hll-merge-aggfunction.md)).', + }, + { + text: 'dcountif', + hint: + 'Returns an estimate of the number of distinct values of *Expr* of rows for which *Predicate* evaluates to `true`.', + }, + { + text: 'degrees', + hint: + 'Converts angle value in radians into value in degrees, using formula `degrees = (180 / PI ) * angle_in_radians`', + }, + { text: 'distance', hint: 'Returns the distance between two points in meters.' }, + { text: 'endofday', hint: 'Returns the end of the day containing the date, shifted by an offset, if provided.' }, + { text: 'endofmonth', hint: 'Returns the end of the month containing the date, shifted by an offset, if provided.' }, + { text: 'endofweek', hint: 'Returns the end of the week containing the date, shifted by an offset, if provided.' }, + { text: 'endofyear', hint: 'Returns the end of the year containing the date, shifted by an offset, if provided.' }, + { + text: 'estimate_data_size', + hint: 'Returns an estimated data size of the selected columns of the tabular expression.', + }, + { text: 'exp', hint: 'The base-e exponential function of x, which is e raised to the power x: e^x.' }, + { + text: 'exp10', + hint: 'The base-10 exponential function of x, which is 10 raised to the power x: 10^x. \r\n**Syntax**', + }, + { text: 'exp2', hint: 'The base-2 exponential function of x, which is 2 raised to the power x: 2^x.' }, + { + text: 'extent_id', + hint: 'Returns a unique identifier that identifies the data shard ("extent") that the current record resides in.', + }, + { + text: 'extent_tags', + hint: + 'Returns a dynamic array with the [tags](../management/extents-overview.md#extent-tagging) of the data shard ("extent") that the current record resides in.', + }, + { text: 'extract', hint: 'Get a match for a [regular expression](./re2.md) from a text string.' }, + { text: 'extract_all', hint: 'Get all matches for a [regular expression](./re2.md) from a text string.' }, + { text: 'extractjson', hint: 'Get a specified element out of a JSON text using a path expression.' }, + { text: 'floor', hint: 'An alias for [`bin()`](binfunction.md).' }, + { text: 'format_datetime', hint: 'Formats a datetime parameter based on the format pattern parameter.' }, + { text: 'format_timespan', hint: 'Formats a timespan parameter based on the format pattern parameter.' }, + { text: 'gamma', hint: 'Computes [gamma function](https://en.wikipedia.org/wiki/Gamma_function)' }, + { text: 'getmonth', hint: 'Get the month number (1-12) from a datetime.' }, + { text: 'gettype', hint: 'Returns the runtime type of its single argument.' }, + { text: 'getyear', hint: 'Returns the year part of the `datetime` argument.' }, + { text: 'hash', hint: 'Returns a hash value for the input value.' }, + { text: 'hash_sha256', hint: 'Returns a sha256 hash value for the input value.' }, + { text: 'hll', hint: 'Calculates the Intermediate results of [dcount](dcount-aggfunction.md) across the group.' }, + { + text: 'hll_merge', + hint: 'Merges hll results (scalar version of the aggregate version [`hll_merge()`](hll-merge-aggfunction.md)).', + }, + { text: 'hourofday', hint: 'Returns the integer number representing the hour number of the given date' }, + { + text: 'iff', + hint: + 'Evaluates the first argument (the predicate), and returns the value of either the second or third arguments, depending on whether the predicate evaluated to `true` (second) or `false` (third).', + }, + { + text: 'iif', + hint: + 'Evaluates the first argument (the predicate), and returns the value of either the second or third arguments, depending on whether the predicate evaluated to `true` (second) or `false` (third).', + }, + { + text: 'indexof', + hint: 'Function reports the zero-based index of the first occurrence of a specified string within input string.', + }, + { text: 'ingestion_time', hint: "Retrieves the record's `$IngestionTime` hidden `datetime` column, or null." }, + { + text: 'iscolumnexists', + hint: + 'Returns a boolean value indicating if the given string argument exists in the schema produced by the preceding tabular operator.', + }, + { text: 'isempty', hint: 'Returns `true` if the argument is an empty string or is null.' }, + { text: 'isfinite', hint: 'Returns whether input is a finite value (is neither infinite nor NaN).' }, + { text: 'isinf', hint: 'Returns whether input is an infinite (positive or negative) value.' }, + { text: 'isnan', hint: 'Returns whether input is Not-a-Number (NaN) value.' }, + { text: 'isnotempty', hint: 'Returns `true` if the argument is not an empty string nor it is a null.' }, + { text: 'isnotnull', hint: 'Returns `true` if the argument is not null.' }, + { + text: 'isnull', + hint: + 'Evaluates its sole argument and returns a `bool` value indicating if the argument evaluates to a null value.', + }, + { text: 'log', hint: 'Returns the natural logarithm function.' }, + { text: 'log10', hint: 'Returns the common (base-10) logarithm function.' }, + { text: 'log2', hint: 'Returns the base-2 logarithm function.' }, + { + text: 'loggamma', + hint: 'Computes log of absolute value of the [gamma function](https://en.wikipedia.org/wiki/Gamma_function)', + }, + { + text: 'make_datetime', + hint: 'Creates a [datetime](./scalar-data-types/datetime.md) scalar value from the specified date and time.', + }, + { + text: 'make_dictionary', + hint: 'Returns a `dynamic` (JSON) property-bag (dictionary) of all the values of *Expr* in the group.', + }, + { text: 'make_string', hint: 'Returns the string generated by the Unicode characters.' }, + { + text: 'make_timespan', + hint: 'Creates a [timespan](./scalar-data-types/timespan.md) scalar value from the specified time period.', + }, + { text: 'makelist', hint: 'Returns a `dynamic` (JSON) array of all the values of *Expr* in the group.' }, + { + text: 'makeset', + hint: 'Returns a `dynamic` (JSON) array of the set of distinct values that *Expr* takes in the group.', + }, + { + text: 'materialize', + hint: + 'Allows caching a sub-query result during the time of query execution in a way that other subqueries can reference the partial result.', + }, + { text: 'max', hint: 'Returns the maximum value across the group.' }, + { text: 'max_of', hint: 'Returns the maximum value of several evaluated numeric expressions.' }, + { + text: 'merge_tdigests', + hint: + 'Merges tdigest results (scalar version of the aggregate version [`merge_tdigests()`](merge-tdigests-aggfunction.md)).', + }, + { text: 'min', hint: 'Returns the minimum value agross the group.' }, + { text: 'min_of', hint: 'Returns the minimum value of several evaluated numeric expressions.' }, + { text: 'monthofyear', hint: 'Returns the integer number represents the month number of the given year.' }, + { + text: 'next', + hint: + 'Returns the value of a column in a row that it at some offset following the\r\ncurrent row in a [serialized row set](./windowsfunctions.md#serialized-row-set).', + }, + { text: 'not', hint: 'Reverses the value of its `bool` argument.' }, + { + text: 'now', + hint: + 'Returns the current UTC clock time, optionally offset by a given timespan.\r\nThis function can be used multiple times in a statement and the clock time being referenced will be the same for all instances.', + }, + { text: 'pack', hint: 'Creates a `dynamic` object (property bag) from a list of names and values.' }, + { + text: 'pack_all', + hint: 'Creates a `dynamic` object (property bag) from all the columns of the tabular expression.', + }, + { text: 'pack_array', hint: 'Packs all input values into a dynamic array.' }, + { text: 'parse_ipv4', hint: 'Converts input to integer (signed 64-bit) number representation.' }, + { + text: 'parse_json', + hint: + 'Interprets a `string` as a [JSON value](https://json.org/)) and returns the value as [`dynamic`](./scalar-data-types/dynamic.md). \r\nIt is superior to using [extractjson() function](./extractjsonfunction.md)\r\nwhen you need to extract more than one element of a JSON compound object.', + }, + { + text: 'parse_path', + hint: + 'Parses a file path `string` and returns a [`dynamic`](./scalar-data-types/dynamic.md) object that contains the following parts of the path: \r\nScheme, RootPath, DirectoryPath, DirectoryName, FileName, Extension, AlternateDataStreamName.\r\nIn addition to the simple paths with both types of slashes, supports paths with schemas (e.g. "file://..."), shared paths (e.g. "\\\\shareddrive\\users..."), long paths (e.g "\\\\?\\C:...""), alternate data streams (e.g. "file1.exe:file2.exe")', + }, + { + text: 'parse_url', + hint: + 'Parses an absolute URL `string` and returns a [`dynamic`](./scalar-data-types/dynamic.md) object contains all parts of the URL (Scheme, Host, Port, Path, Username, Password, Query Parameters, Fragment).', + }, + { + text: 'parse_urlquery', + hint: + 'Parses a url query `string` and returns a [`dynamic`](./scalar-data-types/dynamic.md) object contains the Query parameters.', + }, + { + text: 'parse_user_agent', + hint: + "Interprets a user-agent string, which identifies the user's browser and provides certain system details to servers hosting the websites the user visits. The result is returned as [`dynamic`](./scalar-data-types/dynamic.md).", + }, + { text: 'parse_version', hint: 'Converts input string representation of version to a comparable decimal number.' }, + { + text: 'parse_xml', + hint: + 'Interprets a `string` as a XML value, converts the value to a [JSON value](https://json.org/) and returns the value as [`dynamic`](./scalar-data-types/dynamic.md).', + }, + { + text: 'percentile', + hint: + 'Returns an estimate for the specified [nearest-rank percentile](#nearest-rank-percentile) of the population defined by *Expr*. \r\nThe accuracy depends on the density of population in the region of the percentile.', + }, + { + text: 'percentile_tdigest', + hint: + 'Calculates the percentile result from tdigest results (which was generated by [tdigest](tdigest-aggfunction.md) or [merge-tdigests](merge-tdigests-aggfunction.md))', + }, + { + text: 'percentrank_tdigest', + hint: + "Calculates the approximate rank of the value in a set where rank is expressed as percentage of set's size. \r\nThis function can be viewed as the inverse of the percentile.", + }, + { text: 'pi', hint: 'Returns the constant value of Pi (π).' }, + { text: 'point', hint: 'Returns a dynamic array representation of a point.' }, + { text: 'pow', hint: 'Returns a result of raising to power' }, + { + text: 'prev', + hint: + 'Returns the value of a column in a row that it at some offset prior to the\r\ncurrent row in a [serialized row set](./windowsfunctions.md#serialized-row-set).', + }, + { + text: 'radians', + hint: + 'Converts angle value in degrees into value in radians, using formula `radians = (PI / 180 ) * angle_in_degrees`', + }, + { text: 'rand', hint: 'Returns a random number.' }, + { text: 'range', hint: 'Generates a dynamic array holding a series of equally-spaced values.' }, + { text: 'repeat', hint: 'Generates a dynamic array holding a series of equal values.' }, + { text: 'replace', hint: 'Replace all regex matches with another string.' }, + { text: 'reverse', hint: 'Function makes reverse of input string.' }, + { text: 'round', hint: 'Returns the rounded source to the specified precision.' }, + { + text: 'row_cumsum', + hint: + 'Calculates the cumulative sum of a column in a [serialized row set](./windowsfunctions.md#serialized-row-set).', + }, + { + text: 'row_number', + hint: + "Returns the current row's index in a [serialized row set](./windowsfunctions.md#serialized-row-set).\r\nThe row index starts by default at `1` for the first row, and is incremented by `1` for each additional row.\r\nOptionally, the row index can start at a different value than `1`.\r\nAdditionally, the row index may be reset according to some provided predicate.", + }, + { text: 'series_add', hint: 'Calculates the element-wise addition of two numeric series inputs.' }, + { text: 'series_decompose', hint: 'Applies a decomposition transformation on a series.' }, + { + text: 'series_decompose_anomalies', + hint: + 'Anomaly Detection based on series decomposition (refer to [series_decompose()](series-decomposefunction.md))', + }, + { text: 'series_decompose_forecast', hint: 'Forecast based on series decomposition.' }, + { text: 'series_divide', hint: 'Calculates the element-wise division of two numeric series inputs.' }, + { + text: 'series_equals', + hint: 'Calculates the element-wise equals (`==`) logic operation of two numeric series inputs.', + }, + { text: 'series_fill_backward', hint: 'Performs backward fill interpolation of missing values in a series.' }, + { text: 'series_fill_const', hint: 'Replaces missing values in a series with a specified constant value.' }, + { text: 'series_fill_forward', hint: 'Performs forward fill interpolation of missing values in a series.' }, + { text: 'series_fill_linear', hint: 'Performs linear interpolation of missing values in a series.' }, + { text: 'series_fir', hint: 'Applies a Finite Impulse Response filter on a series.' }, + { + text: 'series_fit_2lines', + hint: 'Applies two segments linear regression on a series, returning multiple columns.', + }, + { + text: 'series_fit_2lines_dynamic', + hint: 'Applies two segments linear regression on a series, returning dynamic object.', + }, + { text: 'series_fit_line', hint: 'Applies linear regression on a series, returning multiple columns.' }, + { text: 'series_fit_line_dynamic', hint: 'Applies linear regression on a series, returning dynamic object.' }, + { + text: 'series_greater', + hint: 'Calculates the element-wise greater (`>`) logic operation of two numeric series inputs.', + }, + { + text: 'series_greater_equals', + hint: 'Calculates the element-wise greater or equals (`>=`) logic operation of two numeric series inputs.', + }, + { text: 'series_iir', hint: 'Applies a Infinite Impulse Response filter on a series.' }, + { text: 'series_less', hint: 'Calculates the element-wise less (`<`) logic operation of two numeric series inputs.' }, + { + text: 'series_less_equals', + hint: 'Calculates the element-wise less or equal (`<=`) logic operation of two numeric series inputs.', + }, + { text: 'series_multiply', hint: 'Calculates the element-wise multiplication of two numeric series inputs.' }, + { + text: 'series_not_equals', + hint: 'Calculates the element-wise not equals (`!=`) logic operation of two numeric series inputs.', + }, + { text: 'series_outliers', hint: 'Scores anomaly points in a series.' }, + { text: 'series_periods_detect', hint: 'Finds the most significant periods that exist in a time series.' }, + { + text: 'series_periods_validate', + hint: 'Checks whether a time series contains periodic patterns of given lengths.', + }, + { + text: 'series_seasonal', + hint: 'Calculates the seasonal component of a series according to the detected or given seasonal period.', + }, + { text: 'series_stats', hint: 'Returns statistics for a series in multiple columns.' }, + { text: 'series_stats_dynamic', hint: 'Returns statistics for a series in dynamic object.' }, + { text: 'series_subtract', hint: 'Calculates the element-wise subtraction of two numeric series inputs.' }, + { text: 'sign', hint: 'Sign of a numeric expression' }, + { text: 'sin', hint: 'Returns the sine function.' }, + { + text: 'split', + hint: + 'Splits a given string according to a given delimiter and returns a string array with the contained substrings.', + }, + { text: 'sqrt', hint: 'Returns the square root function.' }, + { text: 'startofday', hint: 'Returns the start of the day containing the date, shifted by an offset, if provided.' }, + { + text: 'startofmonth', + hint: 'Returns the start of the month containing the date, shifted by an offset, if provided.', + }, + { + text: 'startofweek', + hint: 'Returns the start of the week containing the date, shifted by an offset, if provided.', + }, + { + text: 'startofyear', + hint: 'Returns the start of the year containing the date, shifted by an offset, if provided.', + }, + { + text: 'stdev', + hint: + 'Calculates the standard deviation of *Expr* across the group, considering the group as a [sample](https://en.wikipedia.org/wiki/Sample_%28statistics%29).', + }, + { + text: 'stdevif', + hint: + 'Calculates the [stdev](stdev-aggfunction.md) of *Expr* across the group for which *Predicate* evaluates to `true`.', + }, + { + text: 'stdevp', + hint: + 'Calculates the standard deviation of *Expr* across the group, considering the group as a [population](https://en.wikipedia.org/wiki/Statistical_population).', + }, + { text: 'strcat', hint: 'Concatenates between 1 and 64 arguments.' }, + { text: 'strcat_array', hint: 'Creates a concatenated string of array values using specified delimiter.' }, + { + text: 'strcat_delim', + hint: 'Concatenates between 2 and 64 arguments, with delimiter, provided as first argument.', + }, + { text: 'strcmp', hint: 'Compares two strings.' }, + { text: 'string_size', hint: 'Returns the size, in bytes, of the input string.' }, + { text: 'strlen', hint: 'Returns the length, in characters, of the input string.' }, + { text: 'strrep', hint: 'Repeats given [string](./scalar-data-types/string.md) provided amount of times.' }, + { + text: 'substring', + hint: 'Extracts a substring from a source string starting from some index to the end of the string.', + }, + { text: 'sum', hint: 'Calculates the sum of *Expr* across the group.' }, + { text: 'sumif', hint: 'Returns a sum of *Expr* for which *Predicate* evaluates to `true`.' }, + { text: 'table', hint: 'References specific table using an query-time evaluated string-expression.' }, + { text: 'tan', hint: 'Returns the tangent function.' }, + { + text: 'tdigest', + hint: 'Calculates the Intermediate results of [`percentiles()`](percentiles-aggfunction.md) across the group.', + }, + { + text: 'tdigest_merge', + hint: + 'Merges tdigest results (scalar version of the aggregate version [`tdigest_merge()`](tdigest-merge-aggfunction.md)).', + }, + { text: 'tobool', hint: 'Converts input to boolean (signed 8-bit) representation.' }, + { text: 'todatetime', hint: 'Converts input to [datetime](./scalar-data-types/datetime.md) scalar.' }, + { text: 'todecimal', hint: 'Converts input to decimal number representation.' }, + { + text: 'todouble', + hint: 'Converts the input to a value of type `real`. (`todouble()` and `toreal()` are synonyms.)', + }, + { + text: 'todynamic', + hint: + 'Interprets a `string` as a [JSON value](https://json.org/) and returns the value as [`dynamic`](./scalar-data-types/dynamic.md).', + }, + { text: 'toguid', hint: 'Converts input to [`guid`](./scalar-data-types/guid.md) representation.' }, + { text: 'tohex', hint: 'Converts input to a hexadecimal string.' }, + { text: 'toint', hint: 'Converts input to integer (signed 32-bit) number representation.' }, + { text: 'tolong', hint: 'Converts input to long (signed 64-bit) number representation.' }, + { text: 'tolower', hint: 'Converts input string to lower case.' }, + { text: 'toscalar', hint: 'Returns a scalar constant value of the evaluated expression.' }, + { text: 'tostring', hint: 'Converts input to a string representation.' }, + { text: 'totimespan', hint: 'Converts input to [timespan](./scalar-data-types/timespan.md) scalar.' }, + { text: 'toupper', hint: 'Converts a string to upper case.' }, + { + text: 'translate', + hint: + "Replaces a set of characters ('searchList') with another set of characters ('replacementList') in a given a string.\r\nThe function searches for characters in the 'searchList' and replaces them with the corresponding characters in 'replacementList'", + }, + { text: 'treepath', hint: 'Enumerates all the path expressions that identify leaves in a dynamic object.' }, + { text: 'trim', hint: 'Removes all leading and trailing matches of the specified regular expression.' }, + { text: 'trim_end', hint: 'Removes trailing match of the specified regular expression.' }, + { text: 'trim_start', hint: 'Removes leading match of the specified regular expression.' }, + { text: 'url_decode', hint: 'The function converts encoded URL into a to regular URL representation.' }, + { + text: 'url_encode', + hint: 'The function converts characters of the input URL into a format that can be transmitted over the Internet.', + }, + { + text: 'variance', + hint: + 'Calculates the variance of *Expr* across the group, considering the group as a [sample](https://en.wikipedia.org/wiki/Sample_%28statistics%29).', + }, + { + text: 'varianceif', + hint: + 'Calculates the [variance](variance-aggfunction.md) of *Expr* across the group for which *Predicate* evaluates to `true`.', + }, + { + text: 'variancep', + hint: + 'Calculates the variance of *Expr* across the group, considering the group as a [population](https://en.wikipedia.org/wiki/Statistical_population).', + }, + { text: 'weekofyear', hint: 'Returns the integer number represents the week number.' }, + { + text: 'welch_test', + hint: 'Computes the p_value of the [Welch-test function](https://en.wikipedia.org/wiki/Welch%27s_t-test)', + }, + { + text: 'zip', + hint: + 'The `zip` function accepts any number of `dynamic` arrays, and returns an\r\narray whose elements are each an array holding the elements of the input\r\narrays of the same index.', + }, +]; + +export const KEYWORDS = [ + 'by', + 'on', + 'contains', + 'notcontains', + 'containscs', + 'notcontainscs', + 'startswith', + 'has', + 'matches', + 'regex', + 'true', + 'false', + 'and', + 'or', + 'typeof', + 'int', + 'string', + 'date', + 'datetime', + 'time', + 'long', + 'real', + '​boolean', + 'bool', +]; + +export const grafanaMacros = [ + { + text: '$__timeFilter', + display: '$__timeFilter()', + hint: 'Macro that uses the selected timerange in Grafana to filter the query.', + }, + { + text: '$__timeTo', + display: '$__timeTo()', + hint: 'Returns the From datetime from the Grafana picker. Example: datetime(2018-06-05T20:09:58.907Z).', + }, + { + text: '$__timeFrom', + display: '$__timeFrom()', + hint: 'Returns the From datetime from the Grafana picker. Example: datetime(2018-06-05T18:09:58.907Z).', + }, + { + text: '$__escapeMulti', + display: '$__escapeMulti()', + hint: 'Macro to escape multi-value template variables that contain illegal characters.', + }, + { text: '$__contains', display: '$__contains()', hint: 'Macro for multi-value template variables.' }, +]; + +// Kusto operators +// export const OPERATORS = ['+', '-', '*', '/', '>', '<', '==', '<>', '<=', '>=', '~', '!~']; + +export const DURATION = ['SECONDS', 'MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS']; + +const tokenizer = { + comment: { + pattern: /(^|[^\\:])\/\/.*/, + lookbehind: true, + greedy: true, + }, + 'function-context': { + pattern: /[a-z0-9_]+\([^)]*\)?/i, + inside: {}, + }, + duration: { + pattern: new RegExp(`${DURATION.join('?|')}?`, 'i'), + alias: 'number', + }, + builtin: new RegExp(`\\b(?:${functionTokens.map((f) => f.text).join('|')})(?=\\s*\\()`, 'i'), + string: { + pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/, + greedy: true, + }, + keyword: new RegExp(`\\b(?:${KEYWORDS.join('|')}|${operatorTokens.map((f) => f.text).join('|')}|\\*)\\b`, 'i'), + boolean: /\b(?:true|false)\b/, + number: /\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i, + operator: /-|\+|\*|\/|>|<|==|<=?|>=?|<>|!~|~|=|\|/, + punctuation: /[{};(),.:]/, + variable: /(\[\[(.+?)\]\])|(\$(.+?))\b/, +}; + +tokenizer['function-context'].inside = { + argument: { + pattern: /[a-z0-9_]+(?=:)/i, + alias: 'symbol', + }, + duration: tokenizer.duration, + number: tokenizer.number, + builtin: tokenizer.builtin, + string: tokenizer.string, + variable: tokenizer.variable, +}; + +// console.log(tokenizer.builtin); + +export default tokenizer; + +// function escapeRegExp(str: string): string { +// return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +// } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx new file mode 100644 index 0000000..74c6631 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx @@ -0,0 +1,343 @@ +import PluginPrism from 'app/features/explore/slate-plugins/prism'; +import { BracesPlugin, ClearPlugin, RunnerPlugin, NewlinePlugin } from '@grafana/ui'; +import Typeahead from './typeahead'; +import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv'; + +import { Block, Document, Text, Value, Editor as CoreEditor } from 'slate'; +import { Editor } from '@grafana/slate-react'; +import Plain from 'slate-plain-serializer'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import _ from 'lodash'; + +function flattenSuggestions(s: any) { + return s ? s.reduce((acc: any, g: any) => acc.concat(g.items), []) : []; +} + +export const makeFragment = (text: string) => { + const lines = text.split('\n').map((line: any) => + Block.create({ + type: 'paragraph', + nodes: [Text.create(line)], + } as any) + ); + + const fragment = Document.create({ + nodes: lines, + }); + return fragment; +}; + +export const getInitialValue = (query: string) => Value.create({ document: makeFragment(query) }); + +class Portal extends React.Component { + node: any; + + constructor(props: any) { + super(props); + const { index = 0, prefix = 'query' } = props; + this.node = document.createElement('div'); + this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`); + document.body.appendChild(this.node); + } + + componentWillUnmount() { + document.body.removeChild(this.node); + } + + render() { + return ReactDOM.createPortal(this.props.children, this.node); + } +} + +class QueryField extends React.Component { + menuEl: any; + plugins: any; + resetTimer: any; + keybindingSrv: KeybindingSrv = getKeybindingSrv(); + + constructor(props: any, context: any) { + super(props, context); + + const { prismDefinition = {}, prismLanguage = 'kusto' } = props; + + this.plugins = [ + BracesPlugin(), + ClearPlugin(), + RunnerPlugin({ handler: props.onPressEnter }), + NewlinePlugin(), + PluginPrism({ definition: prismDefinition, language: prismLanguage }), + ]; + + this.state = { + labelKeys: {}, + labelValues: {}, + suggestions: [], + typeaheadIndex: null, + typeaheadPrefix: '', + value: getInitialValue(props.initialQuery || ''), + }; + } + + componentDidMount() { + this.updateMenu(); + } + + componentWillUnmount() { + this.restoreEscapeKeyBinding(); + clearTimeout(this.resetTimer); + } + + componentDidUpdate() { + this.updateMenu(); + } + + onChange = ({ value }: { value: Value }) => { + const changed = value.document !== this.state.value.document; + this.setState({ value }, () => { + if (changed) { + // call typeahead only if query changed + requestAnimationFrame(() => this.onTypeahead()); + this.onChangeQuery(); + } + }); + }; + + onChangeQuery = () => { + // Send text change to parent + const { onQueryChange } = this.props; + if (onQueryChange) { + onQueryChange(Plain.serialize(this.state.value)); + } + }; + + onKeyDown = (event: Event, editor: CoreEditor, next: Function) => { + const { typeaheadIndex, suggestions } = this.state; + const keyboardEvent = event as KeyboardEvent; + + switch (keyboardEvent.key) { + case 'Escape': { + if (this.menuEl) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this.resetTypeahead(); + return true; + } + break; + } + + case ' ': { + if (keyboardEvent.ctrlKey) { + keyboardEvent.preventDefault(); + this.onTypeahead(true); + return true; + } + break; + } + + case 'Tab': + case 'Enter': { + if (this.menuEl && typeaheadIndex !== null) { + // Dont blur input + keyboardEvent.preventDefault(); + if (!suggestions || !suggestions.length || keyboardEvent.shiftKey || keyboardEvent.ctrlKey) { + return next(); + } + + // Get the currently selected suggestion + const flattenedSuggestions = flattenSuggestions(suggestions); + const selected = Math.abs(typeaheadIndex); + const selectedIndex = selected % flattenedSuggestions.length || 0; + const suggestion = flattenedSuggestions[selectedIndex]; + + return this.applyTypeahead(editor, suggestion); + } + break; + } + + case 'ArrowDown': { + if (this.menuEl) { + // Select next suggestion + keyboardEvent.preventDefault(); + this.setState({ typeaheadIndex: (typeaheadIndex || 0) + 1 }); + } + break; + } + + case 'ArrowUp': { + if (this.menuEl) { + // Select previous suggestion + keyboardEvent.preventDefault(); + this.setState({ typeaheadIndex: Math.max(0, (typeaheadIndex || 0) - 1) }); + } + break; + } + + default: { + // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key); + break; + } + } + return next(); + }; + + onTypeahead = (change = false, item?: any): boolean | void => { + return change; + }; + + applyTypeahead = ( + editor?: CoreEditor, + suggestion?: { text: any; type: string; deleteBackwards: any } + ): { value: Value } => { + return { value: new Value() }; + }; + + resetTypeahead = (callback?: () => void) => { + this.setState( + { + suggestions: [], + typeaheadIndex: null, + typeaheadPrefix: '', + typeaheadContext: null, + }, + callback + ); + }; + + handleBlur = (event: Event, editor: CoreEditor, next: Function) => { + const { onBlur } = this.props; + // If we dont wait here, menu clicks wont work because the menu + // will be gone. + this.resetTimer = setTimeout(this.resetTypeahead, 100); + if (onBlur) { + onBlur(); + } + this.restoreEscapeKeyBinding(); + return next(); + }; + + handleFocus = (event: Event, editor: CoreEditor, next: Function) => { + const { onFocus } = this.props; + if (onFocus) { + onFocus(); + } + // Don't go back to dashboard if Escape pressed inside the editor. + this.removeEscapeKeyBinding(); + return next(); + }; + + removeEscapeKeyBinding() { + this.keybindingSrv.unbind('esc', 'keydown'); + } + + restoreEscapeKeyBinding() { + this.keybindingSrv.setupGlobal(); + } + + onClickItem = (item: any) => { + const { suggestions } = this.state; + if (!suggestions || suggestions.length === 0) { + return; + } + + // Manually triggering change + const change = this.applyTypeahead(); + this.onChange(change); + }; + + updateMenu = () => { + const { suggestions } = this.state; + const menu = this.menuEl; + const selection = window.getSelection(); + + // No menu, nothing to do + if (!menu || !selection) { + return; + } + + const node = selection.anchorNode; + + // No suggestions or blur, remove menu + const hasSuggesstions = suggestions && suggestions.length > 0; + if (!hasSuggesstions) { + menu.removeAttribute('style'); + return; + } + + // Align menu overlay to editor node + if (node && node.parentElement) { + // Read from DOM + const rect = node.parentElement.getBoundingClientRect(); + const scrollX = window.scrollX; + const scrollY = window.scrollY; + const screenHeight = window.innerHeight; + + const menuLeft = rect.left + scrollX - 2; + const menuTop = rect.top + scrollY + rect.height + 4; + const menuHeight = screenHeight - menuTop - 10; + + // Write DOM + requestAnimationFrame(() => { + menu.style.opacity = 1; + menu.style.top = `${menuTop}px`; + menu.style.left = `${menuLeft}px`; + menu.style.maxHeight = `${menuHeight}px`; + }); + } + }; + + menuRef = (el: any) => { + this.menuEl = el; + }; + + renderMenu = () => { + const { portalPrefix } = this.props; + const { suggestions, typeaheadIndex } = this.state; + const hasSuggesstions = suggestions && suggestions.length > 0; + if (!hasSuggesstions) { + return null; + } + + // Guard selectedIndex to be within the length of the suggestions + let selectedIndex = Math.max(typeaheadIndex, 0); + const flattenedSuggestions = flattenSuggestions(suggestions); + selectedIndex = selectedIndex % flattenedSuggestions.length || 0; + const selectedKeys = (typeaheadIndex !== null && flattenedSuggestions.length > 0 + ? [flattenedSuggestions[selectedIndex]] + : [] + ).map((i) => (typeof i === 'object' ? i.text : i)); + + // Create typeahead in DOM root so we can later position it absolutely + return ( + + + + ); + }; + + render() { + return ( +
    + {this.renderMenu()} + +
    + ); + } +} + +export default QueryField; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/typeahead.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/typeahead.tsx new file mode 100644 index 0000000..83428d3 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/typeahead.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +function scrollIntoView(el: any) { + if (!el || !el.offsetParent) { + return; + } + const container = el.offsetParent; + if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { + container.scrollTop = el.offsetTop - container.offsetTop; + } +} + +class TypeaheadItem extends React.PureComponent { + el: any; + componentDidUpdate(prevProps: any) { + if (this.props.isSelected && !prevProps.isSelected) { + scrollIntoView(this.el); + } + } + + getRef = (el: any) => { + this.el = el; + }; + + render() { + const { hint, isSelected, label, onClickItem } = this.props; + const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; + const onClick = () => onClickItem(label); + return ( +
  • + {label} + {hint && isSelected ?
    {hint}
    : null} +
  • + ); + } +} + +class TypeaheadGroup extends React.PureComponent { + render() { + const { items, label, selected, onClickItem } = this.props; + return ( +
  • +
    {label}
    +
      + {items.map((item: any) => { + const text = typeof item === 'object' ? item.text : item; + const label = typeof item === 'object' ? item.display || item.text : item; + return ( + -1} + hint={item.hint} + label={label} + /> + ); + })} +
    +
  • + ); + } +} + +class Typeahead extends React.PureComponent { + render() { + const { groupedItems, menuRef, selectedItems, onClickItem } = this.props; + return ( +
      + {groupedItems.map((g: any) => ( + + ))} +
    + ); + } +} + +export default Typeahead; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png new file mode 100644 index 0000000000000000000000000000000000000000..246d74f8e57069714119280093b341c4931c7886 GIT binary patch literal 258796 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 z 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 Sb8)c0pR~AwSe1xj;Qs(sW*lk& literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_network.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_network.png new file mode 100644 index 0000000000000000000000000000000000000000..702afd2b142e837310536a780fc73310e9100d7c GIT binary patch literal 170395 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 z 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 literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_2_azure_monitor_api_details.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_2_azure_monitor_api_details.png new file mode 100644 index 0000000000000000000000000000000000000000..578951cbd160347d36929111dd5997ad92197f3e GIT binary patch literal 20162 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 MNhwQ~i<^Y}AKuyzVgLXD literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_4_save_and_test.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/config_4_save_and_test.png new file mode 100644 index 0000000000000000000000000000000000000000..80a92dee7d915f59e39e4f9498eb62d070aa9e07 GIT binary patch literal 36072 zcmV)KK)Sz)P)7Q8bPRzOSr#?vMBT{tp2Fz&*x`{5U&vYh2gM zJC&{IEDgWZ<%d6)AG_IETyJl6nY-8T;*g{c0D!PwWW!@p@71WCx%?%;ujWkQb+=F9 zpC|QFOX>gsoR`!$WnPb@UNZW|fAwi1e@XxQXX@D_sRICTQeJ;p&i8%q$Mn@aqJPSs zoRcX^h%asXG5snH*IXWv)BymXrso>aAD5eveMIu^n@8sF%Qv`jQk_(Zp3t0^uidC0=Ub-SGN%pzz!~DaJTwgvzPCSy zNb{RB>nQ?yI6bKY03fgT(=YjVi{g9CvbWRfT^w%`{1}@_{W>wJ0|3C|sCvHt<&yPm zdyi@LTIUQ*EqbP3008)!XX@Sg`r4}ETE^6G)w;TBQjeaE8`e#~003})Sbx;FZBC-k zCqHhDs>i+TqelO@0VeA6lR5wZl6uywsGa@E`tG577^Q92)!Lm_TXrT%9RT3#a986_ zOwWLxUq%#}*E7&J@<4ria{c_I4gi3>p1V5->h<0KNVHxDIimSBj<@6Nb5~La0Qja% ztIy&uN7YMbCiT@Y^$|Re-m^2En$!URpu44U7SY)pt8d-_^Say!>+!FmSyKN#007*r zp2gHmtu&r{HpkZIR$SMZ6EBkbw*UYjuRkaCki+PC5954xlKO-7J$kAP?vnZ!0s!E~ z#_;LD`m~n&Lo)k$y`O%`j6O-~#{&Q$N7bKyT{`QC;2EaVO4V!mI7pLme18(=C$09C zBgxeRQFv9qm0NbNf^^=~y@ z@@+(-uWR0t@#OXU-rxZ_d0kE2x~l1Z#rw)7M`q^+dVV5FL5J7*6Ih|Iuh6Y~Vz$>} zFD4wDsoJ}#l)n_`*XPzGri0$RO$gJ`iQ?X8ge&3_Bqthqyk=3WkRu48pf4&rxe7bC zwy?Vci35H^(mJ~QMuZQv_7v{k&g1(;3pbNEgHrA+A|yyN_B7f#BrBYmnm2;b+8kEs z?u%-^4!1R~xC75#Bf>6sN1=xmIyz=MzZciPxqAueg`V)h$?*9@NLH*0x|~DakJWKG ztmZj@vfjFQL+GF2 z&Bec}zn-Sv+cvatsNmg8%vQU(b%<*2(hNF1_ zHBT<&=v{nw3Zl-=?JCvxU#k>L!6#6}+#!kZ^NU z^XDn$p+70#a0#E%w+VRSYI<+yaah-ayPA5WvE^3Y%Q`UocUH11+{AsDE374OMur1& zDB@NUmk4J&UwvZlNEl3z=&=9zLg$~PqD6b0QHdTUQUCthzsH+ur%>VD+K z=0Lw`0_&otj}_`TW;G`rMO;}A^4ZHJLY>=F(AOQ9y9AQwZ0bniwh9XpAOGuui&Ztx z3_=KfS?_4%g9^Ql31Jv#P|Ef79ztS8YwGyq>vPlW_(_dmSd2dX4Zj78yX zj`!a0=-=uz6Ce)tp(L_-HO(ba+)zC{eGoU577e%bSt#$^{dtJq+IjhyN@<965L_15Z~c|@i)gMK zs1uACbLuUC4t&e;8@OwBpnmF2Ab95GrTJ3$RrCcGZyshFpUd>0&qmS-xAf{fOY^T3 zF30?EoUfO~iLH}a2gK*>D4`#GcRB2#Lxrc=3X8%FihD|KpesJ^Hl%To;iL*(l>C&RLR3EXzo$T z?xk(#=yH7OcPe}jg6m8zrl*$O3+0Wif3mxDuDo@oZhiRsm?Y7A+lIC^jGo&lGwag*y)hrc8F`*bRs`;MvUm)_2Z-GTa7XX*yfkHZ8V zc8_o31yV5)8(SZg7un6dpn~{8_S1Ps=l{e& z9S|43y=qTE{5Zs50^*F-muWggu55b+=xUjKKLNdMVmkF_VID^K_DHR6=)7oN5HG%u z&jdZr5zIZlZ<@mB?=?_9$B&SDqURfkN0Bd*&eI_}$2F&}@0F=^orh7nFPKvQa|>T5 zmCkLjt>bfQF@28Hwqm+!28Ja195+%Aj>m6E-NCuYKDCm_4Z8FQxTB!Cfc#hr@bV22ERSK8ar$JJ$IxnAkZbkN*s$19}zFq20m~YM03+2>c`dm3O?{K|< zP5|8(Ou_M_Vt9u21MX`u-7AU-qZ5@m{@f~gT_`_Km%SKCCp`065P-3+bE|>r)KASS zI9~mANS&a0v3_c}T;0Pu$Eya+JQpa>ZHPZyo_+D4m)N49YE-&ZNAB$livBO)o|E zT-9muFRh!0F)Gvk0($)0oVq(+is_EePpAITpgBK*%cD2xm!{SK{nUon3-K1ay7*z`dIZpqir`)~2~&}W z$?bDs{g?yp4#meDtuYv5>&l>ulk&b%YR^$~>T|``m%05w^Z2`5^YVo0Ur)cl=sLSX z;c?Eh8bAl!J7{01{ZVO#!V&&Z_@6C>aTj(qJ|$sVyQr~bKiY@uS=v}4ck7Gsbmb~W zV=(P$Tl&eV7r4!u=L%kjyb*1$C5tN={f6L2I_A-vfw8Ax-GwmTDxv&M@nJ42UvjM9 z47%x;$C-NTXO+u+o?`eropB<!ht07H@Lq9@+nObBg9B(M9$V$lj^BjDD21_zR0K6Lflt>4(4T7tObj`bhGA48yD= z_Mz%I@&w0!Pe8fI>+HO-^$)zhkDd$S-tMx~Ir>nwP~QIXcaG#sT5jKo~LD%uJ9Dd6?x zwb7}E&eK`)zK3txet^`Uf-Z|Q$}-3GS2JHfbPk$Vh4T9AvKM?9otj0BYe*gCCySbU zag%!2uK(U^qeA(N*o~^QKs}1%HpXK5w%x_&FVP092fIb~=iM)aTRxAjR2Rnw%F`LV z&TXC)MAA*d^yfnPz1*H21otq);=66C z?~j7=IMLivBX+#RC)|>rX4&^JI>loT^#7YGQpbnR7rwU{_+COUX-n~#UuXAA@Eve( zu}d_s#%b}PF&84Y|A?_VPo-aV3FDEUSkf=n|LpFU0dAr%)P!Cn_`XZpIz@))d4|F% zK=TxfzidzUs%D>6IlhB-K7qszN9O3~NuULFQr@EwU;jR;F11@{#O~Xoad`3;X>h#t zDdLQ;et3=!c1z@M-#|4F)BdNxI=j`+6Ep|nBOO;Xwyq@gskhxPe)-6u`9JvhSZ14j{xy%+v3uzY-UVD0bK>kF;fSoS)G2dpHZ_*)}P1f z*2*pEj}Y(MqH9{+hSt1qdizG{0C%SB|9+k&@~H2OJ_Gtu2j@fBF9z*#l8sri>3waq zEN;-nNQ}CQs>dHL?~GiOpEhGK(l(f`^Mm>TZf>2mKNhEEd-@W3QMXHA45Kf#PX)MF zrqAa%z&%%Ermb|ou4`-z<~jhwOnn5ki{^vxbNpIP9W-D3euWfsUCx+r3%78ySKA&K zYfV=)GGfm$=G9upc`mP$vZ!%x2jWv4pDm3odi41^n0}Pwm<_u0Ckjl*=aZ!|BjPbX z!{|EB#g&ZSdJC_cb(!W;IE>zB+ongk^`$C&OYQZ~k$vg_I-(&35f|S*RW>LS>c}Jv z$Euj_d!;r9(~Z6$K7O>kD*EyTQxx+4Rdkf@&^@>8Q{T}X7VjJ#E=O&c;S__jdZ^ht>e?o%;-tEhhg z(*=%_x(-5V{%?!a{Vr)}n}slWMkdq;%5?zu*2CpyV1RhoooxET-}B_S&)4f;eXq2~ zJzo(`0Ma=CN# z-ooqsiShI$`cgsUow$4ZG6{nZwJ&dihRDZ(%MHN9ak&1o%NY@p%WY_p=WR|M$14Zq zXeW4(-Ox*5J;7YSQPDx`NJeAoA1Z ziblA+vvvfmZ!BID?Q3CN%V_jG&q(u)vQK>qccphxaI{4}Y)Th*7iOMU6wf%?7~ zX4%*XpmPP3yFrlI~^>VgMul(B4_yg;6WiTeN&RBi0J8fjsS8y+* z3+TeSi(c(Bd>yeq6Y=D`1iV}IEFYbg{!Z7vkk-c@EEwbsQOJGEHLA;XA^IX=e zlThFMqxNhY)^F(?7GDTH6rTENlRBI|x4uQ%<9l8G>S|*;jNW4Fm&^NMqv@w%{Y^Hk#RKATAoz3jhR#v9yFl>F-9zmzaFo=2ue3!7{+RPC zH^6jt`t#?IED-9{{5*Fj&*OsPn`;nkz_M2Pl7-xYrpS^Lg>h)FffzKkRMHG<~k>?3-Q+;x_DIbcgOfulBm& zBLs-U>n>s!%3B}pD;i7cd8U{M``Zoc!ne=LUm1M~!6(`Bd9}Pw3Z{P+)q>!Ci}ZKX zFIx;|%rt+K)SZj-X;K!byTH)}>gR}9`QlE-Ro-UXx?^>o8?U$RX}GSuefUzT`(9eW z;sJ5$cL5k+-7V=oPJjL0@{_~gj^1H(j@7++FUfn?9&6!f5=vSE6uU5C#bk$VZX-^$Q=K-@Y+9;WZpBKsMgsaKdz&>f{?Lga?j?I};M z+ZbD@A#A7Vr|{@|qV0jm-I)GX|H^U1h@FogwGhnuGQ_j!6JLq@Es|4YH>;43lKbwoJVvc8BG?QaihUBeR}mFNvY&mWT^Adtf>v zUl*=3N00B7(U<2n>Pk`<&G}NPDVI0|)^i+3B6p>FQ|a~>%ERclq@LM1DHq}~>G>w9 z6M&iGsJ!loy;l~F*cU+e8%aU=o!$NT5u^^RJ689bNAVMJL37oDpA^ccF17nb(m9sk zx7^FSu{uf;rjDB#9jH4~2h+z%!&jjE{Sp}cXKJ@NJ%i~tK^GJ#U%rk+G0h908(44R zWpo_Cy~w_=Q7?ICrTP+ssl0lvV0wyuj;>1Q-j>dL+;*$`M(HM(!?ZZOz5_S)!sX(4 z$-bc%p}esE_A8`=_4@bgUt06A(o>UliRu^D4~ria!HvEM^q~17Fnz&yqAxK8^qbX< z4&7Y>;~vHj*-#emi=d0`|cM%X*50}Br`f@GcAVERe`=4n4cwe-y(I3 zOM6_@IL%RxB-DRt{4pJ_7x#BMf1iQDT-jY-FPd8%^|*YR)P_>H<`h}1p0vz8raLUp zlZNT}Si2qvUeB&Z^6P}r&$Go>b~57ZI36OW;rZL8AFxiWg@jCf5xhPk<$~;Ctj>3i zay4RP-eYil{5U4mQ94sNP4{THWt?#N`gUn+(=R<9QU8*pUjByK6M#8Slk3Xxdg*-K z*ti5^@IO!72TSfL;OUu={zV)T^lx3Xu!t5IIVNgL4-(mgbjz>Mc4`|Mjn!{uq6!{(o}n zf7+TN2wrD=XX+h_T$r!#`_!TK{hLR9ert`oxfh&`>5p3XcJ%C|U_j2|mljJBhP3(xqYLXk zC*0D`Ks&u1!F1m`J&uygdwiDfn9A;i8T3;Z)~o(R^~xc-$leAg>kiSKwRb=IjL*m4;_0$`Yh)f>K>zo$c&Ysy z1L!G`lXYJq9lwryUHaT-?a(=93Xb9Ra(Qcq=mProzNy;xz&t~|N3;*lZK#OEC?hTn z!_)-OMe~WOuWQuJz|3(Zh|@z1=5F^3F_`1*P;P|X{fxxW1LBO>nZd6)b&-7p!GCY} z3wvE~>Sw;5V*KEE$XHipW$h&Rz%i?t$t5@4cj!eJg83ozh z*l13sg%ZcDEy~|>B6%=hlyvZi92vFXuUh;9iBW zC#}v*Js|E%btR6wKz$l4l%(z#OiSuUUoN*aZW~~v_G0?<^ZKc8j!NzCh#buiiMx{eu2W%oaV=_FyKbLth) zF;`??w>mDLEQRcs{ph)J=zh)UW%0kKIGQ%myxLa2>3pC?_)>9GM->ZAX5xw3+pidJ9ZaE;x@| zz9!U5+li#x={HSNId!jGpk5~hTh#;WL}eJa7uMl&(cCvm)eV=gIPM#w z#@40w$PbjB!rb|o#M66=PLHJi3F4A^SHz_L0;!X4(TN{|PaE4^#$kKXj5tY|#N@cPt7Tqvj!uZ=#vJjx!%C66wAFYk$-XH{5F zC-o1>DEX@%eQUw}4#C;mILB!t*M6USQDO1CV9LIi(Vl)8A{Wrz05i@D8C9l~vk;-cw%U46b?DwH4X8ylNYpJ$J~t^aD}i-C2s=|kiMxUc= z8RHk$M;?Rep>}wkTXOB)`Z6krKPmk`N$MatCd2Z8JImD5c{Im=`t^VO9}eA(r^EDV zSa?14^Vg2@JSvXUX_Q4wXJdM@F7?f$S2dqsBz3VmJs>**&!tQyO*8rRZhC2_ z$@S{AeRY(63FUDZtM7oGALe!ssRzFOIw>jkJ)RDr)3b=sOOA-~IYE~c;duH_~F|I&8W&y71t6#o;C8J5=+JIuJu%xH$-lI<`o zgqfKcEhnDeZ{peV_4i4hiVjq-RIS#8>65OfHvs5KeCptAA?s0MWq$pa*(lrJtiSCC zK&S2(iSypGXyh^nZd8o?=wBS@obX-qY3zR_ zlo|GZpQ?ZH`JcR*sL=qU!=5gOb<9mCXWY{?^YcsRx9hfehe~TxQc+mn4F32!^eW2% zq%P_Nrf1uS+uH^n*#0{YjL~(@;yz~*h@2pD{bA~o=SOvi%7u-7r&^m|;#r>gZM%5*`5R{Eh~}B^hr7NF+w(kR z+^&|V#5vOSb?{lbmZycD0nl+{BcILYx7xhg8e!dyI}X?N`qb4D^h`G>g(*8kt&{b1{;fSPCqnZ&5xf$8K6P}$ zh?*|=e~~&0BV0%U5B>z$NpCHod6|uZ8MBo24}8&g44dz+HxA*FYgF~ zzvC)`*_ly!d9C=wzNAHyE(UNX( zWPe#<%KC?b<5zNu2CnCPx>Z_vUNjBP;>JxDS@5Pi0OfTddq!3KTWzebNDkln?J;O) zhx$hE6k{wF9eiDRhAcMQXLfo2aJ%l3n=(4uT_EX&`d+Sdj4-COHXFo(+|PH)stoE8 zyB-5fg3*)hT`HPTkouRIjG|5pep)I9u* zQP|qW*lxH!1!`(r7iS5biyDR2b6V4mID=9=v;3!i>`gV^O=@xR6bD!NPyLq?PvVw1 z8}fn=uY9nIWftG{<}DUktjO}9PhAMc>Y}4Evgg+w(j`n z+-7#$yt4{B8h=}(4d_x=dd8*t`vv8qwq%5ZOGj*J?6DOwU7qKt<%;n??>b!dIJ6Yw@5U1=8Kny)UR6hbG$1k;pN+5iT{S z=Fo=)dK1>s)Q-k8Ss!PhCe2jE3n&dyX#pc>>ePF+Fg_ z0HbuUyQn7UwyFoM8%pdj!dRrK?i6KoE^x)%?VsgbJJ9vwj{n+|kiT&xbk~K^jrdTh zbe|(My-;>1H4N0@EQs9Td^!+iTSNE$e(TqNzWsYJz0e}J;RpXHzJ$)4f1jw&Vh)V9 zZ5=yuQU}jEuW13#m2#bXnOkPJoPYZd!1QG97lR}7^D4(8kmQWt$)vex(}4XsWxL?k_vKDy?QeGdwLZkC?{g%=je-nBK>e4>$V@h8{7NAbZ z`@-9^ScE_n2JmJK1a~B)!2vkcZY^5pI6_y8w$=SEV>yib|tf|pO2&P&S3YO zd*!#D)mAo*);;hgxEwOnB@dDUuVeO$1kQzD+`)C zAJPE>AXAzj)zw@fBCP(E+v=QG+o?sPGH_sbBlVN&OeM zJU1HnH*jZxxU_h$Hu9hP`9PLOk^>_dw;wC@Q{~j5va_c|H$$a9(WDEI-ASZ6y44F? z;{P4h+4wEP+mb7;Op}xBi*zDc(mq#BMHeeV7BOx&+AX=Bt2ys3Ky{vnuXwmO&IWOQvmjBLH`_!lhvw18Sj)Au4V$)w=}rdY@~B}EViMwc1WB~Xqv z2q;|CnxfNm?L2k0Jw}tip_7c-LDa!QB@VhTDWP_LLTdkikvcFpIAM}+T&mgs7j0+# z)kd(s@jvmaSL@ya3GVfx#hWS?BOZt+8+X}_ZHRlY2n3o2_V+t0Xzl7bmEAY;`}1J+9xsC(*R3z- z{_oa@KVF;v-#<6Yr3S*m-E>-B{Lhh zA231iJ{g2hg9LUz7%_{DDL9yQ>fo7P4u#i!s*lR36VCgb2}tM|>11C&Z<&W3QQ4}# zr-j@7!ROhbrDEztmGL&~KKdO^3oO3BXql2nt?IwSO2jX`oU3v&z=|9Ev35Q0%$sT4? zS$(Qcs;CRj!)BB-F1bfi^{7pw^^DPlBYf25%Sd0aZ;o2k5DKy=7VlSoR>klv6Onal zsNa2MmYCA8FRkYo#}!42)Xz%NA9MRY1%ovV+b&uSF@x0FPmy|-J7(Ym(E=Hl(mh6J zo$>nPQH#`=MuIuF4xa7f{uq}h{fUzX%7H8g$6HeE12Wj9T7_Z;!|5OWreI8Ps~-{= zVv5;yisrj@9B|5zNNM<(#~G!J~8M!oHM| zV;pQDK)GAPhEO>}e)q{*|K|`Xn#~Y1uGBBAW|ZY>wgVrZ@V zBVvSe_7(NLi-1m(j`PBHIFI_$b~Up6Qbc~*I%ZurrTw9tM~BS!ui>2igwwMWG;)od zy=o?u_PfLH8E(iBU3<-^V7*l^C5al9?h(3ZN`xBtre2cN>5_3SYIo=TpbM2}x{E+| z8WDu^es4mxsO$ViJ5e_+Zr^+FvuS z-Te&O|Ml7o0AldblPQl$kFtkO>HMt|44(I#N8&hr%pwAl0^@N;W%CNsgQt&MMNlSy z6arL`XgO|^nKA*O4CH4F@F@fz&@5{bUZmi6CCwt|+g{!S{F+vxWj^4FtC^-}ZFsIa za)2ElePpH ztN0X(0ZT+MxzR>q_k>fg^G$b4r-V^2l2eTEAC21~eT4s-aKbDxXM&-;Pwk!V zJl|)%W8AL5;{_k&|2QbP#N1~PKOIb-G$LHRy?(qc)6*$ioj0@+wuo&n-1a_N%!^_bz4X81j9@Dzf+r1q1hlVs=%q~0>&08XPPBN0!9i!YlQb&a`m~=Gg9vYr@W^tVXMgedc?YJ&W1vH zSPe7!`INan3^6qc*k1~{Q&y>GZa~=9zWz>%592w}pnJlpwB$nJV#t-Wni5uHTs$hG z5u{#L8s|6>mmhqTP%(&VnJyVaPE0D6291r&x=vuRPdHSd-!R?`Vq4zOFYl+`A)6OQ z-2P$&$-1=x_Q(ba;yqMW0fqu!hD8*PPr8f{GT*Z?CyK#HPRm71U_9dzipA-Y_Zx8= zxG}mm7}8CP$oi7(qDwZb!f9NNWx&s6^LBAni5?av@pW^s$i(CHbP1f80 z-ev;o&t*oZ(to(|%O&hSkNnt8Ka2bzezn%6^K!~%F6Unf{=Dera`3lwer4B}pZvMh z;QM-*ADQdj7h#t z#Uk;NdP;QZQ*hk>BTVP8uiyIg=L>9o<(bhRzM%9^HyF3?zWwhGtrd{E5xRLA{~@g3 zoPi4%w(s%1p)oI7={MPF{CD{Ti;Ub_jscV2JAsTY7vueXOU#S#F z$Ga6wODa~yu)|v19rp-yEgecF0QazGko3Q4>%#0Y7WF@Sd`{|j0mPqCdy9abXkxqZ zcdZ1M)7IBO`y%?b<$_aAm$8yx0TvYDm?u;&SLy(9BFiG2)CmO~6Ks;{?~n@2atKU^ z)TJg?-Sy~$wrH)<+;=UA0V3c165an+3!>G=(P1f--+0Bk10uL?1mE{Vn&J<#ZoYE( z>UgCS>Wcc)_457^LF%mYigvnu$zCV{c`G}9cfzAI4?SW(1*CplFPG~%ByJDw?PFL2 zg`W}}d~YU@#AQz#b*P_^ozug*>bT=3eHp8zmuX8=p!?$H+!G?+spQ&|UU$;#NuxUr za1#a@f?K2R2!?G*H5AQ1vj+$&dH|q#(BeHg&V@>quZ?nXFR-$4r&Yv24zT7y!V=WE zw+~L5+e&@Q6-yo`^`@U?f*Z{wQ%2h1UE+MB8eNg3PtH8`euDw%d{|4kq8^|aeklVE z>$G(~_)(T?m0C68j(O9E*vaws283hxkkw1j!=rY^Dk)lZC*VnVl@?Ip^KdSd0+F5V zfIW_Gf5n>n9)=Y6!iTM37Wvaz1XniBEpy;&T58}AnOUUROx>8Fxnh*!(M3|n8+5AJ zj=~mk1BX0GSY>-flKSysrCiS=31@ik0AKa~5rV>LbD=aYd&UAqf`sfsqU!z1Q>5dD z3RcUoI#w6pI#!B(Y$FYI{%MtO z%cvbf%lItv1;m?lvo!7Paxv&G9-N>7;^)2E*s7l#AEdIdFPp0$W0jKVIS%XXpu6L* z)#_0-mT~*?xJs46TJL3Ny^VkFskaI62_79(EA@gu=?)W}MuKBFSgY5GJ2Pww+g!Q) z&bIaizpY&^!G{r=1l2E-{y12|0Y7`mtcV8I5W#sHu6)Enh`wNOHwiDVT_x$c^l z(8lcg^GT&VEKTQ_H&Dk<-4Nc7H)^mZqxl+;7sNXk!kKR% zD`SHqA)sfaBp%}9aWHpqzeQ@`e%nqL)Sv7K7@pnPOH%A>cK~GBtbH8{DKUj9Iv)@@ z3yk2TS&3$lP%)p+to7lTz5;^HdfFB?djH1TdA}u+bbbF1e8PQia?BY42|Ac{f*Er_ zMUfm8F(N2I1#?ymqciHL=!7-w%8cXh@2M`J8*9;d)8kbaS9!Xsp-xxBr%!+D^d%g= zOZ#-;bL9QBRQ>09ouqJNCq*yM3SEe-FTp~lf*~!DTO;gbA>f;~6PrjQZ6`D^U?KD^ z(Ut2Y#79Y#{&XCjTw3}MyiVJnERU4v^a4}(Bfy<3|F6|A|A6LX)|$JI3cZW}?T`X(&&VRk-|j=p$Wk+866yYJ#M$KKOBe z>lFuOEy=w*fB*Qt)ToKi?(5wSz<0fpTle2D>i_$X>i9i+cX(IeeY@TwVt#nPR-Ib&-!bApGF8Yz z^-2N(`Y7sUFKooUbhsB=Q)>SVK(OpSz)u%T69^dq9(ZnjaeRMMO5vk`p zY@Z(Pybb|NmEPsMzQ3JsG$Mx1=zS5_@LOEt?}auGXkr(gB9=0xa@Gf4GFvk-XTO?R zqdbgAo$~g>&ABD7`Cl>O-_zuoE!+Md_eU~$Onmbn3C(Y(zdl@l)V++%wqCseq`o0B zb;Dq{S6?Ie9@HIvzn&Zy^&f$91CcsMTQqtBp7E5o{TAQ!dwgs4+!fHwP>D8;Z_yv$ zgO|qg8exoou=Ow&ptmY?;HfqW@-D8 zQhcWwg7GC&OVlU^^O}EoQ5Tc0pye)nvv%+P;nLTf(Gml_VrpYDlxe*FUdqXh>5VTE znqw#@e;sphxcYK8I>o=Zth`w9Z}BaOS*qrhTeu>U=b+emPTz6h`;E6&1#x;Kn!PJP z>YNUrAG!?pC&o3##MS>E*OUm*-0A~29``EZ5?f;vTPkLd8x46svAVRvyLfdU*qABG z(gaDJOv0EcuXXza)LHMFTyD-c;HWuAdkAxWzCn-w?4*)Ui5G1w5!5(58 z;yK!Esqf2=`vZkK8H{lae)HDXSDt1=YC=nVLX&9B4`=Ty^34nliKKB*@mjgFEF>4#2w(J__SW-P5`T4=$JZ3oyIp?(FAr06X9-24-1nz zM8u`NaS1I6$sK5F-JMzT^&Fu^?F&$|v^g=UEuqA*4sIWJ3YjW)s28~$pWrCC^0`_; zH#+N=TKXx7H`c(O;j+B3D=w)mF{Mkg321pSRBYf1hx_0&-<_KUP4P*s3BrL1h@Kqz z0AZOibz$%B4Z5fUQa6@cFJWlQu&cz@gt+<_snRSUbS2o-->L!@5nor8;+4NqBeOHyJ>@|!6iAnEjM9*{b`9lZ6F zT>v{wY!;Z_2Y!58mze3Kjf|MO_9d!|Dc9{vd*h)?N@vR|xbA$Y(8w?KE~1&~e3{ab z0)CEfO3gO#!yiLvcr}FNh@%D4j0{;?wHH3^d$}+{tBA zqr=9F^SPOKGc)@$o(~Wqc$k~XW~u9!t{xy#^5Mj;a^|rq?9Le)y7&f54E<|+rk0T` zut4GHdw|q0d<`7x-w>%6<5i^m!9Gmtum`64B}D4!@ELN39q^0e@$$0N`Do47r;!3V zQ;aSgK83)ltD-jn{i{lDTslLf-v5}?f$77f&e2J?z5z|{%)IlUvcG`RUgk$T`_ut1iAR@zYBU?ku*^v=>X3=vn=Yp8=^)ywan88Kllaq&|#Ey_$}x6FPO#nEMvkYd#6lM z@Lr&(;k2xr6Qqtc==_ieviN-p-}i@af9`n>=DnzFeS-6MznhOp9o>kNDAPl-7G%ME zAFY>=vR^@WEwzMHQ#5pnNj)O#Oa25J`|4~7Q>T9CA1KmL4RlqL=&Ti9pi$d>QEnkmj!eUEb zHYWAMRWVP8n4Y6)b?3}vULWj^V+sf9v)5%7IrX9-j8pJN(2Y6MTqICg2KRmJvj%18`D3d)Icf3K6cRr9SG?~RXQ3S916qnn9md|FX7eavI#17+Zhe6p9kLM! zlN8)p5G+W+DFyBnCO&g%Z3(Y^c} zpc9z>w1wt%;&Dg>wG)}tBlR#${(f5e5|z%$5a*R~56$C_0!!h&mqxaORm+4+yi9RdH5B2S%|CjBj=r`Ei^ z5BCrse%vfAzX3XD>w0!6QfDJHXOcQrv-F+OF?D(sX*i}1^$b~=6M~T^ilR~UwdM1O zGF5Z}bx3|<^~3tbi8q>{QM_~%jnu&dI6BTX_U_&THm$pSZd71g>yBYZ87Iu;n$Yr7 zY{9(@5xv|#H`NQ|m@=^c=>zczX6~H+W5CDZQVlb2I*|GpMe4cX6{T**ic+?w7=C!} z{QVTEdnvcix_ncpAh+8TqZ}rwXB#DJe?2_>v%9`&(RiI&tMK8&-*)tn;1uk?GQ_7ci+O7fmb#?oay1U)e4L zC+l@=wSXpdqa1l=w>=yBqLS6I^%-fCEa`lUfx1X5$J%sWzZys#2pC7l2~rnP-GS6+ zZ_%})FcdF;J4vP4d8H5avSs~KW=4p#v1;tYo1YM`|Mk_~Vo5J>%u-ICbNi;JeABLN zP>$M_qdXK-N5{n=(m`Kb(gYR=?FSYY3aS=2zdf9r24J5+>V_bx>xw|53}M%|dRllMJP+C$8#xII_7AHXs7Lf0H}-E=O6%RyMjG@W-iT-_J%C4z(~k%&%oB8)CN(V~Y* zq7x-*L>Uu|7EvR5ucMocHcIqP^ys7a&Wz3|!*IR7d+%T8IcL`1v-UdsInUbboc;ZD zds*iT3qO~P9rc%#+ei=i!P$ll8hg3^8yGiBVh#GmY_WQejnR<=ochYkC#lJz?VKzN zGf-UZqj~q0G}4@&B2Bd$Y4}3pb*CWacRn<+pu{D|m}0i_h{b)Y9{?In`t;ley+K zrV+D>pvGU!|L}pYU4Bdep7dF;zRfhzb>Q|e$?f&viD@9g)tO}Qw>1VW+Ub2BwBQ$} zO8JalH}o%|X33P~#N1f2P4+$f^Wy4JNzJ*dpk%ALb6X|21@3i3H+cAi0zM}Q&tpN< zq}6rY6Q!GlzHc*;aCjx2b@M3H^Po-i&nMJ5qoOK|mq<)E|87SZY9*zC#sFBk5sD_> zd+QQEk}Id=Q7ic-pUgRQDrNJnZy543z9w7}i2I?&XkyFXW*qq;0Ua_B(e%aiqq^!( zAB)R`wA5s-ZoQ{kD7<|DpCa>3$n{$y^o?+xadqQ7D>6ilF(fNct=XFH(VPhoki*1^ zc@YJAZ~sO4SC@zU;K|$sM#bVm2?)q>|0tiTFO`DIb58ZaVRJay_RQ+((C%pzbrjrj zJcntRIzR8@RjV3WTJod;%5=YbIT~NqTG!J2w05EtpLh_=6XG~j zGI-`B^B#=Yk1*}<)7?tJOMOt35B!Cz>0V&=7v5rd|&whBA{>oVl$) zt5S~ADEqIx+86w-2lyz>Oh_${sK!Lg(Nt&O5ejw%cuw+#N-sYOj7o&^(j*kH*ENWc zuTP!VR)Gz0KWy&DKu{<7vdN*sorgr4%cPq32CWUnBc-$F@<=!uYnU!)l+-crWRW`9rP5G>`2-*n3ddnutZTmUTl1O%8Lf;IfC znBe;9?-x+pqAV=k7(8AOQ@^EV`da!&OTd%^aE?+zA@%`cPc2oQEFNh-ob%z{fH3&; ztoY~Isn4_OC-*WiSN)?e;Qp;p%5#E^-6#Nekgh#(k$D+eq6AH{>c5A{9t{s-B{39| zon^Cl8wb~UhzJq*mQn2Pm0P1|GarXm+Sz24VytAZO@|ahR|K$MJgEx#`|ewD`D6$aQYq!Ql*tcrxSe}yq*tBkF2Tt> z8qv;g5$<2+a|bqy=0^ol-^0f_;5_Yd-FhsUkQn6E&MCkIys^$zC&E~pMc8&y;BYS< zsesE3G{gFU{w3R}en2B}Fr_+^=HeP;^@u<3Wno&N_C#{7g@;Pp+Uh z978>Q-A2JR6x^+%8gKbo$wFr^hB_odOCgU>G*AE2_4mltbKJ7i{dK=&;D8IoGLQAt6f zlZLp--#V0vHor$vF)-S~4#uws^xGYB|R-1lS2phzl zwGm&Fd1^-?(f#BkA^Ca$m?hy)MjCi3sbRhLc<=jmJ z<5@pF<1{<=G1cHQK4f$PN@#ppyz&w=B=J{WRPoi(b(noiK|dCYF-S*KmacjhM~A^d zf8>?N_}nJ}@Jvz5+HXg^8JK)+nO}yg$_U9eTU?H$DvrBC5}k-o@JeU#TvQ9Go>Zs+ zcoqd0FUtTj#Z1i5Kl|UL+p??rd!lM5w*Q-cFY!Z^*aYe~l7AV+6e}6pxBr5ha+vvP zbI^!J1CGFYusGcm6R$+{$v0KWg=a21R|79*k+jbO;4Xngs>3|rm~4GKbxL>m#|~fXC{2}VTM|fz3Te*SJxoDr{h%aj7zcZO zP1f;H+agk_d9Tc=u=pGQj{!AI4Bl7)sVwz&t#fW6d|}-52Lb`V-lmLQ*Lv^Aw5s+E zrjYTtAf8t%&*qblP>;rYj>ngD;8Y$l75yyLikK8_q`#OlTa|R_J1)NURb6h)dymx? z-3MSAzX#bUV6N9(%%MR8xBeKC!Br(Dm4={8S;|Q@O<3MeY@+9?y{e8}p@>jnS;Q8N zWX=7=4^$|d}N;@!PA)@MX(Zg)I*Y-{)LhaoK<(l$E@ z=V+Y=Se+QW)Syb|@&6JOojh-7BawZ6yS3q2ASUSHvn%wSl)E!W zx*xWq`fu=k>$}*CpyR&!U(WA)4sN`n-S&y&B^iPK2|A27%4qfTgP0^phBBv?&fY4x z%&)E01?>@Bi!XzqD(oI;N&KOlEjpK+ETT-XZrio?9Y?<~^c^fEiJ*o#UF7e~O1Kw~}y%{e7X z8olkTwIgrMBhWOVa>0xbl){pv%DP`^teOo~dhY@C7b%5INxh-psZ1%l!$79&%DQ|F z&?m96t%E(NUj{kL<+}%&VQ^ql#J)*xGM+D?*TO{~we_2s3c^<{02V#l@zg2NTxjIU z@&nm~M_PAVYvH5T8JC9^Rb8>}cLUH<1CgBmK;C-B?RF(>=;1!O9yQbd{% zZX1uHX4S|`dIDtrRAepyr_$ z=Tgh%-LHbdV(;3VmY6+N+rB}u5gxi={K^9KAy=JOEg#@rhkT{5w`0i4_nU0;-!y8| z%EvNsNCN4+O+4SuFW+`N6O?;te#Degh^8Bd0iWN@$axNkjF zbJI&c`p@`397F#&V!hR!Wi-Qro#p1|8^@8Xw^J9bc8%eio2l&A=g;2|Mv8DV1!Veo z8aT!5u`rPY2roPzS~5GwcI+(3mCLNLe_PCGErOZI=yb}rnF9GRyq9Y?s^*}r zybsTB8dgTpNd-nVp7rXJa{LfO!+SNz&6{zDXTv?k zB;cCKx`eTnn#8%$loV5ui>;TyP)bJLxphSYN}(!3`YF_vsK4Q+pVo{W5m@)NA^%+k zk_Sp!OGgBD!l%Id^e36gOjxYQ9zYvS;aR_B@{(!IMWyMCZ%Q6Qoi(J0KY0m}-f5giKOXNt4WJ7Skp1--Op5N{IFE@ax- z50fC;6HaI^c|=4t|2wBvelUr_XaX0pK92bx~2W+LbBUrlV?gVWPP& zmH@|@J)SqIRD)xlZx3U3N@l@ZoG6+bb}d_$LAtY__)S2sz2L{s0YwuF#6vEyoKfkL zb~8N7GAIM4SZ53#_7My?+&O;oYC6iM)Y>w=M@v^igNorbKkBS5F)87hfo6?c^nw2Kao60lha&e{Vv5N!-MKAt1FVBA+G+x0=CHT4Darh&f|52fn(@!OW z9(qY(>rHKw>UhCU+T$Voh1*_OvtYMB>te2rxK)F@t-a;`AaTzAxRM)YlSKXQ#e`84fYeR@*Hv%k=^Z5q|{ z*fZh}8113B`mxo2kITMC?~dWod(}Glv5Vw2Tv(6d?B!qNhIB~nCb{_*CkLp zP@K)9ka+nMM8{QIHY2FSp-^z*nRTiO(=-N#uCv!^iY~dqI8o&nIRwY!F`VQ8A-Up8tlU9g|4E}Q4({`xnu z4$RxqF8#xNLj?9~m6C$?wTZSiE`O{TrQuf0aNp{92~a8bq)6}QuvV>3i00@x_9e^y)(xs|LhIQ?rKP`e&IkaX@FD!v!#aVX$PEkt4ur6IRllA|JRQV z`U0)=EP9P6Uw-wc(l9H>$}2Z}TOqc^Z*`n0A(dT5wy|kMg<-*e=IvUVix>~eT70mB z(2?3=iy}p}NN%fN)6Lg8+PK%T2@rvH`svIwdgEpSCJKGRn1^UXCEW=AW^GuQ*d>S_qQK32dQo+&iF~i=k2=b)X2leF9**tO?nT@ zKZH-cX}D}1@q%irZ(6Xrc*(`zB(b=F&?|$PjemMt+-~t_5{34v zU8-#_FA`LmyS1{Kna=aL;v94`b)*4#ltss`{9cI$`p3)U)DM}@&yww)CMw#2n$r$P zxoXK+IT#Q1K4o>k8e>Rg_@jqcEAQT0P?E9YIam@x9-S}kcfPuo`g>nNSpOIxP+tDB9zR|X6Uxi79s$feF3*)tj&8wVO18|iDJ04@71zXk!* zJ*Vl>xxDs5#9eu2;LJG>8LhFcbahq#p2ec$7D{$ufLP>wYd^g&cijJ)Q)PT zXpn-4D6L#J+m@ST&GVJ1*${;HPo%Av7yU}1 zY8Qpy-6%gwv-XMa_su_B1`pr!An(Ihv|62ODyyeH9#B2sZ;%zj2Cki;Wz$ZAWOb-@ zSo2$MB%&Tv4zMGYNK*4(QzbrtsxU|(PS76ws!UP81{OR+M^fib?r}$3bZkgG`B_!> zoY*H+g!-7D&{d>K($j^30x`{4VR?uLp`pZM{J@>Xn84B!mn~5&FQFwV()oq@uj^Q$2I){IUnkKagt@;hP;ZB|HWhKb!E(8f`J}RNVNO_+NeSVzGI^WM` zPr(lnQM1f@ln!zpE7bPI9s9FvZrU)6_5wxI<*Kq`!XcyFR`@}W7q>uWdD@|Ph>7S| zdDV`k=aPki%QOTYu(JU7s3V@;{c^IxsZ}wVrnXZlIj5Vo9x7+)TQu83*SaD8_6KLg zw-(YN4Yg&E~ z-J43)xMm*9#^q+JmNBZBV9!d-645SRKBWEz#Nd!!Uq;~tQ4{dIceDzcNP8mzb7J-2 zw^feXc~K{3ZhYAXFuuikO-KQ5o@50mhSL2&nnV0?SykVz&vuIFRuw%zQU-e_203YR zsjh{9DHtfC;k;UIjX=O<Q0_V~wWpI}U_g?LdK&ALk~A$}BnSiDA#)>c9% z{`T*CtKCO}h7*Cy5;7j7ko_@#Q4GOR_Xeb|c=ogvsLy;n1z=h6Yzk;B)42pu7d~xb zs|)QlZ(UYX@>421%yclxlFHP#leOXg`!YQ7hvx;o?lvC;b!iygEO4&oOAdq+OPyqS z?%!Tqs>tz+ivY&PyZNMAAKvdLEyRh*#Is(cOZZ86)q=>KoVaLww6z)kPQb9Np7b*k z_Xkt?3!J3&j%zrPWx`6#`2O+V*TDDWd)T-RaAe zJF%23V}8KNrD6O&1^%K{U@|+w90ZR|pYbC$MhnyW3;v#MKKa zQRQ!G%>J(z;ZB-UDq={uX@q}5XjU#rhAXb`(PkSn;)Z3 zt+H&f68xN~XY9bOe)EV8^H1c~kNnTuMKe*S?;TJ5@WIqgE`uz%`(ezs6F|fl53e9k z?QI4X*j}2q1d=JZ_43atGjaBC2*#jE81r6t?uDoRsbAsHo9;$z#wm#{YIr5d$5PECK@SYLYWnCwmI<2Z3+=5ZE7X5H-m?!BkRARK1>Ig*(;dm+eUo#^ zLsP7;G+~dB%@l6l@{Gj+_4H9fQnm3@%7;>!&ZIu)&+-N~)VNy(B~#2|hp1Sy{1b)- z3l9wLMDavNlgp?)ync*=&!oGaxF*N4 zeap~Z6P!rX*gHR}U&qlFI`7#vO+j}nSj>4Ooy*H;;li(%9G&j_I(3^SA^Gyp^c$`j z^a`=CPq=6AF5()-PqUoAluW~}s0)B6#KF9COecI=gNfX?m>ufAD`f8{$Sl`K$sfz4 zqv+8~>jW}~ErZ=u; z`0|*rE^%?9VOy7&X9M%#sCw#c*FXp^i0_11RkZ!vUAI)lcL#XhlXlOI&_pz1_% z-PCl8GHr{ulTUi|@aK<%iz}~6=;SVBxNwUr^IAv17xBd=VoFeFr9>uGRc@;j*zxb@ zz?w4@xsj|M9o916jyuBM5JgXFx_$#Y<#IP!;q5D>s1Zt-zZb zGFrNw=>m_vX%xPp_vEU(LRXT88bQhFNYLsr!>mKhkVI;T=_+M7VCNCiPAqMQ?*ji~znC-yz0}MSm(ss_Ffy>;ZR9gs z)j^jdpJ5M~T5Iah)1QBIa|2LkMZ|PmWt?vNZg)*YT6nne{qtS=)#;WqpZ$w$iXn7c zT<;bRTtHsG)|^yTnbs&qRFyL!^nXMB)aruQu?Y7pY==0PW#y3Qam#7O)a;n0r2ZB< z^PE(m!c&JzhGVK&@nqM}RaX)P24@UK=47oF8k5gH(12ZH!ewexzhM6auWf522`n03 zYo$*~joTJo?^)M2y!*QP84W@D4nvAoYPg$gd^e2RIl;5{-6w!us@N)Jkf-Uwc^d<* zo$L13{({{2x`DU;qw}lBM|Lfc`91>{#PGNo^Dt60;g(Vq;NhbkV+IJ{?iyfkGi57J)g4R(&R>Y%oFNe-0 zz#Mu0OPV+)4|@atzc8(esrf!6gE*x=($)GtzoCE{#>MBfED@9I{TrG)czvnRl(5fER#&h+zz6Tq?HA&np4% zu}q$$wl4~;4i4SUIYXs96Y&OvC8tW|!dl)-A2>-LkLz)6|Jot^^w&esv_7kX=PDQ( zS(@%WgFV~WejN=~9y~u#A8h_&m=g4r9?#Iy3(`oBPnn3u4{NBsxmd$!T6$C|Y;{b^9LFa|d+IkzRP95*GUABzR+TYWJF4L#p+m0QE6 zPJ~r&Q}vXQ z0wY?&EsBh*UHAWn2&RP3QYcXW)4qmwc;rZbTY@~@qN)`I6tj4nGZrt^3UWS@6;W*o zSv&uPm2N*xmHF`Ad3%GK8*yXw#CkZOmDl=IejW>I-?nTnZ(%qH&7ZhbO^X_fN3?qc zd((IiDy34@dNvGIzAoqg1rtJhSD%ofQ(glIV(GtoMHMZb!4Vxtvo$Ta zVI;^#;`wT@veFoR?m@gT=K89voBfCQ$EgGJ26fc%y%Z2i@ucT+bcgzRE5k`kMa2nA zI#U=Rc8L%r_Rjp6Z$d))bDZTtQ}3sS7SOrcZ|BkRmz!m_syofZ88WW(Z`*jlQ3^_I>T@Wg+@43emzm2tS0W4Fg3r%~zsn z8S6!wq2bAtgTBNqGlclr2jvX5C)?WNjo3?gM2j-z_~rU3lOAI7!3cmrEycoJK&Etw z;!YRO?Rp?MqOYWW1%rZU7Bep;qPj;w7Zqc3yG?K48_vnXQ9Q|^Q^PtqZzbs4s@|hv zVGL+dSill;%bmHjF`U2;&vMj3?;0N2IIpEaQKp`z4#`soN;h}XG$mWi3*#t>7zjb^ z>A$~)$>FGsE?2bZ%0*eS>5i~@)&xJ*N@E`;aT7N%dQE&<&}e>hYVO>sTXcYSk?0-r zS(v@XYA#JZTjin5Nv@k0X06@I)xZ{X%y?->v=9%Hwjl#svDf(HZ895kYa_UfZE0G) zgZ|3o6*un|b!S-?svf=uRM;BHK6+bY1y&}X_RzXEY`N=b=*l*)Q3p3J4Y@sLJ$>`H zt2q+$K7+8{C(HVXTI16YPj=M}f&4IHr1iyB8mC#AS4tPwmUZ~t;go}~aq2oCslM^! zE=BVX8G+#@GpGIFHf-@{)XU=*C3C$@l2!D2;{`|9@Xka5|1tJd?wS~0{OR2$u1Ece z2@~;E8e0pZzTrlaM<#36Q|*Sqo->8XKizP+cAM_U5)n$2`_saagpNRiY|0I^i^WsH zhiiZ5-Xv&j;C;i%g;enQ>?LnMTy-V0`~n+z4A3uX9fmJiFt)EgQwuw{0_Fnj6q?>} zBLq6kVo^;)z07tu!kRF#pZh`>i5+WJrbb(B;oh59=2mFLbd?$2f{m1f!X;WKiN8d3 z#h$0>>BPLeZ@n~Mm@)Ga%MQ03v3}g2^2N&y;_-!aXe&M#9qyxF{i z-9xVSK}RR`Inmx#gWJrn0L-1xWE~x@CG-0hD0n?8bRtmaLr;pD9tRfZ3rE8y!9L?IY%O!q#?FC zS)-RGwhqwYkC=b%hILGE59obKaXcWde%}>jE;?B2dG@E_6NJFx)NnTC#8S>mM(x|3 zWbQ)qbaB3!am%@6sBxqmG?~mJGrr0&R`TxRQ zq}2}l2GKh>hGt1kov$|5t4szv;1{mR(I|XFGUa0wwQMc zY#Ns#hAGHP5b!oTE%Qu-l&E+OQ?^{x7*OXjIi%q5q~RU^;MB6FFFN18f2%Qc@ga|dy3*?h@ng&8$EZz!}{n63lCq+<{iE)FM~SY zuk1C|e!!E-bbk-g@B540#PI^E6Q)ug?HU-}AqiNik@Zpodg&v><;-vV3&uapYN%q4 z1MJC6I$84gN7L==i*BRI9c1)q{OdASD=(Q_DL|6 ztoF;-R2iCs8fA!7w<4=k)cLQ8@eU7Cv~|erz{b@&PZSLDvg|tMOLy48JqQ=@EF3kk zPhFGGrhi9$;X?m_xHUHjV-nazq-8xnIdmQh_+Cb zSx0u}p!pOgTdt3SLMBW4*Qm1LU*=&s^u9Ag`X?B+&g~G3<5u6S2J>Hfs_P~TIkL)@ z24=>E{T}B7>&8$)T%5W1QRO3yQsqVl{ye$e;xLm}>CFOK#Md`;W6mB=MWpozVzRFj zFDsrdO1^~9e{^$UepbEh7R5s5jA?lf^tZO;$Q7>+kLhp|fHrklE=%WQa7$Mlv?grl zNkmjy)ImMB9O;ex7}a2X;q@;=*UzWoh|iyrdZU3d9q!e?dd)J={24R2d_p&x#>xwK z_p!<0k#&b_s)yLv9qNn)oZG!4Zhr-T0)I9OnUgtN6tx5MM)|_$A3LeNEDcRi+U%`) zG5FeWy&v&XFlHUA`~j50AA24&E?SZ5TGx~-hQ8`g2LI+w8p8b%sbA~DMc5xdD*H0!AJSl@U?+llVl@!f)Q;;nTWtOp}X9%%)Jv$HGBcR)Ec10KZR{- z2xNw7Z0<%_5gi=FN4un8ym@D9 zSG2VP3(ji59Am)_Lm#xZGLZ|=7TX)&v^y|HiHtBKkw_LA_@>+D=QYk?A)}!sE4)Ex z;Z3=p#$L^3zQNX;H;n#eHlNIhKGleoGPztG{Qcy1fB$ON1cx6Lhh!{+eYz_ZF|@XZ zEH}v>1k5-#L=TUOXbzvl%%3+3B4}xbH^BFB$diO0ZiPFYOK}Y{PrNwo!IlDV9sE-g zoPC)yP6BpNlwVii_jy`rtK09?hfXD2yt+6N_Z_wUIxZn<>#`rV&8M1nQ{U!pH5xQS z(wO16{TMg+v@am)UuN1;ZM!BpPcrJ<%>@byFE}f6k(L0jf6t)WWxWDvlpau>4sonRSNGaYi*e|+;vA106QNzmLJet@McMIkM zhz;nJA*pTseKd+_`~YGyR;dHaUYWR)`uEUj;(W_U8U<+g$kcgTe=K2NzW9{uRg2Bd z2`BivhahT_b1E}ie;lZVR|v`K*hiI>80pk5@qX~r?WY<&Ow7L_y1hoXVF`^U-7AOG zq%$Qe?s0NeIX3veg@mtbE?}20{xB}wGgUbcjnuQbC8#`afS-a*1`N_6(VGsiofC;2 ztRTcs8N+(v{_(2wt~yT023nfaY;`ZZdivM@pS}jY@;5GYn!S~P>J{oiGa7!Pc!}h7 zm+h4uKPm-Z_vN1prR*Ots2v)+Z%lj!EPLxrtw6f4QNw^dci7IVXLW;*a{q(O&nRot zhQceG?x8%ogkN-~CwitUNNI;w=kJD9pwW6L_*Kz3iGk|haSn``l;UByR3Y<_*-{0z zKTGmhaV$IrSM?bsr;&9_?&R@3RpBwA#l^>h-)1=@QrC6}WL3@b#tA#l%1}kq2K0T( zHg015slG<-t-#p2Ot!PPmb(#68FMrK(C+8QKsp#ZKXiE>TxkZaz7p81y#+MK7R<~~IsML=f zY4n!cZR~Do+pJe#6PN}Ewl7_zfpvZcrZWMqdfKl&Au^xO8EMkDod}Nlwi)VluaYhy z{AIUUYfhSd-+U|`*><8|1V8|`Ap{C9naPFFN zeE65(eXM~hlRU}30OVywM3maInLuYmd{lh!(>wl)waGZPwEH9u@@{DP4tk=fS&I*} ztnVr=fF=wHcR*B_y=rmeuWS6KIEVp0Zw_fg&@HEWVQY~-uW@Nyqq}|4d`x`+AEP53@Z|ub72GY~gpjJFp z_cH&1x*MM*rZ+OakI^_2dv6Z-?Hx8(u(?>@!nWWrt{5e!WB&jT?~Nw&^S}#NoZMX5 z`5h!xUXMJsnW6O(ime0Q;Z(nS$3gb{*2z)+1Xzoc;X5uX-op==o0=v4G;yTSe+C+= z8lhWzP6Mk>3nth?GCaIv2>HfZNw-*q>rLgDdFy}Pc#UbK7x^s)Nf?_qUp~acOF)81 z3r{1UoeeyZcZ67l-!1F9Xs0H~lE435mw2Kne%B&8kexqwXxZf=m&?%$7wG>C1AiK8 zq|XOk`uy*gIJlCmpfxTVcQ>qaE{uSgjO{6x?HzLVd%0ibZAy+iyrj5hlkf4 z0Ub`<%!&NZXx>?pf`?ab)ItDf7E8RlE1xV3cYflXMlemrVQuj6Xr^9|0Pl=9y1!!U_I237cz6a}kNv!+cB%33 z1nw1{Ua52Z?;1M>QiiL%^Ws!VmhOC4=PpFqr8i^$1^>U}f1P$0qu~d4^Cl~03SIi& zh4kp87Eg7yyMUFD3;G)WcLR^L3IOVNZ9kFyE7nQ4kN5ou<*fN%UxX*2rcxs*tP!gM zi@v*>_rtzgq)!k$pfpt^$!-XI^J;{Y{g1ivDCxIF38`qS!R|8UDkeX(Eg8_Fps>%i z@0pmIFYXxkYbwhfe(@C}tRBbSm#=-a%_)c*43%@1^Pk^$54f;AS|}qyBcL*u4f`!} zt;|P~M|wAjceeheQS|JXizSz!ArEsXO7Cc46>WMn*jn1?OHv2s+YSxb^m+^=moXl( zC_P#0|A1tB2EvSVX>81?#j#&HVz%cw;m#hM7{@Oj`ka`Edh%FUDEH>$5@365yr2yp z+0gI_-sw|}w9PSD$&p@DHoID!moHpxA^(OVWTaN~S4C=U z-nxn!$;UDTgL(o2(ZCH^gUj=eHy-{isJwGm6!F})Gly-_C-Jmfi5%(rCP2AhwofIZ)ZM>q zKY{d*)vd$6Zx?z4#S0AP41oV4+y#H3m>`RDPaZsca6dr)1;y>Szb;);Fk|#zZ4QZG z4x@`vQXO`yTb6jTmO8QRxTkF6lH8EN%cd@A*{Nyqe{I6=)5mu>Z%1B)9G`sG*2*k* z`P6{NO4wG{_klTEyX~=6e-h96x610_5N68+Y1vGgM!blgm(cTd3~cyZ1joAO5}F zhf2oG(wN;iO5E&`+}x2ZoXziG4fm~X@Y9%L6-)hO1;pjVFuH{) gn-5A8b=w-Ua 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 literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..194fadeb25871113ca6131e32dc5ef8de754b1dc GIT binary patch literal 256723 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

    8qIfByk>d;=*7TNb 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�J=tT?SWkHK^%O6|~Jr!+} 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

    qu~UKtOEly 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 A?EnA( literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/grafana_cloud_login.png b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/grafana_cloud_login.png new file mode 100644 index 0000000000000000000000000000000000000000..75d07607806c492ad8024a937f710c41ff9d0b52 GIT binary patch literal 61686 zcmc$^WmH{3w=Iaf1`iGiPH=Y*PJ+9;yB^$K65I(8T!Xv2ySpA74swtt`R;q~_87N+ z^ynYGf7BjlkF{&BS+#1_nzK%Xl7bY{M;@=@4;C?|sK%yZ)y+1KX+_r>( zxa^b`7g6zm1TF<&LqMR2=iNNM`ra(jN8`SmKwS2CLH@mK{B8V!(0^|xq<>p7@ZT0< z8UAk@$N0Au2K}?GMgRLF9`LvQuUGzKH6-}=(f`frf9UJKTm3(O@qhTpfBO3GuS5I~ zR{!r@;Rc<E7O?F(YXD`%f#X(ud(_o~KX?3*}sNmw1h>Yfw(R{|uBh!HT zA@68h0>u$XK@0bz-?en6^cXi(;aNn?)$Y$JaPp~D9br@0iJXHL6?Eg(5+OY?E>PEz z<9r@~xDhbQReY$*(V*M%)X;6g>e$9dH9IINcK@gBP;s94%eX>}R=I<5rDdY|)=Bxu zQ*7A6)wB@tqF&))%mzc9cMB%k77 z`!D8nyaxN0cR>lL-w&KfzV{vKo$rc2zsQ_B+o8N0{pYGd-|AKndPTn0mYnqIb75It zgoRdfks_t8yzjE2h3@+|jqei)g!XWdzttjeFO8~mY8<=-J;u(i40y?B73E#2SP$-7 zhsN2x0|A_c_>RrXttff-Qb@h3ORab3w=uZt8EW>Q7JunjHMWU!HNVUq_&>e+OV9kw z(6V<5Rul@g{$n-Y^4a6c5}% zPA_t{8Dan31O5KR&8?xcx)an5o6K}zjo^(y<>nzNi8Ls6WGf&N@S(BYOX1Uh;vE9Q zh!leJvQly0IjF;{*sClvMn7qNS2p_K!U<6Iy=SZS!~Yps2;a1onNidhU(sWZj@A9y zVX(YTXJ@-=(?ASBo$SALsHM18sA!>?^X2nV#;8N~japxkSxJ;Ea;?5vKW)l*66GP9yT7{etY`Q>{?oz2;gadN2flm&jc@s2cUFy(Mm&Sr>&-EmvCv> z%Jv=WxE`xj&N0b@5d|$yekD60t8jw;3*CgWxp`5a1XHrCE+t6bF=-qjW+Rc)VGzk_ zrg1Qo5tO{7f6gCBS)kgp8YT1SXSmm%nA{@K94OeGw{-YaC&zyPU!^E_mb3p_CbLRJ zOh8uWjEkK&jYyXc``UhrJ5s1`f9v9V`>HuKymQFnbmv3xFPr#Z!PcNd_|~y^zTldJ zMCxXGr4cPnzZb{H3$~YhlP+eZN+YS$Umd?Im}P@Xg&nSsZzA50F|Q)v!E~1kE!$@b z^=6~L-ZC?6nnn;fovNjs!2jVvW#P@!4L&i4gNio-Sj{if3uOLLj8R{S{2?n9ErMkl z&|SK7om_SSaxfC?@Uh96QRG#txoEs`I+BnZ~^;$2G~NW=5-?W)C*Drnh%+dprzb;3Z!PF;6=eGm1D!%uA`OP~V(ZspqhX zJ@fQ*aq}1pNJd`Ilqc1lwN%NAOz^%xiOyfB)KdUr@z$I2alv{p|BF^(ptEii*Qdi| z;yEIFo0&frf9}H2aWB^Jc~`TVQ-`;kRZ&+$$CO~9AK+dD!5_>)`{1yhPh)8{iVnJ1 zCs&=uR-0}8s5kR`VLuMGN$z!XHbnakP24x>6f<`{fX*&Trzp2vwvlo19Q4}JYeP>T zzlXxmQe3U*`E3j>4|?zz*SN?T=QtclCsKP`B$U?A_Qf1c{5ipUNkw=pZ_fg)J)Y0( zqxA_cW}31Znt80zdRLwWh0>s#+3vowt*`xlU7_>Zm2sL=I^pt8hE*^hrMY z2af>A@d?@!ry;R#^Wz7=5yJYVu9tT6ufe{zZp~&_2FYy)ptHkYSQ3i~#L{=YwFksl zadk#B<}l3KB)%nuivy;}DYw&%r*{^3Xt1&a%pk<75``h|72DlwoBFyZ@eFrhLd+j7 zn>DwVZ`jQXp4=E<9f#y$@v6)5LoheR^|zkzupRzo<|L!vvoXAw?i*igDqYcORrUuk zC8_;rHTwQHK>1iana$(S5%nDO%k+UKfn%H|Cv}{+n}ed0=l$;otRyK_;VTV*Q78Qa z9|&x(2HhgbqDdv@;z2>96DwLK8QI89Y~rNUbUiKQEbd=H&lk@MuM&-@crH?r^R$kH zfzakMaHLUGIi!VNg@sXx;`-of#x7@L=UV65N1|3?3>m!cjT8pUT_$9|viz|z z6CPDh!ukd$jNz3R(hjwns&l8B7$rD(+mEYMt;6K6n6ig|b7-xZw^!0pPVi&)c)$GY zInas4U*!a&<;?16&4WGN!G`lK5(iAIniMyvu_{ITXVY%J79VgAS^w8+;=nSabLTj6 z;g!F#A|SLI0InH7U%0j0R`zZ(cc<}*N*r`_cMG!#u67#YJg6TdkXVp~M% zC^Vw|L--!6gKyD1eTQug0)#l6DbtS~xwxPklI3=Xk=>3FImXVZ3xBK1`OyZoD<&&B zRQ&Sbj>$5&i>gZdsRoO10i0HB%Cwc9Fp{MWobpwcfYh zlCgb~UZfCq{(bJw%W4`kxLtvI3hjc~tWiw~CPK$PTICd)vXHw7&496)Np&;pV8@rV zkm9vKWA>JY^f}YbvnE8W;xfHf3Dq5q@V@1mIJKNdW@?AVrJX0kX{SNs94xp(6mhU{ zP+vjfzU?Dfm}qKNd1KMWvGq`*0&&5TR7ef~LPkXVxS(9kslELtw2UVybJJ9z$cD8@ zCo)j;CBvwX)$av>8~HsCV%_F_pp!z{SmS*KYaumdgIYxTn`{2)&!{o*nQ}8*@qgmN z-w_AQ|8ldrg{3`?+%eWbfE<h*)Gri2QTw^|^$=b?@)Q zB;NHjxxDj;zK(B_&%03^YAI(td?{~VL>G}A&4YYvEu=V9M=xk{fktQo^}91^HE|yj zcYT|^-nJ&hR#uNRfX+TjBf>q8IMn*ty?xTY+WEzBTJkw&Oj^kt8dfXFhtcyRg1k&U zQ4tyS0VKosjg3f)P0jCnNQh?9kOFEf+Gh1CR{hI@i|Rhtb-R|sZPENkv2^zn|L}j6 zIRvwy5vgkn>}d#x=b&s~RPV1d4&J;!h{(Jhr1b@D(u<|TNPa660~JFD(3(=+TKZHE zcj-{5q$?nMZA&}1q|YMOAnV5!H;0Nj3efRNY25w`cacKSv z<=-kY;P3DF%<^$%$7*(x6Vb4el4(xNBS?Sf)l4v-IQ$2yE=4gdqT4CDo9ulX`E6~# z-B?dt53<6wz!zhl{*GuY!*9#SB6aE$|I>~AlGQox4-MKo0ZTtaDqV;X(9p=+tm}@iBLNVv7 zFYyS2G;voWf~`#6wS!M6(B|1MaOnVTco!e5HU|dNkhmM8oYTo`tYdRabo+{>N4kQ| zRi;fLY%VJrIolc!+`Lv*_+Q0V9l9S|Wd=yZ|Kastk{Uhw-SP5mAm>8F^PINv@I&mW zR=tGuD^kDC)9$fD-cHu|zEH5y=2dXB+9Fn86+2KdtbR^GBbVBkqGoaelT;d4u+edl zf-3%Ixc2U7LUrczWamVuyM1kD4+ZEF%C!cPG_&4QFIXw&27QsMF{ksL(=352208{8 zW)f*Cef~h#Oa_P$j|z8c#Lt>c2>-1W`p)C{cTfZzDu&+aM1RL6@uH;HuPr9^6qi(P z*`kSqQj~o<`}c%#I$66{^6~`swWEafYK>SgLuBK^Z5z~lDhHns7Bbl(T^js@39)NN z$Yg8!M?6h#X~0_W-7(jbARi?Dl36st>x<#zk(3M2;z+ousCHm%@w{H z&DbJt;$`9MY&-Wj?dQ6KE114bc#LtEyPfm_DtkA71r{uCN`?t7@e7HwT6(`^s7;^5 z4qvJPzL^E*%mpkRUf0P@P8THkoq=Y}Z$hFygaj?ryflcjGM(!)>Wo$L=D&j&Zs-KcU`061#xTyK0)1@q*Qk6i6pmxB3~3NZ*q3@hE#lD_%V&Hl6#H zq|7gn%FfK{NP`cM;1BEytpCOIP!U2(>8vGEa?6z>`Cc5>^6{WY-1vuBLfqJ6hm3xv z`(6?~W~sE?CfOVZ7ldO95XaYSTNgWE(s(3O$r-y-L4KJlKZr;0_l@ypL0vBwDGxX{ zf{^$u$+cvuQH39rFucw4GsSkpQ`ug28WckEx7y4yT?u4l{xjIJdGBTnj&_2Y6n~2; zg#$2|_hwmB-_z2z{Z%vGSy+zsV-8DLux?A$X9W1g$X|_yr|VQ>*bV2|C6keWIjge9r9bybf0*yGFlVP3-xWBLzZNz#O8H`zCe4(5)hKWEFo z4PMMHo0If^r`9oiFC<>;MJBr{X&z1k6v}>!b_Esy@x7YrQtIH({9KqpJl0Bw107*% zht{n;;AuqsT`EultzB(%4NqZl%h>U9xJh;e$q=ThfvKMvLASSPt=mPT|H+d}5MX6VERG zW}Sm=+N;YS0-3^LnI~-6vHgOB@UFfe1vLjboy~r2SBUR5LCC?JZO9QEXKwx9UL)(v@$1vu)GYWzwNE zRA3)=-k9loh9=f+7uz-0&L{oH6CvIsVfS?}S&Z7X$M^x%UOhCJb(is6zq9-UknSxs zsV~@801&c}@=S`AlHG%G36wfLZRG;Dvegx@?MLC!^Gn77>Q@w~G2GT!ua?Z;!lPq9 zEMy7R@Xa_6m5_~C5uwZ!kRfod-BCQUM#r#bLT8$vxIRer_%36cn< zi+Eqik3qaV(9-m++;vPSEzp=9MrHHaLP8GSonGcuPHlsNT1V9A9wnf^b+jEIzvmri zdL0AbClm=%*UtCc?WJ57gzL(iAl|}x7_Qgk=mM;w{$CjJ*n92aH^DH)an^dP`fis> zu32h>*3)Vi5t*mm<-;jIt_02>HkeE9>=}pnim>SNKu51T&u!{+{qXwsQ(1O2GM5eS zzLQoCRdzJyJ77{8)!5f#4!=cW4io|g#oQi@(3wysB{L?kbVE;;J`QUPc2Lhue&4o| z)s{3BZyTC&q$n5^JVZZvMlk{CjogL0>P3eSbo^5&&LG19ds5c-jhp$@AI!Yu|4{zj zVUsq^^>@Zz7vhhc{lZQV?mq za|7?H2A7{JXRKx ztia0g#dbn=G6MI;Hu75kDVz}RmZ%U^(u)@}&OzrO9d4o%j^vujs@R?WcYZJir}bf8 z_UGWc64EC^JRy9`-Qea$wE8u}ncB;Cj0?qga1EXYq&YcpXH1@G*a`Nd0ewp)M6Z+6 zc8p7AN*^z>k_ zkwqDaoX#TNm2pS3a-g%pRE^%Ud$FQAO}f8{AlPzvKB%rXZsVMEUGM>f>A!OEh0lS}>uVyi4&X8PdAGp`}*()JPT!-l2jJ>N-UD$81 zQmx!Q?xbRe0TqVVnAya**z4!@=AgTomL8D6=qJvT;ru-#-qJsQdC8PDlW9S`S80H> zi)jO{GAeUPS)d) z#}8MOz`xu7bSHB%%RjH*6+izglYDg#BX}GbXDMbEP8ICJ&;8;sOuG2^)eggTuk%XagOa6QjS+Yjo5-L<+PT#TRnuOg6|J`9`$AWIR<3KID070782bmJXvu zviuZIgT=gJQw435sNumfm%}w54Uc+is=B);ps_O z59wuyM*nJ#=DWj~wcPB;`R|--!FNBoe#__!6=-dr4S2k3QKMXkKRD)qXb#JoMcUZw z(}S4XD+<4j-#Pce1^tx{QM0tJ_lW=&ai0ivzd8Fy9^|5pGLv&bvtua8mZF z>X}W8EJ80>XLp<_`&_PqaaYo*DYg<9rT|PSP<36Anm!IO6mluh2v{GWx5(5yuB==Fh5^k55gdvR-0^RV{>K7+w@ zrBrs<_jgBcI)Be+UsQ>wGMc8-T1G1FU1W;}RT}Vl_)=eeWCUz7E|VVTgl+Q$dP@7h z!~OH_olY^wBCeA6vVw^D>~r@e)V%KS6iJ6R8ssL1Uit+`gafQrdMu}nqMwvaTSa}A z1+dIMkplB>tcW836CdY}366#fhg-E<%eBXBdttM*n&)OSBV~BU!>Avk3w{EU_zwj# zzJFi$+MR3^w=$cIp;zTAIEeNMz8#Ujd(tCx9C1+92Fa-S&-I(9CC`3AWh@n%wf)eF z<2QqShH!_|j_$i1vCnurANn-svHSgB0r^8S$&;QKe%c$^9|TcS?U zPsB-XdKZd*nd4%o;Qkedu?cq$Z@ON}tcP2gU{t-5Tbh2?H#N~zF;l)*3EW*t%Tm|3 zwMP#qh6_*j-;DY`cvYQFvSN~uaWj4575Q251>;;yM8VBWCew*PK+3+}4{{Vc?-rz9 zH{<^4#{T~23ZKg!^6>F8PfzFIqtLrb?LN>lHU4Pt226dQ7Z?Mj;^oc^OdkQV_i6ec z&XN9a$58)&9ajD45bgiFE13srS|>*ke~j=$yDxtxuD>}1g`f^CzPflqs0d42X(nb> z1>v?n(+`?<3?LI!hzOX$`)9r3>({Wt6>l_4LocuWVj@H&`?&SZ?$jBo?uz4c{@`O!dqbc0Z?bN^&VF^Q z_+JCj^^xv>+`r_dR4K^K@n$VpcZ$0IdEf%OMXxfWh(X@IHl8m)buacxA)?j{mpIaV zGvXDPiUJT4MgbNxCx}8JX2`b{H1N;V1P##z&dFaJmPK}|$q_!_FdDJ2y-w%MmOM@H`zDdyp&O9FKQms{DRFx$XQojSJQomlHbYsb)QxTJL$Bo@_26kapEt zbiF3?X=4h9v=q_HwC}f`QNj#x`V9rs$3o^ZrN}-Kx#+%x27B~8#1%tMZU@CKo^vV) zLD1{m%T5CIXIEbFO9U7s9-fg;UQ_rVgaG`lOyH1KFa#u-C%47HFslcAIfk`>D?)w` zIl#J%;M%+VHaa_QkBF<<5))XWcvGFxS!=?<#aaewC=0qixH9&X$KUs z41~)*<+>HTX4qN@p_Q2H?{{?Khn(-8AWJlLnqUvYH4ZwbYY?p)Ic5J>XHVql@&59OyLw2!6SfZE~J}) zLQ26(&t%Eb2R71N4{f$P!N@gS(Qj6-W5hitK>pQL*uzkjWph!b+fUE|%a%dQd^U4O z%VdAGD@=`lY4?fzk0&C^LUUvZ!fK0={>0Gt4ztsj-2*cw1{I0xn9U7a$>FxC-yhj* z`(CQ^2B4MMiD_`v5|=J4o1LkkUc~O7YjN2`9cA1;PUMB36$Ih#TMGwCv zKJ@eYL@Ly`aDl5)o}(cH&8O&~9&TFd)sOp`@yybQ$jFER&Cp42*B_@1f>$ToWYN7B z8SX-wTN+zRTT_$Xj|$Z{)s4%xJ?N6?JqnG6>GzzEllESBj@mP-)}eb7<~*q~Ze(!i z4{Fs9siju+6@`#Ac3(`X^l13ZC?}y}a!9JOv1Q%pB@aKYwRWZt|LA;<_aS)tGk6X zj`q_`b$#9o0B2Hc2$xyTI#>Tu>ST4?amGZmiyy>y-e_a<1OI2lYf+?EhIdq>=h#3O8;`4LF*PXqUco~nnYvlJRS z@|Nt4=?)Ff+Y2$IifzREx~9UE*+h4Aub|!S=swC(*}<%*<{;S)0f$!v5D<=%F!AA8 z1{~DFCliIg4Z;(2=e-!}FRDE6P&SX6#~RzC^?<_?9xPX)2)4C}fus5oz1uIBA{1G) zYH0?q4(A)T?6_JobAT+=6~j&U5EmyQqDt^Z_r_@PdZVz(#Vh#xe61|HZS|zal>vKj zb)0eF%jw4D_aYTGCcpjQ3QUB>A+yigTR zcreo7_IlQO*2~vD25sYp&BOfBAr?M3ke?e=)$7kkY%Z-9lQ3-HWSg8HgdUuywg7C1 zxoNeGLKP#a4w)AOfgA`;lQjV6h8Wn;Z0CNi@! z)>+dar^d$$B-kYwjfmUS=&AXWx2OOsFSj4wK)*ea#jj&(QycI$c}OT-mB2GnJ(*nt zezaoH?=Me4Oe!(#uIjj#+p#K~@WKQz9&5mlRNRVBthL;TpGmKz%@F z+Iu>xW4aAhGmG_+C0o=?%MHuTV9%xK;JOY8n zZe6O;k6QvT@JMjsNXigQb$xq=RdPK2#I>I=>b+%_C6M4|_$Bjogv~zC}y1|JuqzTmqSqkzZmLUL>g zUT3=oR*ecnBSO48(SWBdY#o_ZbYG;#iemw^BZ9);fK`0qEQWdEuM0S#sNj10+6E{@T)qu^_t<8JelAp8R?# zSA5n-j0v(8c&KKcy7BO!O6Mr8pXD=-s}I$cBN=J4Do^~n z;EVq5TjR_DFyKq`0_ksiFWq6)r$F~2D9L8C6_pUnw#PqlAG-Y*(r|Y(4wkd?j<|4A zk|J6g4RQh^?px)+R%vY6a<`g>@4>v1V;)y7-vj|}8bYOOyAv(x$XLPw5!@LK|LD(L zCq1PB`-`!3Io;0@x-N&^85y+rDjkc?U*({cl3}KdKIx@Q(EdQ(D%sE;0mEmJ?J^n^ zd5L}DhMraNAlNhFQh-F4hY(EpV&I6)q^_}9e12fRS;IDPt#4jD=x(2CN*eOD<^BYU z0V~98Cqm=HPR`G**9Npw0$Aun?dE43f^yJa5fk1qnW8F904|FN_w7h*qkLggUO;8NUd!g2Vy^wG+g0ic)A?``yn`1OyzFAM} zD10LOUV+-yM^|~hQyf1l2Ln=GvLMB0YJbFUeZ@_V8H{Yi#4dC<&Ynl`K|E%G5TU7M zPJ_SEJ`=RDUO}>&zIG8X02YozkF{`^oUVvQL zHn(jFknICAvepUOZ75v!AU`ztqGI$c|4zer-!YgI9%B;v-@Y!83 zjf7!bu2pQp_X8CH+mGpT~aqQ?>1+eHG{?rtC4|$2LDR+ zG1oULoIIZBxF3DVA)eEgD`$UCFv|#F5DHM6YDpcGSy~EPmtd}I>%Xyr2#R3k{Y-{V zVev=AN@AEsFX=Ep%Y+whx@pM&lB{WIYzQG@uoNm3i#nc08y5ImmVp4^d`flLn1|bCo~qd1jIxc0T$QRucwA(c*}GayoS*X_ydGra@|o~lH?zgWIB-1 zNyK++KDEg#GB%vCZ&c2^|FLXK5@$xqr&=?30)2IfgP3vAPkzXoNJ2CV%MM+nX|sT=vd|Rn%}D``=km3$l$>7@ zva=Ez`KrNA%oNp#(=Jm*1|qQ#YpD4F?BzHwL-8r!D52$ZoyI;x{tj?Utodk6l^Z56 zCjGmb;&xZG+gAfLlq4W-Nms)YmPQiYydL5WtYWfUAHv*p%x;Cl;9r-{3BO!Z9E99% z2g7+VIabf;qqL}w9VjO>7T+OFb>`Hi`5PH2uE2K4f#qlVpu#e0I%#MweQNFj+J)HI@brA z^w#qa?=P%nnk|*Abe7EdLI2Cp)wf6i_w4=78Xbj|w)0_eMfh0WZ0o>eIwWp2SC%g- zRK5_ujI#@WK+Od{T^faV?eB0sAsA$JK)TOz_zP02g)p%ym_OuKENQ!a27OiZiuh2z}!%%HQh%vmw0P3-X};%;n3;L)A>|Q6$_h4TMyx~+e8Q3^0aCg=8tljV;E=uZg@c6y z1#YM0mj~sdWO}7)#8VAfNpo)e8?t+y^0wX2D4qC`A|{w;$40`S!w?^iY|##lq_5Jj zB&EVE*_z$rp|ag2nuiE1II2b58e)$zOGYG6RE5Q}RJtXIkZ{`k|DUKsvYhz`v}f4MwNQy0OuPGq2BUlx+| zo+=XB?5d@1IAJg>?(jhCOx$GI?VPWY#2YN2P3D&+O2(}ckq&&WFv(*j*MW}L_wbS+ z(U9eCG#qK-E#@LKlnMLR*BeivPqnh;>(-ZZjRs~d?A3%sp&+w&%p&Y z*N%#XZhezvBITeMKPWeT%XmItjO^8IX~9L`XwAuDA>clmH^#)RZ#oIvv1rlw0D_?7 zgm8$5CF%oNVZfaf@Z%TO)D6{j1+cMR7>^*L&g9r|f#4j1Czo+^4Rjtp3$7nTM<)Hv1UeNo3g zrdF0EoAc_er5v5r#Mh_%O$cMDo+O;ux+l+^a7By6@uIm&-OD#%g?1v^<6I64?x%w! z#`3l&aJ(i$C;2?a_br|@kHt_OBU83dp8&ubZTyF75*pLHY}H;EUSn&bPJI-9cvf!X zW~6Z)r*fubXyK&KAJRCcdU8phbV3R>c_fFF;7ZHwahv)|Y!day&6mS-HXvAP^Rznc z)sdUf=J{z5&l~Rmt}Cm=pBR+J4J|ty7yx)Hc+W@>SJQQm>qC}LbQeD(3yr>brwQ_` zXlD9i2;ZvFYfbtUsktTY?{lrO`Tdk3VTeJ6vu!3pnY0;HV5-e_$0AFS9&}rj5*=u) zoLtf1$xUhfnnhI3#Ls2-Hoif;JJOP=gq{6XPRt9gKUZ5+E<=Y&zSzWuCF0Xu!Bzws z==&?ZHHd*~3=tfcl($QDRdOCVemmjlC0Yj%B9WRUwc3pmLv-GF={%d8|0wzze&DJ4 z$ub$1v}A&=CmB_&M9n`y?2m{o4w=OsC_S9o?UY1ohl+t za5mrWjYzQx|5LZG+i}j7j^9KT-cf%QKzwdG-k;}K!7Vii$0Rlyfdv0vIWcYvFBVMg z=V>)`0&lPxeaY)`N;3|F;Z=x3dp?Ebfmw)p%{4{gAeJ}ak2W5`zZvPM3vSIBB9&+J zb0Ou=jj@A34&1qmAv%a)L9^l_{sNL&61koYO)hVK)2B&P4f^0SZ{?jZBlo z;ew1#`O+02r3F4pe?E=(IZCCUC&B}0LTJzvT^N5!nXrG|8j@s%+c-EnH3i0ttqoc$ zGA;My+AaIy1pTI2vbVby+(S8x+w{2M$zn%7o$W4}n6*RxHSPH>`m(#Dt}Y0`#|Qlu zxLS;BH-_w7urb1dI<(dvSieO8-VM<7fI7*;xTRJYdNQ64_dJA4M+BKCBZ-Go*+dzk z&aJ_N32D=pd4RF2nc651U35M^_QISO^MPP)TLW$7%lN#slyACHh4{lR!gZgbC`NC8 zx9)lBq4QDF^J#FwkEf54y6L+lsy2*2jJ==q8p%=-RWMHSuQgJ>W z*`R~6vWS7+g1x89(w&+ORMp7nQAm?SeWp8-zxVtjyBd(>Z?J6*wX^Hb*s_aM^u{4M z!z>UsSSa|(g~fVu%YH}sb!U-ytt~lb?=-{nd14+GgIw`jTvzrH&^VyQhi;4d+@_A< z@1pDTb*BBx74L8%x5iLwhVt89shIH+Sva8S`p*oke$R2Zr5`v6^B>-kWp%WZgi09O zr{ev(xWma2KoD+t+@%&4Ufa2Napa8~HyW#>hWixEbl`q9%ulJVEFnu}oCsiDWM^x@ zl#2J4a+jwgMzVd3GHx?BHz(5%a^={>#cHX0^~l8Hgc4(PJBRzRJbRA5!NVP%A;3aQ zbgzz&#*3AtCN|JyY6=nZiul0vaVu9qo)9dGu0IWFQS;%k-Rfd-jK-GA|6$m@Jqhe- zfj7fn&9hf+TO1-eChXv`GD!>hL37k>JMStJjBXYtxlIvY+ffX4B75H)^s9%4FEMHk zXF8fJNB-jcz{$5u@ALpkq)tpq#SQxNqk;LzjOr)ouCThSFeoK0Ukfe+sV)Zg>ba%O z{$9nQwZ!%&D16u!>H#;vy#43s+8}_rc`?x2exV$7sA|*b=64C(72e_pPb{U}PQJjD zZyHmFuek$F!1=l1=S2ori~=4~Ho+i%@uMHPYc*PmHYOQpOWD+*RMsx{wCv?IGl)tq4H9fOa z+12C}ti+1HM%L$@2XGFytK4~!NxIkpyzU@WS!Qub-0m`6U!`;yLSGKurJHy%hPJkd z(U%@Rt)RLu71So9dfkMINj!ujOu&n51*HycK6M;{8gX4Rv$9;wMvX4GBudKZwj{emYBx>fxg2j%b?Z-D5-&0&tc3>d0gqb;Vn-m4?XzXEJ939bOg z9Wjdo2yU^`v)NV3_O>g8olk;KbQ0bR%AEGW2e%Wc8I#_%&pu{)$J8@kCQ8+@T@V#(V$4c@ zC_`{Ah<>!UBK!W?w-#i4pxa(g(~>V??&j%ip9F-$Xd}w!~)CLKQ@`nsi&t#-2yBhZTjH+3^XU_aq}bV z#~)f0rpmN1P`S|^(cbgpI=(%J5=qlITCyfjtduo4M({{2)%H#mr=t=bkI%rq>KG+o zA~0eIcYz#1!SHroqvBFotnmT!feE@P>rXeo2(bR;4LK#03Z>%J$e{5J#?PQU_6=mi zBZ18I;C=K*Kk}6G$>(oIWu04(Ixwp-r|D*TC~jg(9W)l zaJ~iJp_}x#ZCtYSGG}yfhrytD&(O2M=JGNvSaJ_<;FEXPI;ztrDSC{NeSGv~tfmpA zNtY62YVa4`Vi+PgBxvB#JjRw3RU!!%91IMB3wyOOMhj~kzY_oRJ;514`#8!u7;zn3 zJ~q7p?{^2B>Mg^zDWk=fy6xgnC2 zo+*7u6t>^`!#xp<4Xn~P(Xj}lNMPHlYnUE(kvT$<_jt>%y#NAjLM?PB77ndYfh~Ue zdXPI<#X7_d;&39)5(kd4$3>bL@UDdESdjFu$E?J&Vol$$;YQ$yKQpK4dy8Y3SRa4x zGF@DDUDt2^=vS06@MmS>l~bSnqUDcR+XQ?9NRZr`65d57JK5ETNMu;pvas=+0s{V_ zD*+43t}M;21JjZDpcqv#09q2>d@C=jfCtE55xsR~Q|x`yvdvad|I6De2aX1(D^rNV z(}&CJ24@59n*qhp#~<#n!)X+*iAU#<3aB3p7bSxRvfjQ-t2+#|a4x zc)PioMRHY+A~;pAd!*3*N~1(DxQMg-aZDX39vHeN2N%#Q{4^z(87a3QDkQ{Yfs=7; zwdR5RXXV)?DsyY&)Qn zdx+pMcisxZuuD5vh#SEq0s-??(okz!ccN5)y`fjcxp!!#>+^lurp{>Wo7Q%}@Dm9T zQ}TD!p8J+mx2Ksinl-#)v|u_niHZ$`cCxUf+B_`4RJ)SAD1{0dyNBR%x;^I8oD1vu z2CSI4t7PR@xV5Psf!yBi__M!7mv`O8aCF6_T|MS2F&ae^!}-M2pAnZo9)^JzV%5V^`jVBH%RwL$QT;b#QA1tl^rwk)VSpOPfV zc*bcto&;^bt7_{{1%@A6E|zJ~FC}ykqQgI&kN>MDMH!t!+tZdfTGH>$Tpq-YF&QIfYjHQySmbsMiHtu?;YWgE7lS)S9JcohCgo=-$rg* z9`%P>n7p#iYK#~{nn>>}Ourb;!jr3Ic6YvO?17z`E#NrJ(tzDzKlEqg}^nW(LQ`wx@tvLcXvc3)^fgLf7B&^9?GFu}Jad z)=sAErlYc?!rfayK;VY+X`!lkF-GsfPU5ZDHHfWM$~1GGqs6DG`Bh0!(U$nN+N&De zuUAX!_kRJHKxe-R1(s)l7MW!ju{oxLxEM3vzA0Nma$PWsbzNWV0$YK|0A>L)!U1YN zM94uNq9JBpa%+@44~;VN;IoH)o?bFvN;D^IGfrU}vu!G>Mx< ze4w9pq4NF``O;zSz2F^%z)||aAbtYZK>FZ=_XPu( z3p?im<9a)G07RY(*w|e282Vxd}S3usygX8J($<^bNX&EQVD9_hNQ$DZ% zIlcMt=Z@ibD+ksU8i#>=`5wdTW~Ase3|NtX(HrV^6*jo|&z?Bjq)UdA@stGFc+2Sc zoTw%p%0EtL8}{M#H@U3OnN%cb$g286^&>1FGU%2Ik1wkLc;(MIm~$q^qpeV*yIZJH zFW_CS?*pv^wln(7i&D)T6S!l2BA6g3WGJEp-9s9f0-y=V8$v6)<^J`RcR8x_D)VO$ z#69K}?eDpcV1Sj>sA9!L$CrrXP^b%f<-PlYKZL@LOtr+Mu+tz7LpPsxULNaIf%Xtt zp#n{%n{t>}S`X+Tc&A;1j?OykU|*dE9rnztYazw@W@Z1vk@4cm$*pnq#J`_s_m0o* z9sRL+_{)Z2`7F<6Ts(Z2XBk(HjTg3$){lGnmJNLjp&Z9-$XF0@d|@Bxo|jHEaX7AI zkG`)kR|O&sAGv=h3 zg@;5VX%stif`-g%8OZV+^1%?**)xw|2Tz3b38=2!>PC}}7IvaYlJ|1R7Vr)y>E6EX zjcYMlLg+#Y{HPr*a@+>6=q;EnVmT(@ow{>~&gXw##{2hsPv08$?5wj6_K7Z4G(a3+ zzN6#Cx0>nOP5E4WyII&i{C?G7?f9Yh!4-RD@WMxL^nTkiD2T93{BKOk^j|g(Um4&! zxe8|jROC=#iy)`g%>f4a8`PY7%M$km86t9yEa8^-r8T+3!8)e(n4p2q1hf}B^$Cak z3+OfvQma=+MUCN{2>MzuzKWiCUkC@W7!96)Ze~p3rp$wYY;ZE*MEUDLSlls}_a+YG zxri_1k0JET4XJhpj+0nE*K#@f)UOdX$TNELEc^Hy zi`~Lrw@5zh`{BC+!!lK$57q+jOBOYda#*Zu0A~)Ezi8~vTlO~b-1_vwLgNnhb=Fx2 z`&t@!7CaLj*}A58cK>Mco#y(uc)a;;_23q&%Yzr{x3F_GeP=R#rzxM8-)g>H)xXmp z$=fHX$21!Am3#@vLrORwkR2Q>vULaEMDpg$^9B(nai)a%DqxGq#0=33*$ocncivIO z>RO*hKG8n8+cGWbHK~C!+>F3%_Q@4(6_3JFFLOPkqI?TsPY=tETJ*OC#{n$!1%kjy zPTs#LFO)YC&M){gcnUj#TwiZjV8UV*(3vo(IoE3dJ1 zP7m`xbGVl5*9bUIDWBLi`OU#x{rH)5&J-*uy-YI<0H0Y~5dSW78ex3!2q2+^5Bg+$ z;O0TbUOpcy8NIYK3{e+6;efU9!-Kz{LoVz+RxgjugE+}VIaoij*#oj!E*5Z#^l8pG1jbI`_@};FJ&~%1Y815SEyIptS7HAA>C1CnVzHRSbn_6Ge*ILd74w&bI&9-|B#yW{q&1t(5);>9Fb7zkbS?1k!!K0B|UIuFe z7`2<3UC4g{SVPm1cChas_TB?5s%wk;{@PRC-rx6@x83{2SP?`92AF}^8#Yt~i3$iP zVgV^iQH-d71q-665CIDY5K$BmMT}xgv!$6ta*Za&5^@nlxZcRShcj%3!6#ozN^d%Y2~v%1z`mriV)+qI~
      vw(!1K4{8{$>ZPoe>n7Z(Y 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 literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/logo.jpg b/public/app/plugins/datasource/grafana-azure-monitor-datasource/img/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d1a529d4d6e06e188998145522dab722d9979d87 GIT binary patch literal 9593 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 literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/insights_analytics/insights_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/insights_analytics/insights_analytics_datasource.ts new file mode 100644 index 0000000..f62bae6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/insights_analytics/insights_analytics_datasource.ts @@ -0,0 +1,30 @@ +import { ScopedVars, DataSourceInstanceSettings } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType } from '../types'; +import AppInsightsDatasource from '../app_insights/app_insights_datasource'; + +export default class InsightsAnalyticsDatasource extends AppInsightsDatasource { + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + } + + applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): Record { + const item = target.insightsAnalytics; + + // Old name migrations + const old: any = item; + if (old.rawQueryString && !item.query) { + item.query = old.rawQueryString; + } + + return { + refId: target.refId, + queryType: AzureQueryType.InsightsAnalytics, + insightsAnalytics: { + query: getTemplateSrv().replace(item.query, scopedVars), + resultFormat: item.resultFormat, + }, + }; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.test.ts new file mode 100644 index 0000000..7e5ee1b --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.test.ts @@ -0,0 +1,166 @@ +import LogAnalyticsQuerystringBuilder from './querystring_builder'; +import { dateTime } from '@grafana/data'; + +describe('LogAnalyticsDatasource', () => { + let builder: LogAnalyticsQuerystringBuilder; + + beforeEach(() => { + builder = new LogAnalyticsQuerystringBuilder( + 'query=Tablename | where $__timeFilter()', + { + interval: '5m', + range: { + from: dateTime().subtract(24, 'hours'), + to: dateTime(), + }, + rangeRaw: { + from: 'now-24h', + to: 'now', + }, + }, + 'TimeGenerated' + ); + }); + + describe('when $__timeFilter has no column parameter', () => { + it('should generate a time filter condition with TimeGenerated as the datetime field', () => { + const query = builder.generate().uriString; + + expect(query).toContain('where%20TimeGenerated%20%3E%3D%20datetime('); + }); + }); + + describe('when $__timeFilter has a column parameter', () => { + beforeEach(() => { + builder.rawQueryString = 'query=Tablename | where $__timeFilter(myTime)'; + }); + + it('should generate a time filter condition with myTime as the datetime field', () => { + const query = builder.generate().uriString; + + expect(query).toContain('where%20myTime%20%3E%3D%20datetime('); + }); + }); + + describe('when $__contains and multi template variable has custom All value', () => { + beforeEach(() => { + builder.rawQueryString = 'query=Tablename | where $__contains(col, all)'; + }); + + it('should generate a where..in clause', () => { + const query = builder.generate().rawQuery; + + expect(query).toContain(`where 1 == 1`); + }); + }); + + describe('when $__contains and multi template variable has one selected value', () => { + beforeEach(() => { + builder.rawQueryString = `query=Tablename | where $__contains(col, 'val1')`; + }); + + it('should generate a where..in clause', () => { + const query = builder.generate().rawQuery; + + expect(query).toContain(`where col in ('val1')`); + }); + }); + + describe('when $__contains and multi template variable has multiple selected values', () => { + beforeEach(() => { + builder.rawQueryString = `query=Tablename | where $__contains(col, 'val1','val2')`; + }); + + it('should generate a where..in clause', () => { + const query = builder.generate().rawQuery; + + expect(query).toContain(`where col in ('val1','val2')`); + }); + }); + + describe('when $__interval is in the query', () => { + beforeEach(() => { + builder.rawQueryString = 'query=Tablename | summarize count() by Category, bin(TimeGenerated, $__interval)'; + }); + + it('should replace $__interval with the inbuilt interval option', () => { + const query = builder.generate().uriString; + + expect(query).toContain('bin(TimeGenerated%2C%205m'); + }); + }); + + describe('when using $__timeFrom and $__timeTo is in the query and range is until now', () => { + beforeEach(() => { + builder.rawQueryString = 'query=Tablename | where myTime >= $__timeFrom() and myTime <= $__timeTo()'; + }); + + it('should replace $__timeFrom and $__timeTo with a datetime and the now() function', () => { + const query = builder.generate().uriString; + + expect(query).toContain('where%20myTime%20%3E%3D%20datetime('); + expect(query).toContain('myTime%20%3C%3D%20datetime('); + }); + }); + + describe('when using $__timeFrom and $__timeTo is in the query and range is a specific interval', () => { + beforeEach(() => { + builder.rawQueryString = 'query=Tablename | where myTime >= $__timeFrom() and myTime <= $__timeTo()'; + builder.options.range.to = dateTime().subtract(1, 'hour'); + builder.options.rangeRaw.to = 'now-1h'; + }); + + it('should replace $__timeFrom and $__timeTo with datetimes', () => { + const query = builder.generate().uriString; + + expect(query).toContain('where%20myTime%20%3E%3D%20datetime('); + expect(query).toContain('myTime%20%3C%3D%20datetime('); + }); + }); + + describe('when using $__escape and multi template variable has one selected value', () => { + beforeEach(() => { + builder.rawQueryString = `$__escapeMulti('\\grafana-vm\Network(eth0)\Total Bytes Received')`; + }); + + it('should replace $__escape(val) with KQL style escaped string', () => { + const query = builder.generate().uriString; + expect(query).toContain(`%40'%5Cgrafana-vmNetwork(eth0)Total%20Bytes%20Received'`); + }); + }); + + describe('when using $__escape and multi template variable has multiple selected values', () => { + beforeEach(() => { + builder.rawQueryString = `CounterPath in ($__escapeMulti('\\grafana-vm\Network(eth0)\Total','\\grafana-vm\Network(eth0)\Total'))`; + }); + + it('should replace $__escape(val) with multiple KQL style escaped string', () => { + const query = builder.generate().uriString; + expect(query).toContain( + `CounterPath%20in%20(%40'%5Cgrafana-vmNetwork(eth0)Total'%2C%20%40'%5Cgrafana-vmNetwork(eth0)Total')` + ); + }); + }); + + describe('when using $__escape and multi template variable has one selected value that contains comma', () => { + beforeEach(() => { + builder.rawQueryString = `$__escapeMulti('\\grafana-vm,\Network(eth0)\Total Bytes Received')`; + }); + + it('should replace $__escape(val) with KQL style escaped string', () => { + const query = builder.generate().uriString; + expect(query).toContain(`%40'%5Cgrafana-vm%2CNetwork(eth0)Total%20Bytes%20Received'`); + }); + }); + + describe(`when using $__escape and multi template variable value is not wrapped in single '`, () => { + beforeEach(() => { + builder.rawQueryString = `$__escapeMulti(\\grafana-vm,\Network(eth0)\Total Bytes Received)`; + }); + + it('should not replace macro', () => { + const query = builder.generate().uriString; + expect(query).toContain(`%24__escapeMulti(%5Cgrafana-vm%2CNetwork(eth0)Total%20Bytes%20Received)`); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.ts new file mode 100644 index 0000000..0d28248 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/log_analytics/querystring_builder.ts @@ -0,0 +1,85 @@ +import { dateTime } from '@grafana/data'; + +export default class LogAnalyticsQuerystringBuilder { + constructor(public rawQueryString: string, public options: any, public defaultTimeField: any) {} + + generate() { + let queryString = this.rawQueryString; + const macroRegexp = /\$__([_a-zA-Z0-9]+)\(([^\)]*)\)/gi; + queryString = queryString.replace(macroRegexp, (match, p1, p2) => { + if (p1 === 'contains') { + return this.getMultiContains(p2); + } + + return match; + }); + + queryString = queryString.replace(/\$__escapeMulti\(('[^]*')\)/gi, (match, p1) => this.escape(p1)); + + if (this.options) { + queryString = queryString.replace(macroRegexp, (match, p1, p2) => { + if (p1 === 'timeFilter') { + return this.getTimeFilter(p2, this.options); + } + if (p1 === 'timeFrom') { + return this.getFrom(this.options); + } + if (p1 === 'timeTo') { + return this.getUntil(this.options); + } + + return match; + }); + queryString = queryString.replace(/\$__interval/gi, this.options.interval); + } + const rawQuery = queryString; + queryString = encodeURIComponent(queryString); + const uriString = `query=${queryString}`; + + return { uriString, rawQuery }; + } + + getFrom(options: any) { + const from = options.range.from; + return `datetime(${dateTime(from).startOf('minute').toISOString()})`; + } + + getUntil(options: any) { + if (options.rangeRaw.to === 'now') { + const now = Date.now(); + return `datetime(${dateTime(now).startOf('minute').toISOString()})`; + } else { + const until = options.range.to; + return `datetime(${dateTime(until).startOf('minute').toISOString()})`; + } + } + + getTimeFilter(timeFieldArg: any, options: any) { + const timeField = timeFieldArg || this.defaultTimeField; + if (options.rangeRaw.to === 'now') { + return `${timeField} >= ${this.getFrom(options)}`; + } else { + return `${timeField} >= ${this.getFrom(options)} and ${timeField} <= ${this.getUntil(options)}`; + } + } + + getMultiContains(inputs: string) { + const firstCommaIndex = inputs.indexOf(','); + const field = inputs.substring(0, firstCommaIndex); + const templateVar = inputs.substring(inputs.indexOf(',') + 1); + + if (templateVar && templateVar.toLowerCase().trim() === 'all') { + return '1 == 1'; + } + + return `${field.trim()} in (${templateVar.trim()})`; + } + + escape(inputs: string) { + return inputs + .substring(1, inputs.length - 1) + .split(`','`) + .map((v) => `@'${v}'`) + .join(', '); + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/module.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/module.tsx new file mode 100644 index 0000000..33ca27c --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/module.tsx @@ -0,0 +1,11 @@ +import { DataSourcePlugin } from '@grafana/data'; +import { AzureMonitorQueryCtrl } from './query_ctrl'; +import Datasource from './datasource'; +import { ConfigEditor } from './components/ConfigEditor'; +import { AzureMonitorAnnotationsQueryCtrl } from './annotations_query_ctrl'; +import { AzureMonitorQuery, AzureDataSourceJsonData } from './types'; + +export const plugin = new DataSourcePlugin(Datasource) + .setConfigEditor(ConfigEditor) + .setQueryCtrl(AzureMonitorQueryCtrl) + .setAnnotationQueryCtrl(AzureMonitorAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/annotations.editor.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/annotations.editor.html new file mode 100644 index 0000000..206e278 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/annotations.editor.html @@ -0,0 +1,91 @@ +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      +
      +
      + +
      + +
      +
      +
      +
      +
      +
      + +
      +
      + +
      +
      + +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      Annotation Query Format
      +An annotation is an event that is overlaid on top of graphs. The query can have up to three columns per row, the datetime column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with the datetime type. +- column with alias: Text or text for the annotation text +- column with alias: Tags or tags for annotation tags. This is should return a comma separated string of tags e.g. 'tag1,tag2' + +Macros: + - $__timeFilter() -> TimeGenerated ≥ datetime(2018-06-05T18:09:58.907Z) and TimeGenerated ≤ datetime(2018-06-05T20:09:58.907Z) + - $__timeFilter(datetimeColumn) -> datetimeColumn ≥ datetime(2018-06-05T18:09:58.907Z) and datetimeColumn ≤ datetime(2018-06-05T20:09:58.907Z) + + Or build your own conditionals using these built-in variables which just return the values: + - $__timeFrom -> datetime(2018-06-05T18:09:58.907Z) + - $__timeTo -> datetime(2018-06-05T20:09:58.907Z) + - $__interval -> 5m +
      +
      +
      diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html new file mode 100644 index 0000000..ec142ab --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html @@ -0,0 +1,521 @@ + +
      +
      + +
      + +
      +
      +
      + + + +
      +
      +
      +
      +
      +
      + +
      +
      +
      + + + +
      +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      +
      + +
      + +
      + +
      +
      + +
      + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      {{ctrl.lastQuery}}
      +
      +
      +
      +    Format as Table:
      +    - return any set of columns
      +
      +    Format as Time series:
      +    - Requires a column of type datetime
      +    - returns the first column with a numeric datatype as the value
      +    - (Optional: returns the first column with type string to represent the series name. If no column is found the column name of the value column is used as series name)
      +
      +    Example Time Series Query:
      +
      +    AzureActivity
      +    | where $__timeFilter()
      +    | summarize count() by Category, bin(TimeGenerated, 60min)
      +    | order by TimeGenerated asc
      +
      +    Macros:
      +    - $__timeFilter() -> TimeGenerated ≥ datetime(2018-06-05T18:09:58.907Z) and TimeGenerated ≤ datetime(2018-06-05T20:09:58.907Z)
      +    - $__timeFilter(datetimeColumn) ->  datetimeColumn  ≥ datetime(2018-06-05T18:09:58.907Z) and datetimeColumn ≤ datetime(2018-06-05T20:09:58.907Z)
      +    - $__escapeMulti($myTemplateVar) -> $myTemplateVar should be a multi-value template variables that contains illegal characters
      +    - $__contains(aColumn, $myTemplateVar) -> aColumn in ($myTemplateVar)
      +      If using the All option, then check the Include All Option checkbox and in the Custom all value field type in: all. If All is chosen -> 1 == 1
      +
      +    Or build your own conditionals using these built-in variables which just return the values:
      +    - $__timeFrom ->  datetime(2018-06-05T18:09:58.907Z)
      +    - $__timeTo -> datetime(2018-06-05T20:09:58.907Z)
      +    - $__interval -> 5m
      +
      +    Examples:
      +    - ¡ where $__timeFilter
      +    - | where TimeGenerated ≥ $__timeFrom and TimeGenerated ≤ $__timeTo
      +    - | summarize count() by Category, bin(TimeGenerated, $__interval)
      +      
      +
      +
      + +
      +
      + +
      + +
      +
      + +
      + +
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      + + + +
      +
      + +
      + +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      + +
      +
      + + +
      +
      +
      + + +
      +
      +
      +
      +
      +
      +
      +
      + +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + +
      +
      {{ctrl.lastQueryError}}
      +
      + +
      +

      + Application Insights and Insights Analytics will be deprecated and merged with Metrics and Logs in an upcomming + release. See + the documentation + for more details. +

      +
      + diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/plugin.json b/public/app/plugins/datasource/grafana-azure-monitor-datasource/plugin.json new file mode 100644 index 0000000..7aa3f28 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/plugin.json @@ -0,0 +1,219 @@ +{ + "type": "datasource", + "name": "Azure Monitor", + "id": "grafana-azure-monitor-datasource", + "category": "cloud", + + "info": { + "description": "Data source for Microsoft Azure Monitor & Application Insights", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "keywords": ["azure", "monitor", "Application Insights", "Log Analytics", "App Insights"], + "logos": { + "small": "img/logo.jpg", + "large": "img/logo.jpg" + }, + "links": [ + { "name": "Learn more", "url": "https://github.com/grafana/azure-monitor-datasource" }, + { "name": "Apache License", "url": "https://github.com/grafana/azure-monitor-datasource/blob/master/LICENSE" } + ], + "screenshots": [ + { "name": "Azure Contoso Loans", "path": "img/contoso_loans_grafana_dashboard.png" }, + { "name": "Azure Monitor Network", "path": "img/azure_monitor_network.png" }, + { "name": "Azure Monitor CPU", "path": "img/azure_monitor_cpu.png" } + ], + "version": "0.3.0", + "updated": "2018-12-06" + }, + + "routes": [ + { + "path": "azuremonitor", + "method": "GET", + "url": "https://management.azure.com", + "tokenAuth": { + "url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://management.azure.com/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "govazuremonitor", + "method": "GET", + "url": "https://management.usgovcloudapi.net", + "tokenAuth": { + "url": "https://login.microsoftonline.us/{{.JsonData.tenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://management.usgovcloudapi.net/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "germanyazuremonitor", + "method": "GET", + "url": "https://management.microsoftazure.de", + "tokenAuth": { + "url": "https://login.microsoftonline.de/{{.JsonData.tenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://management.microsoftazure.de/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "chinaazuremonitor", + "method": "GET", + "url": "https://management.chinacloudapi.cn", + "tokenAuth": { + "url": "https://login.chinacloudapi.cn/{{.JsonData.tenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://management.chinacloudapi.cn/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "appinsights", + "method": "GET", + "url": "https://api.applicationinsights.io", + "headers": [ + { "name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}" }, + { "name": "x-ms-app", "content": "Grafana" } + ] + }, + { + "path": "chinaappinsights", + "method": "GET", + "url": "https://api.applicationinsights.azure.cn", + "headers": [ + { "name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}" }, + { "name": "x-ms-app", "content": "Grafana" } + ] + }, + { + "path": "workspacesloganalytics", + "method": "GET", + "url": "https://management.azure.com", + "tokenAuth": { + "url": "https://login.microsoftonline.com/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://management.azure.com/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "chinaworkspacesloganalytics", + "method": "GET", + "url": "https://management.chinacloudapi.cn", + "tokenAuth": { + "url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://management.chinacloudapi.cn/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "govworkspacesloganalytics", + "method": "GET", + "url": "https://management.usgovcloudapi.net", + "tokenAuth": { + "url": "https://login.microsoftonline.us/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://management.usgovcloudapi.net/" + } + }, + "headers": [{ "name": "x-ms-app", "content": "Grafana" }] + }, + { + "path": "loganalyticsazure", + "method": "GET", + "url": "https://api.loganalytics.io/v1/workspaces", + "tokenAuth": { + "url": "https://login.microsoftonline.com/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://api.loganalytics.io" + } + }, + "headers": [ + { "name": "x-ms-app", "content": "Grafana" }, + { "name": "Cache-Control", "content": "public, max-age=60" } + ] + }, + { + "path": "chinaloganalyticsazure", + "method": "GET", + "url": "https://api.loganalytics.azure.cn/v1/workspaces", + "tokenAuth": { + "url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://api.loganalytics.azure.cn" + } + }, + "headers": [ + { "name": "x-ms-app", "content": "Grafana" }, + { "name": "Cache-Control", "content": "public, max-age=60" } + ] + }, + { + "path": "govloganalyticsazure", + "method": "GET", + "url": "https://api.loganalytics.us/v1/workspaces", + "tokenAuth": { + "url": "https://login.microsoftonline.us/{{.JsonData.logAnalyticsTenantId}}/oauth2/token", + "params": { + "grant_type": "client_credentials", + "client_id": "{{.JsonData.logAnalyticsClientId}}", + "client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}", + "resource": "https://api.loganalytics.us" + } + }, + "headers": [ + { "name": "x-ms-app", "content": "Grafana" }, + { "name": "Cache-Control", "content": "public, max-age=60" } + ] + } + ], + + "dependencies": { + "grafanaVersion": "5.2.x", + "plugins": [] + }, + + "metrics": true, + "annotations": true, + "alerting": true +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts new file mode 100644 index 0000000..2316114 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts @@ -0,0 +1,339 @@ +jest.mock('./css/query_editor.css', () => { + return {}; +}); + +import { AzureMonitorQueryCtrl } from './query_ctrl'; +// @ts-ignore +import Q from 'q'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { auto } from 'angular'; + +describe('AzureMonitorQueryCtrl', () => { + let queryCtrl: any; + + beforeEach(() => { + AzureMonitorQueryCtrl.prototype.panelCtrl = { + events: { on: () => {} }, + panel: { scopedVars: [], targets: [] }, + }; + AzureMonitorQueryCtrl.prototype.target = {} as any; + AzureMonitorQueryCtrl.prototype.datasource = { + appInsightsDatasource: { isConfigured: () => false }, + azureMonitorDatasource: { isConfigured: () => false }, + }; + + queryCtrl = new AzureMonitorQueryCtrl({}, {} as auto.IInjectorService, new TemplateSrv()); + }); + + describe('init query_ctrl variables', () => { + it('should set default query type to Azure Monitor', () => { + expect(queryCtrl.target.queryType).toBe('Azure Monitor'); + }); + + it('should set default App Insights editor to be builder', () => { + expect(!!(queryCtrl.target.appInsights as any).rawQuery).toBe(false); + }); + + it('should set query parts to select', () => { + expect(queryCtrl.target.azureMonitor.resourceGroup).toBe('select'); + expect(queryCtrl.target.azureMonitor.metricDefinition).toBe('select'); + expect(queryCtrl.target.azureMonitor.resourceName).toBe('select'); + expect(queryCtrl.target.azureMonitor.metricNamespace).toBe('select'); + expect(queryCtrl.target.azureMonitor.metricName).toBe('select'); + expect(queryCtrl.target.appInsights.dimension).toMatchObject([]); + }); + }); + + describe('when the query type is Azure Monitor', () => { + describe('and getOptions for the Resource Group dropdown is called', () => { + const response = [ + { text: 'nodeapp', value: 'nodeapp' }, + { text: 'otherapp', value: 'otherapp' }, + ]; + + beforeEach(() => { + queryCtrl.datasource.getResourceGroups = () => { + return Promise.resolve(response); + }; + queryCtrl.datasource.azureMonitorDatasource = { + isConfigured: () => { + return true; + }, + }; + }); + + it('should return a list of Resource Groups', () => { + return queryCtrl.getResourceGroups('').then((result: any) => { + expect(result[0].text).toBe('nodeapp'); + }); + }); + }); + + describe('when getOptions for the Metric Definition dropdown is called', () => { + describe('and resource group has a value', () => { + const response = [ + { text: 'Microsoft.Compute/virtualMachines', value: 'Microsoft.Compute/virtualMachines' }, + { text: 'Microsoft.Network/publicIPAddresses', value: 'Microsoft.Network/publicIPAddresses' }, + ]; + + beforeEach(() => { + queryCtrl.target.subscription = 'sub1'; + queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.datasource.getMetricDefinitions = (subscriptionId: any, query: any) => { + expect(subscriptionId).toBe('sub1'); + expect(query).toBe('test'); + return Promise.resolve(response); + }; + }); + + it('should return a list of Metric Definitions', () => { + return queryCtrl.getMetricDefinitions('').then((result: any) => { + expect(result[0].text).toBe('Microsoft.Compute/virtualMachines'); + expect(result[1].text).toBe('Microsoft.Network/publicIPAddresses'); + }); + }); + }); + + describe('and resource group has no value', () => { + beforeEach(() => { + queryCtrl.target.azureMonitor.resourceGroup = 'select'; + }); + + it('should return without making a call to datasource', () => { + expect(queryCtrl.getMetricDefinitions('')).toBe(undefined); + }); + }); + }); + + describe('when getOptions for the ResourceNames dropdown is called', () => { + describe('and resourceGroup and metricDefinition have values', () => { + const response = [ + { text: 'test1', value: 'test1' }, + { text: 'test2', value: 'test2' }, + ]; + + beforeEach(() => { + queryCtrl.target.subscription = 'sub1'; + queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.datasource.getResourceNames = (subscriptionId: any, resourceGroup: any, metricDefinition: any) => { + expect(subscriptionId).toBe('sub1'); + expect(resourceGroup).toBe('test'); + expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines'); + return Promise.resolve(response); + }; + }); + + it('should return a list of Resource Names', () => { + return queryCtrl.getResourceNames('').then((result: any) => { + expect(result[0].text).toBe('test1'); + expect(result[1].text).toBe('test2'); + }); + }); + }); + + describe('and resourceGroup and metricDefinition do not have values', () => { + beforeEach(() => { + queryCtrl.target.azureMonitor.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.metricDefinition = 'select'; + }); + + it('should return without making a call to datasource', () => { + expect(queryCtrl.getResourceNames('')).toBe(undefined); + }); + }); + }); + + describe('when getOptions for the Metric Names dropdown is called', () => { + describe('and resourceGroup, metricDefinition, resourceName and metricNamespace have values', () => { + const response = [ + { text: 'metric1', value: 'metric1' }, + { text: 'metric2', value: 'metric2' }, + ]; + + beforeEach(() => { + queryCtrl.target.subscription = 'sub1'; + queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.resourceName = 'test'; + queryCtrl.target.azureMonitor.metricNamespace = 'test'; + queryCtrl.datasource.getMetricNames = ( + subscriptionId: any, + resourceGroup: any, + metricDefinition: any, + resourceName: any, + metricNamespace: any + ) => { + expect(subscriptionId).toBe('sub1'); + expect(resourceGroup).toBe('test'); + expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines'); + expect(resourceName).toBe('test'); + expect(metricNamespace).toBe('test'); + return Promise.resolve(response); + }; + }); + + it('should return a list of Metric Names', () => { + return queryCtrl.getMetricNames('').then((result: any) => { + expect(result[0].text).toBe('metric1'); + expect(result[1].text).toBe('metric2'); + }); + }); + }); + + describe('and resourceGroup, metricDefinition, resourceName and metricNamespace do not have values', () => { + beforeEach(() => { + queryCtrl.target.azureMonitor.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.metricDefinition = 'select'; + queryCtrl.target.azureMonitor.resourceName = 'select'; + queryCtrl.target.azureMonitor.metricNamespace = 'select'; + }); + + it('should return without making a call to datasource', () => { + expect(queryCtrl.getMetricNames('')).toBe(undefined); + }); + }); + }); + + describe('when onMetricNameChange is triggered for the Metric Names dropdown', () => { + const response: any = { + primaryAggType: 'Average', + supportedAggTypes: ['Average', 'Total'], + supportedTimeGrains: [ + { text: 'PT1M', value: 'PT1M' }, + { text: 'P1D', value: 'P1D' }, + ], + dimensions: [], + }; + + beforeEach(() => { + queryCtrl.target.subscription = 'sub1'; + queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.resourceName = 'test'; + queryCtrl.target.azureMonitor.metricNamespace = 'test'; + queryCtrl.target.azureMonitor.metricName = 'Percentage CPU'; + queryCtrl.datasource.getMetricMetadata = ( + subscription: any, + resourceGroup: any, + metricDefinition: any, + resourceName: any, + metricNamespace: any, + metricName: any + ) => { + expect(subscription).toBe('sub1'); + expect(resourceGroup).toBe('test'); + expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines'); + expect(resourceName).toBe('test'); + expect(metricNamespace).toBe('test'); + expect(metricName).toBe('Percentage CPU'); + return Promise.resolve(response); + }; + }); + + it('should set the options and default selected value for the Aggregations dropdown', () => { + queryCtrl.onMetricNameChange().then(() => { + expect(queryCtrl.target.azureMonitor.aggregation).toBe('Average'); + expect(queryCtrl.target.azureMonitor.aggOptions).toEqual(['Average', 'Total']); + expect(queryCtrl.target.azureMonitor.timeGrains).toEqual([ + { text: 'auto', value: 'auto' }, + { text: 'PT1M', value: 'PT1M' }, + { text: 'P1D', value: 'P1D' }, + ]); + }); + }); + }); + }); + + describe('and query type is Application Insights', () => { + describe('and target is in old format', () => { + it('data is migrated', () => { + queryCtrl.target.appInsights.xaxis = 'sample-x'; + queryCtrl.target.appInsights.yaxis = 'sample-y'; + queryCtrl.target.appInsights.spliton = 'sample-split'; + queryCtrl.target.appInsights.groupBy = 'sample-group'; + queryCtrl.target.appInsights.groupByOptions = ['sample-group-1', 'sample-group-2']; + queryCtrl.target.appInsights.filter = 'sample-filter'; + queryCtrl.target.appInsights.metricName = 'sample-metric'; + + queryCtrl.migrateApplicationInsightsKeys(); + + expect(queryCtrl.target.appInsights.xaxis).toBeUndefined(); + expect(queryCtrl.target.appInsights.yaxis).toBeUndefined(); + expect(queryCtrl.target.appInsights.spliton).toBeUndefined(); + expect(queryCtrl.target.appInsights.groupBy).toBeUndefined(); + expect(queryCtrl.target.appInsights.groupByOptions).toBeUndefined(); + expect(queryCtrl.target.appInsights.filter).toBeUndefined(); + + expect(queryCtrl.target.appInsights.timeColumn).toBe('sample-x'); + expect(queryCtrl.target.appInsights.valueColumn).toBe('sample-y'); + expect(queryCtrl.target.appInsights.segmentColumn).toBe('sample-split'); + expect(queryCtrl.target.appInsights.dimension).toBe('sample-group'); + expect(queryCtrl.target.appInsights.dimensions).toEqual(['sample-group-1', 'sample-group-2']); + expect(queryCtrl.target.appInsights.dimensionFilter).toBe('sample-filter'); + expect(queryCtrl.target.appInsights.metricName).toBe('sample-metric'); + }); + }); + + describe('when getOptions for the Metric Names dropdown is called', () => { + const response = [ + { text: 'metric1', value: 'metric1' }, + { text: 'metric2', value: 'metric2' }, + ]; + + beforeEach(() => { + queryCtrl.datasource.appInsightsDatasource.isConfigured = () => true; + queryCtrl.datasource.getAppInsightsMetricNames = () => { + return Promise.resolve(response); + }; + }); + + it('should return a list of Metric Names', () => { + return queryCtrl.getAppInsightsMetricNames().then((result: any) => { + expect(result[0].text).toBe('metric1'); + expect(result[1].text).toBe('metric2'); + }); + }); + }); + + describe('when getOptions for the GroupBy segments dropdown is called', () => { + beforeEach(() => { + queryCtrl.target.appInsights.dimensions = ['opt1', 'opt2']; + }); + + it('should return a list of GroupBy segments', () => { + const result = queryCtrl.getAppInsightsGroupBySegments(''); + expect(result[0].text).toBe('opt1'); + expect(result[0].value).toBe('opt1'); + expect(result[1].text).toBe('opt2'); + expect(result[1].value).toBe('opt2'); + }); + }); + + describe('when onAppInsightsMetricNameChange is triggered for the Metric Names dropdown', () => { + const response = { + primaryAggType: 'avg', + supportedAggTypes: ['avg', 'sum'], + supportedGroupBy: ['client/os', 'client/city'], + }; + + beforeEach(() => { + queryCtrl.target.appInsights.metricName = 'requests/failed'; + queryCtrl.datasource.getAppInsightsMetricMetadata = (metricName: string) => { + expect(metricName).toBe('requests/failed'); + return Promise.resolve(response); + }; + }); + + it('should set the options and default selected value for the Aggregations dropdown', () => { + return queryCtrl.onAppInsightsMetricNameChange().then(() => { + expect(queryCtrl.target.appInsights.aggregation).toBe('avg'); + expect(queryCtrl.target.appInsights.aggOptions).toContain('avg'); + expect(queryCtrl.target.appInsights.aggOptions).toContain('sum'); + expect(queryCtrl.target.appInsights.dimensions).toContain('client/os'); + expect(queryCtrl.target.appInsights.dimensions).toContain('client/city'); + }); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts new file mode 100644 index 0000000..5c0fc6a --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts @@ -0,0 +1,703 @@ +import _ from 'lodash'; +import { QueryCtrl } from 'app/plugins/sdk'; +// import './css/query_editor.css'; +import TimegrainConverter from './time_grain_converter'; +import './editor/editor_component'; + +import { TemplateSrv } from '@grafana/runtime'; +import { auto, IPromise } from 'angular'; +import { DataFrame, PanelEvents, rangeUtil } from '@grafana/data'; +import { AzureQueryType, AzureMetricQuery } from './types'; + +export interface ResultFormat { + text: string; + value: string; +} + +export class AzureMonitorQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + defaultDropdownValue = 'select'; + + dummyDiminsionString = '+'; + + queryQueryTypeOptions = [ + { id: AzureQueryType.AzureMonitor, label: 'Metrics' }, + { id: AzureQueryType.LogAnalytics, label: 'Logs' }, + { id: AzureQueryType.ApplicationInsights, label: 'Application Insights' }, + { id: AzureQueryType.InsightsAnalytics, label: 'Insights Analytics' }, + ]; + + target: { + // should be: AzureMonitorQuery + refId: string; + queryType: AzureQueryType; + subscription: string; + azureMonitor: AzureMetricQuery; + azureLogAnalytics: { + query: string; + resultFormat: string; + workspace: string; + }; + appInsights: { + // metric style query when rawQuery == false + metricName: string; + dimension: any; + dimensionFilter: string; + dimensions: string[]; + + aggOptions: string[]; + aggregation: string; + + timeGrainType: string; + timeGrainCount: string; + timeGrainUnit: string; + timeGrain: string; + timeGrains: Array<{ text: string; value: string }>; + allowedTimeGrainsMs: number[]; + }; + insightsAnalytics: { + query: any; + resultFormat: string; + }; + }; + + defaults = { + queryType: 'Azure Monitor', + azureMonitor: { + resourceGroup: this.defaultDropdownValue, + metricDefinition: this.defaultDropdownValue, + resourceName: this.defaultDropdownValue, + metricNamespace: this.defaultDropdownValue, + metricName: this.defaultDropdownValue, + dimensionFilter: '*', + timeGrain: 'auto', + top: '10', + aggOptions: [] as string[], + timeGrains: [] as string[], + }, + azureLogAnalytics: { + query: [ + '//change this example to create your own time series query', + ' ' + + '//the table to query (e.g. Usage, Heartbeat, Perf)', + '| where $__timeFilter(TimeGenerated) ' + + '//this is a macro used to show the full chart’s time range, choose the datetime column here', + '| summarize count() by , bin(TimeGenerated, $__interval) ' + + '//change “group by column” to a column in your table, such as “Computer”. ' + + 'The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.', + '| order by TimeGenerated asc', + ].join('\n'), + resultFormat: 'time_series', + workspace: + this.datasource && this.datasource.azureLogAnalyticsDatasource + ? this.datasource.azureLogAnalyticsDatasource.defaultOrFirstWorkspace + : '', + }, + appInsights: { + metricName: this.defaultDropdownValue, + // dimension: [], + timeGrain: 'auto', + }, + insightsAnalytics: { + query: '', + resultFormat: 'time_series', + }, + }; + + resultFormats: ResultFormat[]; + workspaces: any[]; + showHelp: boolean; + showLastQuery: boolean; + lastQuery: string; + lastQueryError?: string; + subscriptions: Array<{ text: string; value: string }>; + + /** @ngInject */ + constructor($scope: any, $injector: auto.IInjectorService, private templateSrv: TemplateSrv) { + super($scope, $injector); + + _.defaultsDeep(this.target, this.defaults); + + this.migrateTimeGrains(); + + this.migrateToFromTimes(); + + this.migrateToDefaultNamespace(); + + this.migrateApplicationInsightsKeys(); + + this.migrateApplicationInsightsDimensions(); + + migrateMetricsDimensionFilters(this.target.azureMonitor); + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + this.resultFormats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + this.getSubscriptions(); + if (this.target.queryType === 'Azure Log Analytics') { + this.getWorkspaces(); + } + } + + onDataReceived(dataList: DataFrame[]) { + this.lastQueryError = undefined; + this.lastQuery = ''; + + const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId }); + if (anySeriesFromQuery && anySeriesFromQuery.meta) { + this.lastQuery = anySeriesFromQuery.meta.query; + } + } + + onDataError(err: any) { + this.handleQueryCtrlError(err); + } + + handleQueryCtrlError(err: any) { + if (err.query && err.query.refId && err.query.refId !== this.target.refId) { + return; + } + + if (err.error && err.error.data && err.error.data.error && err.error.data.error.innererror) { + if (err.error.data.error.innererror.innererror) { + this.lastQueryError = err.error.data.error.innererror.innererror.message; + } else { + this.lastQueryError = err.error.data.error.innererror.message; + } + } else if (err.error && err.error.data && err.error.data.error) { + this.lastQueryError = err.error.data.error.message; + } else if (err.error && err.error.data) { + this.lastQueryError = err.error.data.message; + } else if (err.data && err.data.error) { + this.lastQueryError = err.data.error.message; + } else if (err.data && err.data.message) { + this.lastQueryError = err.data.message; + } else { + this.lastQueryError = err; + } + } + + migrateTimeGrains() { + if (this.target.azureMonitor.timeGrainUnit) { + if (this.target.azureMonitor.timeGrain !== 'auto') { + this.target.azureMonitor.timeGrain = TimegrainConverter.createISO8601Duration( + this.target.azureMonitor.timeGrain, + this.target.azureMonitor.timeGrainUnit + ); + } + + delete this.target.azureMonitor.timeGrainUnit; + this.onMetricNameChange(); + } + + if (this.target.appInsights.timeGrainUnit) { + if (this.target.appInsights.timeGrain !== 'auto') { + if (this.target.appInsights.timeGrainCount) { + this.target.appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + this.target.appInsights.timeGrainCount, + this.target.appInsights.timeGrainUnit + ); + } else { + this.target.appInsights.timeGrainCount = this.target.appInsights.timeGrain; + this.target.appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + this.target.appInsights.timeGrain, + this.target.appInsights.timeGrainUnit + ); + } + } + } + + const oldAzureTimeGrains = (this.target.azureMonitor as any).timeGrains; + if ( + oldAzureTimeGrains && + oldAzureTimeGrains.length > 0 && + (!this.target.azureMonitor.allowedTimeGrainsMs || this.target.azureMonitor.allowedTimeGrainsMs.length === 0) + ) { + this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(oldAzureTimeGrains); + } + + if ( + this.target.appInsights.timeGrains && + this.target.appInsights.timeGrains.length > 0 && + (!this.target.appInsights.allowedTimeGrainsMs || this.target.appInsights.allowedTimeGrainsMs.length === 0) + ) { + this.target.appInsights.allowedTimeGrainsMs = this.convertTimeGrainsToMs(this.target.appInsights.timeGrains); + } + } + + migrateToFromTimes() { + this.target.azureLogAnalytics.query = this.target.azureLogAnalytics.query.replace(/\$__from\s/gi, '$__timeFrom() '); + this.target.azureLogAnalytics.query = this.target.azureLogAnalytics.query.replace(/\$__to\s/gi, '$__timeTo() '); + } + + async migrateToDefaultNamespace() { + if ( + this.target.azureMonitor.metricNamespace && + this.target.azureMonitor.metricNamespace !== this.defaultDropdownValue && + this.target.azureMonitor.metricDefinition + ) { + return; + } + + this.target.azureMonitor.metricNamespace = this.target.azureMonitor.metricDefinition; + } + + migrateApplicationInsightsKeys(): void { + const appInsights = this.target.appInsights as any; + + // Migrate old app insights data keys to match other datasources + const mappings = { + xaxis: 'timeColumn', + yaxis: 'valueColumn', + spliton: 'segmentColumn', + groupBy: 'dimension', + groupByOptions: 'dimensions', + filter: 'dimensionFilter', + } as { [old: string]: string }; + + for (const old in mappings) { + if (appInsights[old]) { + appInsights[mappings[old]] = appInsights[old]; + delete appInsights[old]; + } + } + } + + migrateApplicationInsightsDimensions() { + const { appInsights } = this.target; + + if (!appInsights.dimension) { + appInsights.dimension = []; + } + + if (_.isString(appInsights.dimension)) { + appInsights.dimension = [appInsights.dimension as string]; + } + } + + replace(variable: string) { + return this.templateSrv.replace(variable, this.panelCtrl.panel.scopedVars); + } + + onQueryTypeChange() { + if (this.target.queryType === 'Azure Log Analytics') { + return this.getWorkspaces(); + } + } + + getSubscriptions() { + if (!this.datasource.azureMonitorDatasource.isConfigured()) { + return; + } + + return this.datasource.azureMonitorDatasource.getSubscriptions().then((subs: any) => { + this.subscriptions = subs; + if (!this.target.subscription && this.target.queryType === 'Azure Monitor') { + this.target.subscription = this.datasource.azureMonitorDatasource.subscriptionId; + } else if (!this.target.subscription && this.target.queryType === 'Azure Log Analytics') { + this.target.subscription = this.datasource.azureLogAnalyticsDatasource.logAnalyticsSubscriptionId; + } + + if (!this.target.subscription && this.subscriptions.length > 0) { + this.target.subscription = this.subscriptions[0].value; + } + + return this.subscriptions; + }); + } + + onSubscriptionChange() { + if (this.target.queryType === 'Azure Log Analytics') { + return this.getWorkspaces(); + } + + if (this.target.queryType === 'Azure Monitor') { + this.target.azureMonitor.resourceGroup = this.defaultDropdownValue; + this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.resourceName = this.defaultDropdownValue; + this.target.azureMonitor.metricName = this.defaultDropdownValue; + this.target.azureMonitor.aggregation = ''; + this.target.azureMonitor.timeGrain = ''; + this.target.azureMonitor.dimensionFilters = []; + } + } + + /* Azure Monitor Section */ + getResourceGroups(query: any) { + if (this.target.queryType !== 'Azure Monitor' || !this.datasource.azureMonitorDatasource.isConfigured()) { + return; + } + + return this.datasource + .getResourceGroups( + this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId) + ) + .catch(this.handleQueryCtrlError.bind(this)); + } + + getMetricDefinitions(query: any) { + if ( + this.target.queryType !== 'Azure Monitor' || + !this.target.azureMonitor.resourceGroup || + this.target.azureMonitor.resourceGroup === this.defaultDropdownValue + ) { + return; + } + return this.datasource + .getMetricDefinitions( + this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), + this.replace(this.target.azureMonitor.resourceGroup) + ) + .catch(this.handleQueryCtrlError.bind(this)); + } + + getResourceNames(query: any) { + if ( + this.target.queryType !== 'Azure Monitor' || + !this.target.azureMonitor.resourceGroup || + this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.metricDefinition || + this.target.azureMonitor.metricDefinition === this.defaultDropdownValue + ) { + return; + } + + return this.datasource + .getResourceNames( + this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), + this.replace(this.target.azureMonitor.resourceGroup), + this.replace(this.target.azureMonitor.metricDefinition) + ) + .catch(this.handleQueryCtrlError.bind(this)); + } + + getMetricNamespaces() { + if ( + this.target.queryType !== 'Azure Monitor' || + !this.target.azureMonitor.resourceGroup || + this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.metricDefinition || + this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.resourceName || + this.target.azureMonitor.resourceName === this.defaultDropdownValue + ) { + return; + } + + return this.datasource + .getMetricNamespaces( + this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), + this.replace(this.target.azureMonitor.resourceGroup), + this.replace(this.target.azureMonitor.metricDefinition), + this.replace(this.target.azureMonitor.resourceName) + ) + .catch(this.handleQueryCtrlError.bind(this)); + } + + getMetricNames() { + if ( + this.target.queryType !== 'Azure Monitor' || + !this.target.azureMonitor.resourceGroup || + this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.metricDefinition || + this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.resourceName || + this.target.azureMonitor.resourceName === this.defaultDropdownValue || + !this.target.azureMonitor.metricNamespace || + this.target.azureMonitor.metricNamespace === this.defaultDropdownValue + ) { + return; + } + + return this.datasource + .getMetricNames( + this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), + this.replace(this.target.azureMonitor.resourceGroup), + this.replace(this.target.azureMonitor.metricDefinition), + this.replace(this.target.azureMonitor.resourceName), + this.replace(this.target.azureMonitor.metricNamespace) + ) + .catch(this.handleQueryCtrlError.bind(this)); + } + + onResourceGroupChange() { + this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.resourceName = this.defaultDropdownValue; + this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.metricName = this.defaultDropdownValue; + this.target.azureMonitor.aggregation = ''; + this.target.azureMonitor.timeGrain = ''; + this.target.azureMonitor.dimensionFilters = []; + this.refresh(); + } + + onMetricDefinitionChange() { + this.target.azureMonitor.resourceName = this.defaultDropdownValue; + this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.metricName = this.defaultDropdownValue; + this.target.azureMonitor.aggregation = ''; + this.target.azureMonitor.timeGrain = ''; + this.target.azureMonitor.dimensionFilters = []; + } + + onResourceNameChange() { + this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.metricName = this.defaultDropdownValue; + this.target.azureMonitor.aggregation = ''; + this.target.azureMonitor.timeGrain = ''; + this.target.azureMonitor.dimensionFilters = []; + this.refresh(); + } + + onMetricNamespacesChange() { + this.target.azureMonitor.metricName = this.defaultDropdownValue; + this.target.azureMonitor.dimensionFilters = []; + } + + onMetricNameChange(): IPromise { + if (!this.target.azureMonitor.metricName || this.target.azureMonitor.metricName === this.defaultDropdownValue) { + return Promise.resolve(); + } + + return this.datasource + .getMetricMetadata( + this.replace(this.target.subscription), + this.replace(this.target.azureMonitor.resourceGroup), + this.replace(this.target.azureMonitor.metricDefinition), + this.replace(this.target.azureMonitor.resourceName), + this.replace(this.target.azureMonitor.metricNamespace), + this.replace(this.target.azureMonitor.metricName) + ) + .then((metadata: any) => { + this.target.azureMonitor.aggregation = metadata.primaryAggType; + this.target.azureMonitor.timeGrain = 'auto'; + this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(metadata.supportedTimeGrains || []); + + // HACK: this saves the last metadata values in the panel json ¯\_(ツ)_/¯ + const hackState = this.target.azureMonitor as any; + hackState.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType]; + hackState.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains); + hackState.dimensions = metadata.dimensions; + + if (metadata.dimensions.length > 0) { + // this.target.azureMonitor.dimension = metadata.dimensions[0].value; + } + + return this.refresh(); + }) + .catch(this.handleQueryCtrlError.bind(this)); + } + + convertTimeGrainsToMs(timeGrains: Array<{ text: string; value: string }>) { + const allowedTimeGrainsMs: number[] = []; + timeGrains.forEach((tg: any) => { + if (tg.value !== 'auto') { + allowedTimeGrainsMs.push(rangeUtil.intervalToMs(TimegrainConverter.createKbnUnitFromISO8601Duration(tg.value))); + } + }); + return allowedTimeGrainsMs; + } + + generateAutoUnits(timeGrain: string, timeGrains: Array<{ value: string }>) { + if (timeGrain === 'auto') { + return TimegrainConverter.findClosestTimeGrain( + '1m', + _.map(timeGrains, (o) => TimegrainConverter.createKbnUnitFromISO8601Duration(o.value)) || [ + '1m', + '5m', + '15m', + '30m', + '1h', + '6h', + '12h', + '1d', + ] + ); + } + + return ''; + } + + getAzureMonitorAutoInterval() { + return this.generateAutoUnits(this.target.azureMonitor.timeGrain, (this.target.azureMonitor as any).timeGrains); + } + + getApplicationInsightAutoInterval() { + return this.generateAutoUnits(this.target.appInsights.timeGrain, this.target.appInsights.timeGrains); + } + + azureMonitorAddDimensionFilter() { + this.target.azureMonitor.dimensionFilters.push({ + dimension: '', + operator: 'eq', + filter: '', + }); + } + + azureMonitorRemoveDimensionFilter(index: number) { + this.target.azureMonitor.dimensionFilters.splice(index, 1); + this.refresh(); + } + + /* Azure Log Analytics */ + + getWorkspaces = () => { + return this.datasource.azureLogAnalyticsDatasource + .getWorkspaces(this.target.subscription) + .then((list: any[]) => { + this.workspaces = list; + + if (list.length > 0 && !this.target.azureLogAnalytics.workspace) { + if (this.datasource.azureLogAnalyticsDatasource.defaultOrFirstWorkspace) { + this.target.azureLogAnalytics.workspace = this.datasource.azureLogAnalyticsDatasource.defaultOrFirstWorkspace; + } + + if (!this.target.azureLogAnalytics.workspace) { + this.target.azureLogAnalytics.workspace = list[0].value; + } + } + + return this.workspaces; + }) + .catch(this.handleQueryCtrlError.bind(this)); + }; + + getAzureLogAnalyticsSchema = () => { + return this.getWorkspaces() + .then(() => { + return this.datasource.azureLogAnalyticsDatasource.getSchema(this.target.azureLogAnalytics.workspace); + }) + .catch(this.handleQueryCtrlError.bind(this)); + }; + + onLogAnalyticsQueryChange = (nextQuery: string) => { + this.target.azureLogAnalytics.query = nextQuery; + }; + + onLogAnalyticsQueryExecute = () => { + this.panelCtrl.refresh(); + }; + + get templateVariables() { + return this.templateSrv.getVariables().map((t) => '$' + t.name); + } + + getAppInsightsMetricNames() { + if (!this.datasource.appInsightsDatasource.isConfigured()) { + return; + } + + return this.datasource.getAppInsightsMetricNames().catch(this.handleQueryCtrlError.bind(this)); + } + + getAppInsightsColumns() { + return this.datasource.getAppInsightsColumns(this.target.refId); + } + + onAppInsightsColumnChange() { + return this.refresh(); + } + + onAppInsightsMetricNameChange() { + if (!this.target.appInsights.metricName || this.target.appInsights.metricName === this.defaultDropdownValue) { + return; + } + + return this.datasource + .getAppInsightsMetricMetadata(this.replace(this.target.appInsights.metricName)) + .then((aggData: { supportedAggTypes: string[]; supportedGroupBy: string[]; primaryAggType: string }) => { + this.target.appInsights.aggOptions = aggData.supportedAggTypes; + this.target.appInsights.dimensions = aggData.supportedGroupBy; + this.target.appInsights.aggregation = aggData.primaryAggType; + return this.refresh(); + }) + .catch(this.handleQueryCtrlError.bind(this)); + } + + onInsightsAnalyticsQueryChange = (nextQuery: string) => { + this.target.insightsAnalytics.query = nextQuery; + }; + + onQueryExecute = () => { + return this.refresh(); + }; + + getAppInsightsQuerySchema = () => { + return this.datasource.appInsightsDatasource.getQuerySchema().catch(this.handleQueryCtrlError.bind(this)); + }; + + removeGroupBy = (index: number) => { + const { appInsights } = this.target; + appInsights.dimension.splice(index, 1); + this.refresh(); + }; + + getAppInsightsGroupBySegments(query: any) { + const { appInsights } = this.target; + + // HACK alert... there must be a better way! + if (this.dummyDiminsionString && this.dummyDiminsionString.length && '+' !== this.dummyDiminsionString) { + if (!appInsights.dimension) { + appInsights.dimension = []; + } + appInsights.dimension.push(this.dummyDiminsionString); + this.dummyDiminsionString = '+'; + this.refresh(); + } + + // Return the list of dimensions stored on the query object from the last request :( + return _.map(appInsights.dimensions, (option: string) => { + return { text: option, value: option }; + }); + } + + resetAppInsightsGroupBy() { + this.target.appInsights.dimension = 'none'; + this.refresh(); + } + + updateTimeGrainType() { + if (this.target.appInsights.timeGrainType === 'specific') { + this.target.appInsights.timeGrainCount = '1'; + this.target.appInsights.timeGrainUnit = 'minute'; + this.target.appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + this.target.appInsights.timeGrainCount, + this.target.appInsights.timeGrainUnit + ); + } else { + this.target.appInsights.timeGrainCount = ''; + this.target.appInsights.timeGrainUnit = ''; + } + } + + updateAppInsightsTimeGrain() { + if (this.target.appInsights.timeGrainUnit && this.target.appInsights.timeGrainCount) { + this.target.appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + this.target.appInsights.timeGrainCount, + this.target.appInsights.timeGrainUnit + ); + } + this.refresh(); + } +} + +// Modifies the actual query object +export function migrateMetricsDimensionFilters(item: AzureMetricQuery) { + if (!item.dimensionFilters) { + item.dimensionFilters = []; + } + const oldDimension = (item as any).dimension; + if (oldDimension && oldDimension !== 'None') { + item.dimensionFilters.push({ + dimension: oldDimension, + operator: 'eq', + filter: (item as any).dimensionFilter, + }); + delete (item as any).dimension; + delete (item as any).dimensionFilter; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_help.md b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_help.md new file mode 100644 index 0000000..428430f --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_help.md @@ -0,0 +1,34 @@ +Format the legend keys any way you want by using alias patterns. + +- Example for Azure Monitor: `dimension: {{dimensionvalue}}` +- Example for Application Insights: `server: {{groupbyvalue}}` + +#### Alias Patterns for Application Insights + +- {{groupbyvalue}} = replaced with the value of the group by +- {{groupbyname}} = replaced with the name/label of the group by +- {{metric}} = replaced with metric name (e.g. requests/count) + +#### Alias Patterns for Azure Monitor + +- {{resourcegroup}} = replaced with the value of the Resource Group +- {{namespace}} = replaced with the value of the Namespace (e.g. Microsoft.Compute/virtualMachines) +- {{resourcename}} = replaced with the value of the Resource Name +- {{metric}} = replaced with metric name (e.g. Percentage CPU) +- {{dimensionname}} = replaced with dimension key/label (e.g. blobtype) +- {{dimensionvalue}} = replaced with dimension value (e.g. BlockBlob) + +#### Filter Expressions for Application Insights + +The filter field takes an OData filter expression. + +Examples: + +- `client/city eq 'Boydton'` +- `client/city ne 'Boydton'` +- `client/city ne 'Boydton' and client/city ne 'Dublin'` +- `client/city eq 'Boydton' or client/city eq 'Dublin'` + +#### Writing Queries for Template Variables + +See the [docs](https://github.com/grafana/azure-monitor-datasource#templating-with-variables) for details and examples. diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.test.ts new file mode 100644 index 0000000..f5e1eef --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.test.ts @@ -0,0 +1,23 @@ +import TimeGrainConverter from './time_grain_converter'; + +describe('TimeGrainConverter', () => { + describe('with duration of PT1H', () => { + it('should convert it to text', () => { + expect(TimeGrainConverter.createTimeGrainFromISO8601Duration('PT1H')).toEqual('1 hour'); + }); + + it('should convert it to kbn', () => { + expect(TimeGrainConverter.createKbnUnitFromISO8601Duration('PT1H')).toEqual('1h'); + }); + }); + + describe('with duration of P1D', () => { + it('should convert it to text', () => { + expect(TimeGrainConverter.createTimeGrainFromISO8601Duration('P1D')).toEqual('1 day'); + }); + + it('should convert it to kbn', () => { + expect(TimeGrainConverter.createKbnUnitFromISO8601Duration('P1D')).toEqual('1d'); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.ts new file mode 100644 index 0000000..a83325a --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/time_grain_converter.ts @@ -0,0 +1,122 @@ +import _ from 'lodash'; +import { rangeUtil } from '@grafana/data'; + +export default class TimeGrainConverter { + static createISO8601Duration(timeGrain: string | number, timeGrainUnit: any) { + const timeIntervals = ['hour', 'minute', 'h', 'm']; + if (_.includes(timeIntervals, timeGrainUnit)) { + return `PT${timeGrain}${timeGrainUnit[0].toUpperCase()}`; + } + + return `P${timeGrain}${timeGrainUnit[0].toUpperCase()}`; + } + + static createISO8601DurationFromInterval(interval: string) { + const timeGrain = +interval.slice(0, interval.length - 1); + const unit = interval[interval.length - 1]; + + if (interval.indexOf('ms') > -1) { + return TimeGrainConverter.createISO8601Duration(1, 'm'); + } + + if (interval[interval.length - 1] === 's') { + let toMinutes = (timeGrain * 60) % 60; + + if (toMinutes < 1) { + toMinutes = 1; + } + + return TimeGrainConverter.createISO8601Duration(toMinutes, 'm'); + } + + return TimeGrainConverter.createISO8601Duration(timeGrain, unit); + } + + static findClosestTimeGrain(interval: any, allowedTimeGrains: string[]) { + const timeGrains = _.filter(allowedTimeGrains, (o) => o !== 'auto'); + + let closest = timeGrains[0]; + const intervalMs = rangeUtil.intervalToMs(interval); + + for (let i = 0; i < timeGrains.length; i++) { + // abs (num - val) < abs (num - curr): + if (intervalMs > rangeUtil.intervalToMs(timeGrains[i])) { + if (i + 1 < timeGrains.length) { + closest = timeGrains[i + 1]; + } else { + closest = timeGrains[i]; + } + } + } + + return closest; + } + + static createTimeGrainFromISO8601Duration(duration: string) { + let offset = 1; + if (duration.substring(0, 2) === 'PT') { + offset = 2; + } + + const value = duration.substring(offset, duration.length - 1); + const unit = duration.substring(duration.length - 1); + + return value + ' ' + TimeGrainConverter.timeUnitToText(+value, unit); + } + + static timeUnitToText(value: number, unit: string) { + let text = ''; + + if (unit === 'S') { + text = 'second'; + } + if (unit === 'M') { + text = 'minute'; + } + if (unit === 'H') { + text = 'hour'; + } + if (unit === 'D') { + text = 'day'; + } + + if (value > 1) { + return text + 's'; + } + + return text; + } + + static createKbnUnitFromISO8601Duration(duration: string) { + if (duration === 'auto') { + return 'auto'; + } + + let offset = 1; + if (duration.substring(0, 2) === 'PT') { + offset = 2; + } + + const value = duration.substring(offset, duration.length - 1); + const unit = duration.substring(duration.length - 1); + + return value + TimeGrainConverter.timeUnitToKbn(+value, unit); + } + + static timeUnitToKbn(value: number, unit: string) { + if (unit === 'S') { + return 's'; + } + if (unit === 'M') { + return 'm'; + } + if (unit === 'H') { + return 'h'; + } + if (unit === 'D') { + return 'd'; + } + + return ''; + } +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts new file mode 100644 index 0000000..86510d6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts @@ -0,0 +1,155 @@ +import { DataQuery, DataSourceJsonData, DataSourceSettings, TableData } from '@grafana/data'; + +export type AzureDataSourceSettings = DataSourceSettings; + +export enum AzureQueryType { + AzureMonitor = 'Azure Monitor', + ApplicationInsights = 'Application Insights', + InsightsAnalytics = 'Insights Analytics', + LogAnalytics = 'Azure Log Analytics', +} + +export interface AzureMonitorQuery extends DataQuery { + queryType: AzureQueryType; + format: string; + subscription: string; + + azureMonitor: AzureMetricQuery; + azureLogAnalytics: AzureLogsQuery; + appInsights?: ApplicationInsightsQuery; + insightsAnalytics: InsightsAnalyticsQuery; +} + +export interface AzureDataSourceJsonData extends DataSourceJsonData { + subscriptionId: string; + cloudName: string; + + // monitor + tenantId?: string; + clientId?: string; + + // logs + logAnalyticsSubscriptionId?: string; + logAnalyticsTenantId?: string; + logAnalyticsClientId?: string; + azureLogAnalyticsSameAs?: boolean; + logAnalyticsDefaultWorkspace?: string; + + // App Insights + appInsightsAppId?: string; +} + +export interface AzureDataSourceSecureJsonData { + clientSecret?: string; + logAnalyticsClientSecret?: string; + appInsightsApiKey?: string; +} + +export interface AzureMetricDimension { + dimension: string; + operator: 'eq'; // future proof + filter?: string; // * +} + +export interface AzureMetricQuery { + resourceGroup: string; + resourceName: string; + metricDefinition: string; + metricNamespace: string; + metricName: string; + timeGrainUnit?: string; + timeGrain: string; + allowedTimeGrainsMs: number[]; + aggregation: string; + dimensionFilters: AzureMetricDimension[]; + alias: string; + top: string; +} + +export interface AzureLogsQuery { + query: string; + resultFormat: string; + workspace: string; +} + +export interface ApplicationInsightsQuery { + metricName: string; + timeGrainUnit: string; + timeGrain: string; + allowedTimeGrainsMs: number[]; + aggregation: string; + dimension: string[]; // Was string before 7.1 + // dimensions: string[]; why is this metadata stored on the object! + dimensionFilter: string; + alias: string; +} + +export interface InsightsAnalyticsQuery { + query: string; + resultFormat: string; +} + +// Azure Monitor API Types + +export interface AzureMonitorMetricDefinitionsResponse { + data: { + value: Array<{ name: string; type: string; location?: string }>; + }; + status: number; + statusText: string; +} + +export interface AzureMonitorResourceGroupsResponse { + data: { + value: Array<{ name: string }>; + }; + status: number; + statusText: string; +} + +// Azure Log Analytics types +export interface KustoSchema { + Databases: { [key: string]: KustoDatabase }; + Plugins: any[]; +} +export interface KustoDatabase { + Name: string; + Tables: { [key: string]: KustoTable }; + Functions: { [key: string]: KustoFunction }; +} + +export interface KustoTable { + Name: string; + OrderedColumns: KustoColumn[]; +} + +export interface KustoColumn { + Name: string; + Type: string; +} + +export interface KustoFunction { + Name: string; + DocString: string; + Body: string; + Folder: string; + FunctionKind: string; + InputParameters: any[]; + OutputColumns: any[]; +} + +export interface AzureLogsVariable { + text: string; + value: string; +} + +export interface AzureLogsTableData extends TableData { + columns: AzureLogsTableColumn[]; + rows: any[]; + type: string; +} + +export interface AzureLogsTableColumn { + text: string; + type: string; +} diff --git a/public/app/plugins/datasource/grafana/README.md b/public/app/plugins/datasource/grafana/README.md new file mode 100644 index 0000000..a8319c8 --- /dev/null +++ b/public/app/plugins/datasource/grafana/README.md @@ -0,0 +1,3 @@ +# Grafana Fake Data Datasource - Native Plugin + +This is the built in Fake Data Datasource that is used before any datasources are set up in your Grafana installation. It means you can create a graph without any data and still get an idea of what it would look like. diff --git a/public/app/plugins/datasource/grafana/annotation_ctrl.ts b/public/app/plugins/datasource/grafana/annotation_ctrl.ts new file mode 100644 index 0000000..8876ddf --- /dev/null +++ b/public/app/plugins/datasource/grafana/annotation_ctrl.ts @@ -0,0 +1,20 @@ +import { SelectableValue } from '@grafana/data'; +import { GrafanaAnnotationType } from './types'; + +export const annotationTypes: Array> = [ + { text: 'Dashboard', value: GrafanaAnnotationType.Dashboard }, + { text: 'Tags', value: GrafanaAnnotationType.Tags }, +]; + +export class GrafanaAnnotationsQueryCtrl { + annotation: any; + + types = annotationTypes; + + constructor() { + this.annotation.type = this.annotation.type || GrafanaAnnotationType.Tags; + this.annotation.limit = this.annotation.limit || 100; + } + + static templateUrl = 'partials/annotations.editor.html'; +} diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx new file mode 100644 index 0000000..acb3240 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -0,0 +1,167 @@ +import defaults from 'lodash/defaults'; + +import React, { PureComponent } from 'react'; +import { InlineField, Select, FeatureInfoBox } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue, LiveChannelScope, FeatureState } from '@grafana/data'; +import { getLiveMeasurements, LiveMeasurements } from '@grafana/runtime'; +import { GrafanaDatasource } from '../datasource'; +import { defaultQuery, GrafanaQuery, GrafanaQueryType } from '../types'; + +type Props = QueryEditorProps; + +const labelWidth = 12; + +export class QueryEditor extends PureComponent { + queryTypes: Array> = [ + { + label: 'Random Walk', + value: GrafanaQueryType.RandomWalk, + description: 'Random signal within the selected time range', + }, + { + label: 'Live Measurements', + value: GrafanaQueryType.LiveMeasurements, + description: 'Stream real-time measurements from Grafana', + }, + ]; + + onQueryTypeChange = (sel: SelectableValue) => { + const { onChange, query, onRunQuery } = this.props; + onChange({ ...query, queryType: sel.value! }); + onRunQuery(); + }; + + onChannelChange = (sel: SelectableValue) => { + const { onChange, query, onRunQuery } = this.props; + onChange({ ...query, channel: sel?.value }); + onRunQuery(); + }; + + onMeasurementNameChanged = (sel: SelectableValue) => { + const { onChange, query, onRunQuery } = this.props; + onChange({ + ...query, + measurements: { + ...query.measurements, + name: sel?.value, + }, + }); + onRunQuery(); + }; + + renderMeasurementsQuery() { + let { channel, measurements } = this.props.query; + const channels: Array> = []; + let currentChannel = channels.find((c) => c.value === channel); + if (channel && !currentChannel) { + currentChannel = { + value: channel, + label: channel, + description: `Connected to ${channel}`, + }; + channels.push(currentChannel); + } + + if (!measurements) { + measurements = {}; + } + const names: Array> = [ + { value: '', label: 'All measurements', description: 'Show every measurement streamed to this channel' }, + ]; + + let info: LiveMeasurements | undefined = undefined; + if (channel) { + info = getLiveMeasurements({ + scope: LiveChannelScope.Grafana, + namespace: 'measurements', + path: channel, + }); + + let foundName = false; + if (info) { + for (const name of info.getDistinctNames()) { + names.push({ + value: name, + label: name, + }); + if (name === measurements.name) { + foundName = true; + } + } + } else { + console.log('NO INFO for', channel); + } + + if (measurements.name && !foundName) { + names.push({ + label: measurements.name, + value: measurements.name, + description: `Frames with name ${measurements.name}`, + }); + } + } + + return ( + <> +
      + + v.value === measurements?.name) || names[0]} + onChange={this.onMeasurementNameChanged} + allowCustomValue={true} + backspaceRemovesValue={true} + placeholder="Filter by name" + isClearable={true} + noOptionsMessage="Filter by name" + formatCreateLabel={(input: string) => `Show: ${input}`} + isSearchable={true} + /> + +
      + )} + + +

      + This supports real-time event streams in Grafana core. This feature is under heavy development. Expect the + interfaces and structures to change as this becomes more production ready. +

      +
      + + ); + } + + render() { + const query = defaults(this.props.query, defaultQuery); + return ( + <> +
      + + + +
      + +
      + Max limit +
      + +
      +
      + +
      +
      + +
      +
      + + Tags + + A tag entered here as 'foo' will match +
        +
      • annotation tags 'foo'
      • +
      • annotation key-value tags formatted as 'foo:bar'
      • +
      +
      +
      + + +
      +
      + + + diff --git a/public/app/plugins/datasource/grafana/plugin.json b/public/app/plugins/datasource/grafana/plugin.json new file mode 100644 index 0000000..d7d957e --- /dev/null +++ b/public/app/plugins/datasource/grafana/plugin.json @@ -0,0 +1,9 @@ +{ + "type": "datasource", + "name": "-- Grafana --", + "id": "grafana", + + "builtIn": true, + "annotations": true, + "metrics": true +} diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts new file mode 100644 index 0000000..e9fe0ab --- /dev/null +++ b/public/app/plugins/datasource/grafana/types.ts @@ -0,0 +1,38 @@ +import { AnnotationQuery, DataQuery } from '@grafana/data'; +import { MeasurementsQuery } from '@grafana/runtime'; + +//---------------------------------------------- +// Query +//---------------------------------------------- + +export enum GrafanaQueryType { + RandomWalk = 'randomWalk', + LiveMeasurements = 'measurements', +} + +export interface GrafanaQuery extends DataQuery { + queryType: GrafanaQueryType; // RandomWalk by default + channel?: string; + measurements?: MeasurementsQuery; +} + +export const defaultQuery: GrafanaQuery = { + refId: 'A', + queryType: GrafanaQueryType.RandomWalk, +}; + +//---------------------------------------------- +// Annotations +//---------------------------------------------- + +export enum GrafanaAnnotationType { + Dashboard = 'dashboard', + Tags = 'tags', +} + +export interface GrafanaAnnotationQuery extends AnnotationQuery { + type: GrafanaAnnotationType; // tags + limit: number; // 100 + tags?: string[]; + matchAny?: boolean; // By default Grafana only shows annotations that match all tags in the query. Enabling this returns annotations that match any of the tags in the query. +} diff --git a/public/app/plugins/datasource/graphite/FunctionEditor.tsx b/public/app/plugins/datasource/graphite/FunctionEditor.tsx new file mode 100644 index 0000000..d178904 --- /dev/null +++ b/public/app/plugins/datasource/graphite/FunctionEditor.tsx @@ -0,0 +1,120 @@ +import React, { Suspense } from 'react'; +import { PopoverController, Popover, ClickOutsideWrapper } from '@grafana/ui'; +import { FunctionDescriptor, FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls'; + +interface FunctionEditorProps extends FunctionEditorControlsProps { + func: FunctionDescriptor; +} + +interface FunctionEditorState { + showingDescription: boolean; +} +const FunctionDescription = React.lazy(async () => { + // @ts-ignore + const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html'); + return { + default(props: { description?: string }) { + return
      ; + }, + }; +}); + +class FunctionEditor extends React.PureComponent { + private triggerRef = React.createRef(); + + constructor(props: FunctionEditorProps) { + super(props); + + this.state = { + showingDescription: false, + }; + } + + renderContent = ({ updatePopperPosition }: any) => { + const { + onMoveLeft, + onMoveRight, + func: { + def: { name, description }, + }, + } = this.props; + const { showingDescription } = this.state; + + if (showingDescription) { + return ( +
      +

      {name}

      + Loading description...}> + + +
      + ); + } + + return ( + { + onMoveLeft(this.props.func); + updatePopperPosition(); + }} + onMoveRight={() => { + onMoveRight(this.props.func); + updatePopperPosition(); + }} + onDescriptionShow={() => { + this.setState({ showingDescription: true }, () => { + updatePopperPosition(); + }); + }} + /> + ); + }; + + render() { + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {this.triggerRef.current && ( + { + this.setState({ showingDescription: false }); + }} + renderArrow={({ arrowProps, placement }) => ( +
      + )} + /> + )} + { + if (popperProps.show) { + hidePopper(); + } + }} + > + { + this.setState({ showingDescription: false }); + }} + style={{ cursor: 'pointer' }} + > + {this.props.func.def.name} + + + + ); + }} + + ); + } +} + +export { FunctionEditor }; diff --git a/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx b/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx new file mode 100644 index 0000000..1a8211b --- /dev/null +++ b/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Icon } from '@grafana/ui'; + +export interface FunctionDescriptor { + text: string; + params: string[]; + def: { + category: string; + defaultParams: string[]; + description?: string; + fake: boolean; + name: string; + params: string[]; + }; +} + +export interface FunctionEditorControlsProps { + onMoveLeft: (func: FunctionDescriptor) => void; + onMoveRight: (func: FunctionDescriptor) => void; + onRemove: (func: FunctionDescriptor) => void; +} + +const FunctionHelpButton = (props: { description?: string; name: string; onDescriptionShow: () => void }) => { + if (props.description) { + return ; + } + + return ( + { + window.open( + 'http://graphite.readthedocs.org/en/latest/functions.html#graphite.render.functions.' + props.name, + '_blank' + ); + }} + /> + ); +}; + +export const FunctionEditorControls = ( + props: FunctionEditorControlsProps & { + func: FunctionDescriptor; + onDescriptionShow: () => void; + } +) => { + const { func, onMoveLeft, onMoveRight, onRemove, onDescriptionShow } = props; + return ( +
      + onMoveLeft(func)} /> + + onRemove(func)} /> + onMoveRight(func)} /> +
      + ); +}; diff --git a/public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx b/public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx new file mode 100644 index 0000000..403c125 --- /dev/null +++ b/public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx @@ -0,0 +1,181 @@ +import { css, cx } from 'emotion'; +import React, { PureComponent } from 'react'; +import { MetadataInspectorProps, rangeUtil } from '@grafana/data'; +import { GraphiteDatasource } from './datasource'; +import { GraphiteQuery, GraphiteOptions, MetricTankSeriesMeta } from './types'; +import { parseSchemaRetentions, getRollupNotice, getRuntimeConsolidationNotice } from './meta'; +import { stylesFactory } from '@grafana/ui'; +import { config } from 'app/core/config'; + +export type Props = MetadataInspectorProps; + +export interface State { + index: number; +} + +export class MetricTankMetaInspector extends PureComponent { + renderMeta(meta: MetricTankSeriesMeta, key: string) { + const styles = getStyles(); + const buckets = parseSchemaRetentions(meta['schema-retentions']); + const rollupNotice = getRollupNotice([meta]); + const runtimeNotice = getRuntimeConsolidationNotice([meta]); + const normFunc = (meta['consolidator-normfetch'] ?? '').replace('Consolidator', ''); + + const totalSeconds = buckets.reduce( + (acc, bucket) => acc + (bucket.retention ? rangeUtil.intervalToSeconds(bucket.retention) : 0), + 0 + ); + + return ( +
      +
      + Schema: {meta['schema-name']} +
      Series count: {meta.count}
      +
      +
      +
      +
      Step 1: Fetch
      +
      + First data is fetched, either from raw data archive or a rollup archive +
      + + {rollupNotice &&

      {rollupNotice.text}

      } + {!rollupNotice &&

      No rollup archive was used

      } + +
      + {buckets.map((bucket, index) => { + const bucketLength = bucket.retention ? rangeUtil.intervalToSeconds(bucket.retention) : 0; + const lengthPercent = (bucketLength / totalSeconds) * 100; + const isActive = index === meta['archive-read']; + + return ( +
      +
      {bucket.interval}
      +
      +
      {bucket.retention}
      +
      + ); + })} +
      +
      + +
      +
      Step 2: Normalization
      +
      + Normalization happens when series with different intervals between points are combined. +
      + + {meta['aggnum-norm'] > 1 &&

      Normalization did occur using {normFunc}

      } + {meta['aggnum-norm'] === 1 &&

      No normalization was needed

      } +
      + +
      +
      Step 3: Runtime consolidation
      +
      + If there are too many data points at this point Metrictank will consolidate them down to below max data + points (set in queries tab). +
      + + {runtimeNotice &&

      {runtimeNotice.text}

      } + {!runtimeNotice &&

      No runtime consolidation

      } +
      +
      +
      + ); + } + + render() { + const { data } = this.props; + + // away to dedupe them + const seriesMetas: Record = {}; + + for (const series of data) { + if (series.meta && series.meta.custom) { + for (const metaItem of series.meta.custom.seriesMetaList as MetricTankSeriesMeta[]) { + // key is to dedupe as many series will have identitical meta + const key = `${JSON.stringify(metaItem)}`; + + if (seriesMetas[key]) { + seriesMetas[key].count += metaItem.count; + } else { + seriesMetas[key] = metaItem; + } + } + } + } + + if (Object.keys(seriesMetas).length === 0) { + return
      No response meta data
      ; + } + + return ( +
      +

      Metrictank Lineage

      + {Object.keys(seriesMetas).map((key) => this.renderMeta(seriesMetas[key], key))} +
      + ); + } +} + +const getStyles = stylesFactory(() => { + const { theme } = config; + const borderColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray85; + const background = theme.isDark ? theme.palette.dark1 : theme.palette.white; + const headerBg = theme.isDark ? theme.palette.gray15 : theme.palette.gray85; + + return { + metaItem: css` + background: ${background}; + border: 1px solid ${borderColor}; + margin-bottom: ${theme.spacing.md}; + `, + metaItemHeader: css` + background: ${headerBg}; + padding: ${theme.spacing.xs} ${theme.spacing.md}; + font-size: ${theme.typography.size.md}; + display: flex; + justify-content: space-between; + `, + metaItemBody: css` + padding: ${theme.spacing.md}; + `, + stepHeading: css` + font-size: ${theme.typography.size.md}; + `, + stepDescription: css` + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textWeak}; + margin-bottom: ${theme.spacing.sm}; + `, + step: css` + margin-bottom: ${theme.spacing.lg}; + + &:last-child { + margin-bottom: 0; + } + `, + bucket: css` + display: flex; + margin-bottom: ${theme.spacing.sm}; + border-radius: ${theme.border.radius.md}; + `, + bucketInterval: css` + flex-grow: 0; + width: 60px; + `, + bucketRetention: css` + background: linear-gradient(0deg, ${theme.palette.blue85}, ${theme.palette.blue95}); + text-align: center; + color: ${theme.palette.white}; + margin-right: ${theme.spacing.md}; + border-radius: ${theme.border.radius.md}; + `, + bucketRetentionActive: css` + background: linear-gradient(0deg, ${theme.palette.greenBase}, ${theme.palette.greenShade}); + `, + }; +}); diff --git a/public/app/plugins/datasource/graphite/README.md b/public/app/plugins/datasource/graphite/README.md new file mode 100644 index 0000000..d0890e4 --- /dev/null +++ b/public/app/plugins/datasource/graphite/README.md @@ -0,0 +1,13 @@ +# Graphite Data Source - Native Plugin + +Grafana ships with **built in** support for Graphite (of course!). + +Grafana has an advanced Graphite query editor that lets you quickly navigate the metric space, add functions, change function parameters and much more. The editor can handle all types of graphite queries. It can even handle complex nested queries through the use of query references. + +Read more about it here: + +[http://docs.grafana.org/datasources/graphite/](http://docs.grafana.org/datasources/graphite/) + +Graphite 1.1 Release: + +[https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/](https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/) diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts new file mode 100644 index 0000000..5216194 --- /dev/null +++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts @@ -0,0 +1,163 @@ +import _ from 'lodash'; +import $ from 'jquery'; +// @ts-ignore +import Drop from 'tether-drop'; +import coreModule from 'app/core/core_module'; +import { FuncDef } from './gfunc'; + +/** @ngInject */ +export function graphiteAddFunc($compile: any) { + const inputTemplate = + ''; + + const buttonTemplate = + '' + + ''; + + return { + link: function ($scope: any, elem: JQuery) { + const ctrl = $scope.ctrl; + + const $input = $(inputTemplate); + const $button = $(buttonTemplate); + + $input.appendTo(elem); + $button.appendTo(elem); + + ctrl.datasource.getFuncDefs().then((funcDefs: FuncDef[]) => { + const allFunctions = _.map(funcDefs, 'name').sort(); + + $scope.functionMenu = createFunctionDropDownMenu(funcDefs); + + $input.attr('data-provide', 'typeahead'); + $input.typeahead({ + source: allFunctions, + minLength: 1, + items: 10, + updater: (value: any) => { + let funcDef: any = ctrl.datasource.getFuncDef(value); + if (!funcDef) { + // try find close match + value = value.toLowerCase(); + funcDef = _.find(allFunctions, (funcName) => { + return funcName.toLowerCase().indexOf(value) === 0; + }); + + if (!funcDef) { + return ''; + } + } + + $scope.$apply(() => { + ctrl.addFunction(funcDef); + }); + + $input.trigger('blur'); + return ''; + }, + }); + + $button.click(() => { + $button.hide(); + $input.show(); + $input.focus(); + }); + + $input.keyup(() => { + elem.toggleClass('open', $input.val() === ''); + }); + + $input.blur(() => { + // clicking the function dropdown menu won't + // work if you remove class at once + setTimeout(() => { + $input.val(''); + $input.hide(); + $button.show(); + elem.removeClass('open'); + }, 200); + }); + + $compile(elem.contents())($scope); + }); + + let drop: any; + const cleanUpDrop = () => { + if (drop) { + drop.destroy(); + drop = null; + } + }; + + $(elem) + .on('mouseenter', 'ul.dropdown-menu li', async () => { + cleanUpDrop(); + + let funcDef; + try { + funcDef = ctrl.datasource.getFuncDef($('a', this).text()); + } catch (e) { + // ignore + } + + if (funcDef && funcDef.description) { + let shortDesc = funcDef.description; + if (shortDesc.length > 500) { + shortDesc = shortDesc.substring(0, 497) + '...'; + } + + const contentElement = document.createElement('div'); + // @ts-ignore + const { default: rst2html } = await import(/* webpackChunkName: "rst2html" */ 'rst2html'); + contentElement.innerHTML = '

      ' + funcDef.name + '

      ' + rst2html(shortDesc); + + drop = new Drop({ + target: this, + content: contentElement, + classes: 'drop-popover', + openOn: 'always', + tetherOptions: { + attachment: 'bottom left', + targetAttachment: 'bottom right', + }, + }); + } + }) + .on('mouseout', 'ul.dropdown-menu li', () => { + cleanUpDrop(); + }); + + $scope.$on('$destroy', cleanUpDrop); + }, + }; +} + +coreModule.directive('graphiteAddFunc', graphiteAddFunc); + +function createFunctionDropDownMenu(funcDefs: FuncDef[]) { + const categories: any = {}; + + _.forEach(funcDefs, (funcDef) => { + if (!funcDef.category) { + return; + } + if (!categories[funcDef.category]) { + categories[funcDef.category] = []; + } + categories[funcDef.category].push({ + text: funcDef.name, + click: "ctrl.addFunction('" + funcDef.name + "')", + }); + }); + + return _.sortBy( + _.map(categories, (submenu, category) => { + return { + text: category, + submenu: _.sortBy(submenu, 'text'), + }; + }), + 'text' + ); +} diff --git a/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx new file mode 100644 index 0000000..0e2bcee --- /dev/null +++ b/public/app/plugins/datasource/graphite/configuration/ConfigEditor.tsx @@ -0,0 +1,99 @@ +import React, { PureComponent } from 'react'; +import { DataSourceHttpSettings, InlineFormLabel, LegacyForms } from '@grafana/ui'; +const { Select, Switch } = LegacyForms; +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOptionSelect, + onUpdateDatasourceJsonDataOptionChecked, +} from '@grafana/data'; +import { GraphiteOptions, GraphiteType } from '../types'; + +const graphiteVersions = [ + { label: '0.9.x', value: '0.9' }, + { label: '1.0.x', value: '1.0' }, + { label: '1.1.x', value: '1.1' }, +]; + +const graphiteTypes = Object.entries(GraphiteType).map(([label, value]) => ({ + label, + value, +})); + +export type Props = DataSourcePluginOptionsEditorProps; + +export class ConfigEditor extends PureComponent { + constructor(props: Props) { + super(props); + } + + renderTypeHelp = () => { + return ( +

      + There are different types of Graphite compatible backends. Here you can specify the type you are using. If you + are using{' '} + + Metrictank + {' '} + then select that here. This will enable Metrictank specific features like query processing meta data. Metrictank + is a multi-tenant timeseries engine for Graphite and friends. +

      + ); + }; + + render() { + const { options, onOptionsChange } = this.props; + + const currentVersion = + graphiteVersions.find((item) => item.value === options.jsonData.graphiteVersion) ?? graphiteVersions[2]; + + return ( + <> + +

      Graphite details

      +
      +
      +
      + + Version + + type.value === options.jsonData.graphiteType)} + width={8} + onChange={onUpdateDatasourceJsonDataOptionSelect(this.props, 'graphiteType')} + /> +
      +
      + {options.jsonData.graphiteType === GraphiteType.Metrictank && ( +
      +
      + +
      +
      + )} +
      + + ); + } +} diff --git a/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json new file mode 100644 index 0000000..d74f648 --- /dev/null +++ b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json @@ -0,0 +1,173 @@ +{ + "__inputs": [ + { + "name": "DS_NAME", + "type": "datasource", + "pluginId": "graphite" + } + ], + + "revision": "1.0", + "title": "Graphite Carbon Metrics", + "tags": ["graphite", "carbon"], + "style": "dark", + "timezone": "browser", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "panels": [ + { + "aliasColors": {}, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_NAME}", + "editable": true, + "fill": 0, + "fillGradient": 0, + "grid": { + "max": null, + "min": 0 + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "loadingEditor": false, + "nullPointMode": "null as zero", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [ + { + "alias": "Points Per Update", + "yaxis": 2 + }, + { + "alias": "CPU", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(sumSeries(carbon.agents.*.updateOperations),\"Updates\") " + }, + { + "refId": "B", + "target": "alias(sumSeries(carbon.agents.*.metricsReceived),'Metrics Received')" + }, + { + "refId": "C", + "target": "alias(sumSeries(carbon.agents.*.committedPoints),'Committed Points')" + }, + { + "refId": "D", + "target": "alias(sumSeries(carbon.agents.*.pointsPerUpdate),'Points Per Update')" + }, + { + "refId": "E", + "target": "alias(averageSeries(carbon.agents.*.cpuUsage),'CPU')" + }, + { + "refId": "F", + "target": "alias(sumSeries(carbon.agents.*.creates),'Creates')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Graphite Carbon Metrics", + "tooltip": { + "query_as_alias": true, + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + }, + "zerofill": true + } + ], + "time": { + "from": "now-3h", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "notice": false, + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "status": "Stable", + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], + "type": "timepicker" + }, + "templating": { + "enable": false, + "list": [] + }, + "annotations": { + "enable": false, + "list": [] + }, + "refresh": false, + "schemaVersion": 22, + "version": 3, + "links": [] +} diff --git a/public/app/plugins/datasource/graphite/dashboards/metrictank.json b/public/app/plugins/datasource/graphite/dashboards/metrictank.json new file mode 100644 index 0000000..7cc07a8 --- /dev/null +++ b/public/app/plugins/datasource/graphite/dashboards/metrictank.json @@ -0,0 +1,4781 @@ +{ + "__inputs": [ + { + "name": "DS_NAME", + "type": "datasource", + "pluginId": "graphite" + } + ], + + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "limit": 100, + "name": "Annotations & Alerts", + "showIn": 0, + "tags": ["metrictank"], + "type": "tags" + } + ] + }, + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 37, + "panels": [], + "repeat": null, + "title": "key stats", + "type": "row" + }, + { + "aliasColors": { + "ES backpressure": "#052B51", + "add-to-closed": "#C15C17", + "primary": "#2F575E", + "store backpressure": "#962D82", + "too old": "#890F02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 9, + "grid": {}, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 9, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 0, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "too old", + "lines": false, + "points": true + }, + { + "alias": "primary", + "fill": 3, + "lines": true, + "linewidth": 0, + "points": false, + "yaxis": 2 + }, + { + "alias": "/metricdata.received/", + "color": "#2a4422", + "lines": true, + "points": false, + "stack": "A" + }, + { + "alias": "/metricpoint.received/", + "color": "#3f6833", + "lines": true, + "points": false, + "stack": "A" + }, + { + "alias": "/metricpoint_no_org.received/", + "color": "#7eb26d", + "lines": true, + "points": false, + "stack": "A" + }, + { + "alias": "/invalid/", + "color": "#C15C17" + }, + { + "alias": "/decode err/", + "color": "#890F02" + }, + { + "alias": "/unknown/", + "color": "#890F02" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "refId": "A", + "target": "aliasSub(sumSeries(perSecond(metrictank.stats.$environment.$instance.input.*.metrics_decode_err.counter32)), '.*\\.([^\\.]+)\\.metrics_decode_err.*', '\\1 decode err')" + }, + { + "refId": "B", + "target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.input.*.*.received.counter32), 'sum', 5, 6, 7)" + }, + { + "refId": "C", + "target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.input.*.*.discarded.invalid*.counter32), 'sum', 5, 6, 7, 8)" + }, + { + "refId": "D", + "target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.input.*.*.discarded.unknown.counter32), 'sum', 5, 6, 7, 8)" + }, + { + "refId": "E", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.tank.discarded.sample-out-of-order.counter32)), 'too old')" + }, + { + "refId": "F", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.tank.discarded.received-too-late.counter32)), 'add-to-saved')" + }, + { + "refId": "G", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.tank.metrics_reordered.counter32)), 'reordered')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "metrics in", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percentunit", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "95th": "#EAB839", + "999th": "#890F02", + "99th": "#99440A", + "GCd metrics/s": "#DEDAF7", + "mean": "#0A437C", + "median": "#3F6833", + "total_points": "#B7DBAB" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "total_points", + "fill": 0, + "linewidth": 1, + "yaxis": 2 + }, + { + "alias": "GCd metrics/s", + "lines": false, + "pointradius": 1, + "points": true + }, + { + "alias": "heap objects", + "linewidth": 1, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.tank.total_points.gauge64), 'total_points')" + }, + { + "hide": false, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.tank.metrics_active.gauge32), 'metrics_active')" + }, + { + "refId": "C", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.tank.gc_metric.counter32)), 'GCd metrics/s')", + "textEditor": false + }, + { + "refId": "D", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.memory.gc.heap_objects.gauge64), 'heap objects')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "ringbuffer: Metrics & points", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "primary": "#64B0C8", + "promotion wait": "#E5AC0E", + "ready": "#629E51", + "too old": "#890F02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/primary/", + "fill": 3, + "lines": true, + "linewidth": 0, + "points": false, + "stack": "A", + "yaxis": 2 + }, + { + "alias": "/ready/", + "fill": 3, + "lines": true, + "linewidth": 0, + "points": false, + "stack": "A", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(metrictank.stats.$environment.$instance.cluster.self.state.primary.gauge1, 3, 7)" + }, + { + "refId": "B", + "target": "aliasByNode(metrictank.stats.$environment.$instance.cluster.self.promotion_wait.gauge32, 3, 6)" + }, + { + "refId": "C", + "target": "aliasByNode(metrictank.stats.$environment.$instance.cluster.self.state.ready.gauge1, 3, 7)" + }, + { + "refId": "D", + "target": "aliasByNode(metrictank.stats.$environment.$instance.cluster.self.priority.gauge32, 3, 6)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "cluster status", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "logBase": 10, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": 2, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 29, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(metrictank.stats.$environment.$instance.*.metrics_active.gauge32, 3,4)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "metrics active", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 7 + }, + "id": 38, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 36, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "sortByName(aliasByNode(metrictank.stats.$environment.$instance.cluster.notifier.kafka.partition.*.lag.gauge64, 3, 8), true)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Partition lag - metricPersist", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 10, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 31, + "legend": { + "avg": false, + "current": false, + "hideEmpty": true, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 4, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "sortByName(groupByNode(metrictank.stats.$environment.$instance.input.kafka-mdm.partition.*.lag.gauge64, 3, 'max'), true)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Partition lag - data", + "tooltip": { + "shared": true, + "sort": 1, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 10, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "repeat": null, + "title": "kafka input plugin", + "type": "row" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 39, + "panels": [], + "repeat": null, + "title": "memory, cpu and latency", + "type": "row" + }, + { + "aliasColors": { + "GC cpu fraction - promille": "#BF1B00", + "alloc speed": "#C15C17", + "major_page_faults": "#bf1b00", + "minor_page_faults": "#0a437c", + "rss": "#ba43a9" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 9 + }, + "id": 1, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 3, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "bytes_per_point", + "linewidth": 1, + "yaxis": 1 + }, + { + "alias": "major_page_faults", + "yaxis": 2 + }, + { + "alias": "minor_page_faults", + "fill": 5, + "linewidth": 0, + "yaxis": 2 + }, + { + "alias": "rss", + "fill": 2, + "linewidth": 0 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refCount": 0, + "refId": "A", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.memory.total_bytes_allocated.counter64)), 'alloc rate')" + }, + { + "hide": false, + "refCount": 0, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.memory.bytes.allocated_in_heap.gauge64), 'allocated in heap')" + }, + { + "hide": true, + "refCount": 0, + "refId": "C", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.memory.bytes.obtained_from_sys.gauge64), 'bytes_sys')" + }, + { + "hide": true, + "refCount": 0, + "refId": "D", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.process.virtual_memory_bytes.gauge64), 'vsz')" + }, + { + "hide": false, + "refCount": 0, + "refId": "E", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.process.resident_memory_bytes.gauge64), 'rss')" + }, + { + "refCount": 0, + "refId": "G", + "target": "aliasByNode(sumSeries(perSecond(metrictank.stats.$environment.$instance.process.minor_page_faults.counter64)), 5)" + }, + { + "refCount": 0, + "refId": "I", + "target": "aliasByNode(sumSeries(perSecond(metrictank.stats.$environment.$instance.process.major_page_faults.counter64)), 5)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "memory", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": "allocation", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "CPU usage": "#6d1f62", + "GC cpu fraction - promille": "#BF1B00", + "alloc speed": "#C15C17", + "major_page_faults": "#bf1b00", + "minor_page_faults": "#0a437c", + "rss": "#ba43a9" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 9 + }, + "id": 49, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 3, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "GC cpu fraction - promille", + "yaxis": 2 + }, + { + "alias": "CPU usage", + "fill": 10, + "linewidth": 0 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refCount": 0, + "refId": "H", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.memory.gc.cpu_fraction.gauge32), 'GC cpu fraction - promille')" + }, + { + "refCount": 0, + "refId": "F", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.process.cpu_seconds_total.counter64)), 'CPU usage')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percentunit", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "max": "#890F02", + "median": "#CCA300", + "p90": "#C15C17", + "reqs": "#2F575E" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 9 + }, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/idle/", + "lines": true, + "linewidth": 0, + "points": false, + "stack": true, + "yaxis": 2 + }, + { + "alias": "reqs", + "fill": 0, + "lines": true, + "linewidth": 1, + "nullPointMode": "null as zero", + "points": false, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render*.latency.median.gauge32), 'median')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render*.latency.p90.gauge32), 'p90')" + }, + { + "refId": "C", + "target": "alias(consolidateBy(maxSeries(metrictank.stats.$environment.$instance.api.request.render*.latency.max.gauge32), 'max'), 'max')" + }, + { + "refId": "E", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.api.request.render*.status.200.counter32)), 'reqs')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "render request duration (all steps)", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 52, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 25 + }, + "id": 13, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "duration", + "lines": false, + "pointradius": 1, + "points": true, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(perSecond(metrictank.stats.$environment.$instance.memory.total_gc_cycles.counter64)), 'collections')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.memory.gc.last_duration.gauge64), 'duration')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Golang GC", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ns", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "gogc": "#ba43a9" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 0, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 25 + }, + "id": 54, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 3, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": true, + "targets": [ + { + "refId": "A", + "target": "alias(metrictank.stats.$environment.$instance.memory.gc.gogc.sgauge32, 'gogc')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "GOGC", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "title": "runtime", + "type": "row" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 40, + "panels": [], + "repeat": null, + "title": "requests stats 1", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 20 + }, + "id": 35, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.series.median.gauge32), 'median')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.series.max.gauge32), 'max')" + }, + { + "refId": "C", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.series.p90.gauge32), 'p90')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Series Per Request", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 20 + }, + "id": 33, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.points_fetched.median.gauge32), 'fetched-median')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.points_fetched.p90.gauge32), 'fetched-p90')" + }, + { + "refId": "C", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.api.request.render.points_fetched.max.gauge32), 'fetched-max')" + }, + { + "refId": "D", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.points_returned.median.gauge32), 'returned-median')" + }, + { + "refId": "E", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.points_returned.p90.gauge32), 'returned-p90')" + }, + { + "refId": "F", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.api.request.render.points_returned.max.gauge32), 'returned-max')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Points Per Request", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 20 + }, + "id": 34, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "hideEmpty": true, + "hideZero": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(sortByName(sumSeriesWithWildcards(perSecond(metrictank.stats.$environment.$instance.api.request.*.status.*.counter32), 3), true), 5, 7)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Response Status per path", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 27 + }, + "id": 41, + "panels": [], + "repeat": null, + "title": "requests stats 2", + "type": "row" + }, + { + "aliasColors": { + "mem max": "#3F6833", + "mem median": "#9AC48A", + "mem min": "#E0F9D7", + "mem p90": "#7EB26D", + "mem-cass max": "#890F02", + "mem-cass median": "#E24D42", + "mem-cass min": "#F9934E", + "mem-cass p90": "#EF843C" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 28 + }, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "A", + "target": "alias(minSeries(metrictank.stats.$environment.$instance.api.requests_span.mem_and_cassandra.min.gauge32), 'mem-cass min')" + }, + { + "hide": true, + "refId": "B", + "target": "alias(metrictank.stats.$environment.$instance.api.requests_span.mem_and_cassandra.p90.gauge32, 'mem-cass p90')" + }, + { + "hide": false, + "refId": "C", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.requests_span.mem_and_cassandra.median.gauge32), 'mem-cass med')" + }, + { + "hide": false, + "refId": "D", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.api.requests_span.mem_and_cassandra.max.gauge32), 'mem-cass max')" + }, + { + "hide": false, + "refId": "E", + "target": "alias(minSeries(metrictank.stats.$environment.$instance.api.requests_span.mem.min.gauge32), 'mem min')" + }, + { + "hide": false, + "refId": "F", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.requests_span.mem.median.gauge32), 'mem med')" + }, + { + "hide": true, + "refId": "G", + "target": "alias(metrictank.stats.$environment.$instance.api.requests_span.mem.p90.gauge32, 'mem p90')" + }, + { + "hide": false, + "refId": "H", + "target": "alias(minSeries(metrictank.stats.$environment.$instance.api.requests_span.mem.max.gauge32), 'mem max')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "requests span", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "mem max": "#3F6833", + "mem median": "#9AC48A", + "mem min": "#E0F9D7", + "mem p90": "#7EB26D", + "mem-cass max": "#890F02", + "mem-cass median": "#E24D42", + "mem-cass min": "#F9934E", + "mem-cass p90": "#EF843C" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 28 + }, + "id": 10, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.api.requests_span.mem_and_cassandra.values.count32), 'mem-cass req/s')" + }, + { + "hide": false, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.api.requests_span.mem.values.count32), 'mem req/s')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "series requests rate", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "mem to iter": "#EAB839", + "store get": "#052B51", + "store get chunks": "#70DBED", + "store to iter": "#1F78C1", + "total request handle": "#890F02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 28 + }, + "id": 16, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.request.render.latency.mean.gauge32), 'total request handle')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.get_chunks.latency.mean.gauge32), 'store get chunks')" + }, + { + "refId": "C", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.to_iter.latency.mean.gauge32), 'store to iter')", + "textEditor": false + }, + { + "refId": "D", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.get_target.latency.mean.gauge32), 'total getTarget')", + "textEditor": false + }, + { + "refId": "E", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.api.iters_to_points.latency.mean.gauge32), 'iters to points')", + "textEditor": false + }, + { + "refId": "F", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.mem.to_iter.latency.mean.gauge32), 'mem to iter')", + "textEditor": false + }, + { + "refId": "G", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.plan.run.latency.mean.gauge32), 'plan run')", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "render request duration per step", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "transparent": true, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 2, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 42, + "panels": [], + "repeat": null, + "title": "store stats 1", + "type": "row" + }, + { + "aliasColors": { + "num write-workers": "#052B51", + "write queue items limit (each)": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 35 + }, + "id": 14, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "num write-workers", + "fill": 4, + "linewidth": 0, + "yaxis": 2 + }, + { + "alias": "/items/", + "lines": false, + "points": true + }, + { + "alias": "/limit/", + "linewidth": 1 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "hide": false, + "refId": "A", + "target": "aliasSub(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.store.*.write_queue.*.items.max.gauge32, 3), '.*write_queue.(.*).items.*', 'write_queue-\\1')" + }, + { + "hide": false, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.store.*.write_queue.size.gauge32), 'write queue items limit (each)')" + }, + { + "hide": false, + "refId": "C", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.store.*.num_writers.gauge32), 'num write-workers')" + }, + { + "hide": false, + "refId": "D", + "target": "alias(maxSeries(consolidateBy(metrictank.stats.$environment.$instance.persist.latency.max.gauge32, 'max')), 'persist-duration max')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "write workers & queues", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "message_size.max": "#EF843C", + "message_size.mean": "#2F575E" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 35 + }, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null as zero", + "percentage": false, + "pointradius": 1, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/messages-published/", + "fill": 4, + "linewidth": 0, + "yaxis": 2 + }, + { + "alias": "notifier.kafka", + "yaxis": 2 + }, + { + "alias": "messages_received", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "groupByNodes(metrictank.stats.$environment.$instance.cluster.notifier.*.message_size.mean.gauge32, 'avg', 6, 7, 8)", + "textEditor": false + }, + { + "refId": "B", + "target": "aliasByNode(sumSeries(perSecond(metrictank.stats.$environment.$instance.cluster.notifier.*.messages-published.counter32)), 5, 6)", + "textEditor": false + }, + { + "hide": false, + "refId": "C", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.cluster.notifier.all.messages-received.counter32)), 'messages_received')", + "textEditor": false + }, + { + "hide": false, + "refId": "D", + "target": "groupByNodes(metrictank.stats.$environment.$instance.cluster.notifier.*.message_size.max.gauge32, 'max',6, 7, 8)", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "metricpersist", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "hertz", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "block mean": "#3F6833", + "block p90": "#9AC48A", + "get mean": "#052B51", + "get p90": "#65C5DB", + "get/s": "#052B51", + "mean": "#0A50A1", + "p90": "#65C5DB", + "put mean": "#58140C", + "put p90": "#EA6460", + "put/s": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 35 + }, + "id": 21, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "dropped reads", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "E", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.get.wait.latency.p90.gauge32), 'get p90')" + }, + { + "refId": "F", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.put.wait.latency.p90.gauge32), 'put p90')", + "textEditor": false + }, + { + "refId": "A", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.store.*.omitted_old_reads.counter32)), 'dropped reads')", + "textEditor": false + }, + { + "refId": "B", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.store.*.read_queue_full.counter32)), 'read queue full')", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "store queue wait durations", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 43, + "panels": [], + "repeat": null, + "title": "store stats 2", + "type": "row" + }, + { + "aliasColors": { + "size at load max": "#B7DBAB", + "size at load median": "#3F6833", + "size at load p90": "#508642", + "size at save max": "#CFFAFF", + "size at save median": "#052B51", + "size at save p90": "#65C5DB" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 43 + }, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_load.median.gauge32), 'size at load median')" + }, + { + "refId": "B", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_load.p90.gauge32), 'size at load p90')" + }, + { + "refId": "C", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_load.max.gauge32), 'size at load max')" + }, + { + "refId": "D", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_save.median.gauge32), 'size at save median')" + }, + { + "refId": "E", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_save.p90.gauge32), 'size at save p90')" + }, + { + "refId": "F", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.chunk_size.at_save.max.gauge32), 'size at save max')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "chunk sizes", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "get mean": "#052B51", + "get/s": "#052B51", + "mean": "#0A50A1", + "p90": "#65C5DB", + "put mean": "#58140C", + "put p90": "#F29191", + "put/s": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 43 + }, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.store.*.put.exec.values.rate32), 'put/s')" + }, + { + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.store.*.get.exec.values.rate32), 'get/s')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "store req/s", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "block mean": "#3F6833", + "block p90": "#9AC48A", + "get mean": "#052B51", + "get/s": "#052B51", + "mean": "#0A50A1", + "p90": "#65C5DB", + "put mean": "#58140C", + "put p90": "#F29191", + "put/s": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 43 + }, + "id": 11, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.put.exec.latency.p90.gauge32), 'put p90')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.put.exec.latency.mean.gauge32), 'put mean')" + }, + { + "refId": "C", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.get.exec.latency.p90.gauge32), 'get p90')" + }, + { + "refId": "D", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.get.exec.latency.mean.gauge32), 'get mean')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "store query durations", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 50 + }, + "id": 44, + "panels": [], + "repeat": null, + "title": "store stats 3", + "type": "row" + }, + { + "aliasColors": { + "chunks per row max": "#C15C17", + "chunks per row med": "#890F02", + "chunks per row min": "#E5AC0E", + "rows per resp max": "#508642", + "rows per resp med": "#052B51", + "rows per resp min": "#6ED0E0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 51 + }, + "id": 7, + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "chunks per row max", + "fillBelowTo": "chunks per row min", + "lines": false + }, + { + "alias": "chunks per row min", + "lines": false + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.chunks_per_row.median.gauge32), 'chunks per row med')" + }, + { + "refId": "B", + "target": "alias(minSeries(metrictank.stats.$environment.$instance.store.*.chunks_per_row.min.gauge32), 'chunks per row min')" + }, + { + "refId": "C", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.chunks_per_row.max.gauge32), 'chunks per row max')" + }, + { + "refId": "D", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.store.*.rows_per_response.median.gauge32), 'rows per resp med')" + }, + { + "refId": "E", + "target": "alias(minSeries(metrictank.stats.$environment.$instance.store.*.rows_per_response.min.gauge32), 'rows per resp min')" + }, + { + "refId": "F", + "target": "alias(maxSeries(metrictank.stats.$environment.$instance.store.*.rows_per_response.max.gauge32), 'rows per resp max')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "store responses", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "conn-closed": "#58140C", + "no-connections": "#99440A", + "other": "#F2C96D", + "timeout": "#E24D42", + "too-many-timeouts": "#BA43A9", + "unavailable": "#890F02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 51 + }, + "id": 23, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(sumSeriesWithWildcards(perSecond(metrictank.stats.$environment.$instance.store.*.error.*.counter32), 2, 3), 5)" + }, + { + "refId": "B", + "target": "aliasByNode(metrictank.stats.$environment.$instance.store.*.omit_read.*.counter32, 7)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "store errors", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "create/s": "#70DBED", + "save_fail/s": "#BF1B00", + "save_ok/s": "#3F6833" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 51 + }, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(sumSeriesWithWildcards(perSecond(metrictank.stats.$environment.$instance.tank.chunk_operations.*.counter32), 2, 3), 4)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "chunk operations", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 58 + }, + "id": 45, + "panels": [], + "repeat": null, + "title": "index", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 7, + "w": 2, + "x": 0, + "y": 59 + }, + "id": 30, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.idx.metrics_active.gauge32), 'num')", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "num metrics", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "cache hit": "#3F6833", + "duration median": "#BA43A9", + "duration p90": "#511749", + "metricdef lookup duration median": "#E0752D", + "metricdef lookup duration p90": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 10, + "x": 2, + "y": 59 + }, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/max/", + "fill": 1, + "lines": true, + "linewidth": 0, + "points": false + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "groupByNodes(metrictank.stats.$environment.$instance.idx.*.*.latency.mean.gauge32, 'avg',5, 6, 8)" + }, + { + "refId": "B", + "target": "groupByNodes(consolidateBy(metrictank.stats.$environment.$instance.idx.*.*.latency.{p90,max}.gauge32, 'max'), 'max', 5, 6, 8)" + }, + { + "refId": "C", + "target": "groupByNodes(metrictank.stats.$environment.$instance.idx.*.*.*.latency.mean.gauge32, 'avg', 5, 6, 7, 9)", + "textEditor": false + }, + { + "refId": "D", + "target": "groupByNodes(consolidateBy(metrictank.stats.$environment.$instance.idx.*.*.*.latency.{p90,max}.gauge32, 'max'), 'max', 5, 6, 7, 9)", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "op durations", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "logBase": 2, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "add-to-bulk-index-fail": "#BF1B00", + "add-to-bulk-index-ok": "#3F6833", + "bigtable.fail": "#E24D42", + "bigtable.ok": "#3F6833", + "cassandra.fail": "#E24D42", + "cassandra.ok": "#3F6833", + "indexed-fail": "#E0752D", + "indexed-ok": "#629E51", + "max": "#DEDAF7", + "mean": "#052B51", + "memory.fail": "#BF1B00", + "memory.ok": "#7EB26D", + "metrics_to_es.fail": "#BF1B00", + "metrics_to_es.ok": "#629E51", + "p75": "#0A50A1", + "p90": "#806EB7" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "decimals": 0, + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 59 + }, + "id": 20, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 2, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/memory/", + "fill": 3, + "lines": true, + "linewidth": 0, + "points": false + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "groupByNodes(perSecond(metrictank.stats.$environment.$instance.idx.*.ops.*.counter32), 'sum', 5, 7)", + "textEditor": false + }, + { + "refId": "B", + "target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.add.values.rate32, 2, 3), 3, 4)", + "textEditor": false + }, + { + "refId": "C", + "target": "aliasByNode(sumSeriesWithWildcards(metrictank.stats.$environment.$instance.idx.*.query-insert.exec.values.rate32, 2, 3), 3, 4)", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "operation rates", + "tooltip": { + "msResolution": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "conn-closed": "#58140C", + "no-connections": "#99440A", + "other": "#F2C96D", + "timeout": "#E24D42", + "too-many-timeouts": "#BA43A9", + "unavailable": "#890F02" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 59 + }, + "id": 22, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "groupByNode(perSecond(metrictank.stats.$environment.$instance.idx.*.error.*.counter32), 7, 'sum')" + }, + { + "refId": "B", + "target": "groupByNode(perSecond(metrictank.stats.$environment.$instance.idx.*.save.skipped.counter32), 7, 'sum')" + }, + { + "refId": "C", + "target": "groupByNode(perSecond(metrictank.stats.$environment.$instance.recovered_errors.idx.*.*.counter32), 7, 'sum')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "idx errors", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "entries": "#1f78c1" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 0, + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 66 + }, + "id": 56, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "backoff", + "dashes": true, + "fill": 0, + "linewidth": 3, + "yaxis": 2 + }, + { + "alias": "/entries/", + "fill": 6, + "linewidth": 0, + "stack": "A" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "B", + "target": "aliasByNode(metrictank.stats.$environment.$instance.idx.memory.find-cache.entries.gauge32, 7, 3)" + }, + { + "refCount": 0, + "refId": "C", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.idx.memory.find-cache.backoff.gauge32), 'backoff')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "find cache entries & backoff", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "hit-rate": "#1f78c1", + "miss": "#e0752d" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 10, + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 66 + }, + "id": 57, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "hit-rate", + "dashes": true, + "fill": 0, + "linewidth": 3, + "stack": false, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refCount": 1, + "refId": "A", + "target": "groupByNode(metrictank.stats.$environment.$instance.idx.memory.find-cache.ops.*.rate32, 8, 'sum')" + }, + { + "hide": true, + "refCount": 1, + "refId": "B", + "target": "alias(sumSeries(keepLastValue(metrictank.stats.$environment.$instance.idx.memory.find-cache.ops.*.rate32, 10)), 'sum')", + "textEditor": false + }, + { + "refCount": 0, + "refId": "C", + "target": "alias(asPercent(sumSeries(keepLastValue(metrictank.stats.$environment.$instance.idx.memory.find-cache.ops.hit.rate32, 10)), #B), 'hit-rate')", + "targetFull": "alias(asPercent(sumSeries(keepLastValue(metrictank.stats.$environment.$instance.idx.memory.find-cache.ops.hit.rate32, 10)), alias(sumSeries(keepLastValue(metrictank.stats.$environment.$instance.idx.memory.find-cache.ops.*.rate32, 10)), 'sum')), 'hit-rate')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "find cache hit/miss", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "hertz", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "drop": "#f2c96d", + "exec": "#5195ce", + "recv": "#962d82" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 10, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 66 + }, + "id": 58, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refCount": 0, + "refId": "A", + "target": "groupByNode(metrictank.stats.$environment.$instance.idx.memory.find-cache.invalidation.*.rate32, 8, 'sum')", + "textEditor": false + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "find cache invalidations", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 70 + }, + "id": 46, + "panels": [], + "repeat": null, + "title": "stats reporting", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 71 + }, + "id": 24, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(metrictank.stats.$environment.$instance.stats.message_size.gauge32, 3)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "message size", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "queue-items": "#7EB26D", + "queue-limit": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 71 + }, + "id": 25, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "connected", + "fill": 4, + "linewidth": 0, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.stats.graphite.connected.gauge1), 'connected')" + }, + { + "refId": "B", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.stats.graphite.flush.latency.p90.gauge32), 'flush latency')" + }, + { + "refId": "C", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.stats.graphite.flush.values.rate32), 'flush rate')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "conn to graphite", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "queue-items": "#7EB26D", + "queue-limit": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "grid": {}, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 71 + }, + "id": 26, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.stats.graphite.write_queue.size.gauge32), 'queue-limit')" + }, + { + "refId": "B", + "target": "alias(metrictank.stats.$environment.$instance.stats.graphite.write_queue.items.max.gauge32, 'queue-items')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "queue to graphite", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 78 + }, + "id": 47, + "panels": [], + "repeat": null, + "title": "chunk cache", + "type": "row" + }, + { + "aliasColors": { + "max": "#890F02", + "used": "#3F6833" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 79 + }, + "id": 27, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/^(lru|flat|chunk|used)$/", + "fill": 6, + "linewidth": 0, + "stack": "A" + }, + { + "alias": "utilisation", + "dashes": true, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refCount": 1, + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.cache.size.max.gauge64), 'max')", + "textEditor": false + }, + { + "refCount": 0, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.cache.size.used.gauge64), 'used')" + }, + { + "refCount": 0, + "refId": "C", + "target": "alias(divideSeries(#B,#A), 'utilisation')", + "targetFull": "alias(divideSeries(alias(sumSeries(metrictank.stats.$environment.$instance.cache.size.used.gauge64), 'used'),alias(sumSeries(metrictank.stats.$environment.$instance.cache.size.max.gauge64), 'max')), 'utilisation')", + "textEditor": true + }, + { + "hide": false, + "refCount": 0, + "refId": "D", + "target": "sortByName(groupByNodes(aliasByNode(metrictank.stats.$environment.$instance.cache.overhead.*.gauge64, 6), 'sum', 0))", + "textEditor": false + }, + { + "refCount": 0, + "refId": "E", + "target": "alias(sum(#B, #D),'total used')", + "targetFull": "alias(sum(alias(sumSeries(metrictank.stats.$environment.$instance.cache.size.used.gauge64), 'used'), sortByName(groupByNodes(aliasByNode(metrictank.stats.$environment.$instance.cache.overhead.*.gauge64, 6), 'sum', 0))),'total used')", + "textEditor": true + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "size", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 1, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "percentunit", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "add-latency-p90": "#c15c17", + "max": "#890F02", + "used": "#3F6833", + "utilisation": "#6ed0e0" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 0, + "grid": {}, + "gridPos": { + "h": 6, + "w": 7, + "x": 8, + "y": 79 + }, + "id": 50, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "used", + "lines": false, + "pointradius": 1, + "points": true, + "yaxis": 2 + }, + { + "alias": "max", + "lines": false, + "pointradius": 1, + "points": true, + "yaxis": 2 + }, + { + "alias": "/latency/", + "fill": 2, + "linewidth": 0 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refCount": 1, + "refId": "A", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.cache.accounting.queue.size.max.gauge64), 'max')", + "textEditor": false + }, + { + "refCount": 1, + "refId": "B", + "target": "alias(sumSeries(metrictank.stats.$environment.$instance.cache.accounting.queue.size.used.max.gauge32), 'used')", + "textEditor": false + }, + { + "refCount": 1, + "refId": "C", + "target": "alias(averageSeries(metrictank.stats.$environment.$instance.cache.accounting.queue.add.latency.p90.gauge32), 'add-latency-p90')", + "textEditor": false + }, + { + "hide": true, + "refCount": 0, + "refId": "D", + "target": "alias(divideSeries(#A,#B),'utilisation')", + "targetFull": "alias(divideSeries(alias(sumSeries(metrictank.stats.$environment.$instance.cache.accounting.queue.size.max.gauge64), 'max'),alias(sumSeries(metrictank.stats.$environment.$instance.cache.accounting.queue.size.used.max.gauge32), 'used')),'utilisation')", + "textEditor": true + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "accounting", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "none", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "add": "#629E51", + "chunk evict": "#3F2B5B", + "evict": "#962D82", + "hit-full": "#7EB26D", + "hit-partial": "#E0752D", + "metric evict": "#6D1F62", + "miss": "#BF1B00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 10, + "grid": {}, + "gridPos": { + "h": 6, + "w": 9, + "x": 15, + "y": 79 + }, + "id": 28, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "/evict/", + "fill": 0, + "linewidth": 1, + "yaxis": 2 + }, + { + "alias": "add", + "fill": 0, + "linewidth": 1, + "yaxis": 2 + }, + { + "alias": "/hit-*/", + "stack": "A" + }, + { + "alias": "miss", + "stack": "A" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(sumSeriesWithWildcards(perSecond(metrictank.stats.$environment.$instance.cache.ops.metric.{hit-*,miss}.counter32), 2, 3), 5)" + }, + { + "refId": "B", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.cache.ops.metric.evict.counter32)), 'metric evict')" + }, + { + "refId": "C", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.cache.ops.chunk.evict.counter32)), 'chunk evict')" + }, + { + "refId": "D", + "target": "alias(sumSeries(perSecond(metrictank.stats.$environment.$instance.recovered_errors.cache.metric.searchForwardBug.counter32)), 'cache-bug-surpress')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "operations", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 85 + }, + "id": 48, + "panels": [], + "repeat": null, + "title": "clustering", + "type": "row" + }, + { + "aliasColors": { + "join": "dark-green", + "leave": "dark-red", + "total.primary-not-ready": "#3F6833", + "total.primary-ready": "#9AC48A", + "total.secondary-not-ready": "#052B51", + "total.secondary-ready": "#2F575E", + "update": "light-purple" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 6, + "w": 18, + "x": 0, + "y": 86 + }, + "id": 32, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": false, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "groupByNode(perSecond(metrictank.stats.$environment.$instance.cluster.events.*.counter32), 6, 'sum')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "gossip events", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 1, + "gridPos": { + "h": 11, + "w": 6, + "x": 18, + "y": 86 + }, + "id": 15, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(sumSeries(perSecond(metrictank.stats.$environment.$instance.cluster.decode_err.*.counter32)), 6)" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "gossip decode errors", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "join": "semi-dark-green", + "leave": "light-red", + "primary-not-ready": "dark-red", + "primary-ready": "dark-green", + "query-not-ready": "rgb(120, 0, 15)", + "query-ready": "rgb(154, 0, 227)", + "secondary-not-ready": "rgb(214, 17, 0)", + "secondary-ready": "rgb(54, 193, 31)", + "total.primary-not-ready": "#3F6833", + "total.primary-ready": "#9AC48A", + "total.secondary-not-ready": "#052B51", + "total.secondary-ready": "#2F575E", + "update": "#511749" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "$datasource", + "fill": 4, + "gridPos": { + "h": 5, + "w": 18, + "x": 0, + "y": 92 + }, + "id": 59, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 0, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "refId": "B", + "target": "groupByNode(metrictank.stats.$environment.$instance.cluster.total.state.*.gauge32, 7, 'sum')" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "gossip events and state", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": false, + "schemaVersion": 19, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": "default", + "value": "default" + }, + "datasource": null, + "hide": 0, + "includeAll": false, + "label": "", + "multi": false, + "name": "datasource", + "options": [], + "query": "graphite", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allFormat": "glob", + "allValue": null, + "current": {}, + "datasource": "$datasource", + "definition": "", + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "multiFormat": "glob", + "name": "environment", + "options": [], + "query": "metrictank.stats.*", + "refresh": 1, + "refresh_on_load": false, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "$datasource", + "definition": "", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "multiFormat": "glob", + "name": "instance", + "options": [], + "query": "metrictank.stats.$environment.*", + "refresh": 1, + "refresh_on_load": false, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "notice": false, + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "status": "Stable", + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], + "type": "timepicker" + }, + "timezone": "utc", + "title": "Metrictank", + "uid": "tQW3QShiz" +} diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts new file mode 100644 index 0000000..a4cf2ae --- /dev/null +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -0,0 +1,686 @@ +import _ from 'lodash'; +import { + DataFrame, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + dateMath, + QueryResultMetaStat, + ScopedVars, + toDataFrame, + TimeRange, +} from '@grafana/data'; +import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version'; +import gfunc from './gfunc'; +import { getBackendSrv } from '@grafana/runtime'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +// Types +import { GraphiteOptions, GraphiteQuery, GraphiteType, MetricTankRequestMeta } from './types'; +import { getRollupNotice, getRuntimeConsolidationNotice } from 'app/plugins/datasource/graphite/meta'; +import { getSearchFilterScopedVar } from '../../../features/variables/utils'; + +export class GraphiteDatasource extends DataSourceApi { + basicAuth: string; + url: string; + name: string; + graphiteVersion: any; + supportsTags: boolean; + isMetricTank: boolean; + rollupIndicatorEnabled: boolean; + cacheTimeout: any; + withCredentials: boolean; + funcDefs: any = null; + funcDefsPromise: Promise | null = null; + _seriesRefLetters: string; + + constructor(instanceSettings: any, private readonly templateSrv: TemplateSrv = getTemplateSrv()) { + super(instanceSettings); + this.basicAuth = instanceSettings.basicAuth; + this.url = instanceSettings.url; + this.name = instanceSettings.name; + this.graphiteVersion = instanceSettings.jsonData.graphiteVersion || '1.1'; + this.isMetricTank = instanceSettings.jsonData.graphiteType === GraphiteType.Metrictank; + this.supportsTags = supportsTags(this.graphiteVersion); + this.cacheTimeout = instanceSettings.cacheTimeout; + this.rollupIndicatorEnabled = instanceSettings.jsonData.rollupIndicatorEnabled; + this.withCredentials = instanceSettings.withCredentials; + this.funcDefs = null; + this.funcDefsPromise = null; + this._seriesRefLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + } + + getQueryOptionsInfo() { + return { + maxDataPoints: true, + cacheTimeout: true, + links: [ + { + text: 'Help', + url: 'http://docs.grafana.org/features/datasources/graphite/#using-graphite-in-grafana', + }, + ], + }; + } + + async query(options: DataQueryRequest): Promise { + const graphOptions = { + from: this.translateTime(options.range.raw.from, false, options.timezone), + until: this.translateTime(options.range.raw.to, true, options.timezone), + targets: options.targets, + format: (options as any).format, + cacheTimeout: options.cacheTimeout || this.cacheTimeout, + maxDataPoints: options.maxDataPoints, + }; + + const params = this.buildGraphiteParams(graphOptions, options.scopedVars); + if (params.length === 0) { + return Promise.resolve({ data: [] }); + } + + if (this.isMetricTank) { + params.push('meta=true'); + } + + const httpOptions: any = { + method: 'POST', + url: '/render', + data: params.join('&'), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + + this.addTracingHeaders(httpOptions, options); + + if (options.panelId) { + httpOptions.requestId = this.name + '.panelId.' + options.panelId; + } + + return this.doGraphiteRequest(httpOptions).then(this.convertResponseToDataFrames); + } + + addTracingHeaders(httpOptions: { headers: any }, options: { dashboardId?: number; panelId?: number }) { + const proxyMode = !this.url.match(/^http/); + if (proxyMode) { + if (options.dashboardId) { + httpOptions.headers['X-Dashboard-Id'] = options.dashboardId; + } + if (options.panelId) { + httpOptions.headers['X-Panel-Id'] = options.panelId; + } + } + } + + convertResponseToDataFrames = (result: any): DataQueryResponse => { + const data: DataFrame[] = []; + if (!result || !result.data) { + return { data }; + } + + // Series are either at the root or under a node called 'series' + const series = result.data.series || result.data; + + if (!_.isArray(series)) { + throw { message: 'Missing series in result', data: result }; + } + + for (let i = 0; i < series.length; i++) { + const s = series[i]; + + // Disables Grafana own series naming + s.title = s.target; + + for (let y = 0; y < s.datapoints.length; y++) { + s.datapoints[y][1] *= 1000; + } + + const frame = toDataFrame(s); + + // Metrictank metadata + if (s.meta) { + frame.meta = { + custom: { + requestMetaList: result.data.meta, // info for the whole request + seriesMetaList: s.meta, // Array of metadata + }, + }; + + if (this.rollupIndicatorEnabled) { + const rollupNotice = getRollupNotice(s.meta); + const runtimeNotice = getRuntimeConsolidationNotice(s.meta); + + if (rollupNotice) { + frame.meta.notices = [rollupNotice]; + } else if (runtimeNotice) { + frame.meta.notices = [runtimeNotice]; + } + } + + // only add the request stats to the first frame + if (i === 0 && result.data.meta.stats) { + frame.meta.stats = this.getRequestStats(result.data.meta); + } + } + + data.push(frame); + } + + return { data }; + }; + + getRequestStats(meta: MetricTankRequestMeta): QueryResultMetaStat[] { + const stats: QueryResultMetaStat[] = []; + + for (const key in meta.stats) { + let unit: string | undefined = undefined; + + if (key.endsWith('.ms')) { + unit = 'ms'; + } + + stats.push({ displayName: key, value: meta.stats[key], unit }); + } + + return stats; + } + + parseTags(tagString: string) { + let tags: string[] = []; + tags = tagString.split(','); + if (tags.length === 1) { + tags = tagString.split(' '); + if (tags[0] === '') { + tags = []; + } + } + return tags; + } + + interpolateVariablesInQueries(queries: GraphiteQuery[], scopedVars: ScopedVars): GraphiteQuery[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + target: this.templateSrv.replace(query.target ?? '', scopedVars), + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + annotationQuery(options: any) { + // Graphite metric as annotation + if (options.annotation.target) { + const target = this.templateSrv.replace(options.annotation.target, {}, 'glob'); + const graphiteQuery = ({ + range: options.range, + targets: [{ target: target }], + format: 'json', + maxDataPoints: 100, + } as unknown) as DataQueryRequest; + + return this.query(graphiteQuery).then((result) => { + const list = []; + + for (let i = 0; i < result.data.length; i++) { + const target = result.data[i]; + + for (let y = 0; y < target.length; y++) { + const time = target.fields[0].values.get(y); + const value = target.fields[1].values.get(y); + + if (!value) { + continue; + } + + list.push({ + annotation: options.annotation, + time, + title: target.name, + }); + } + } + + return list; + }); + } else { + // Graphite event as annotation + const tags = this.templateSrv.replace(options.annotation.tags); + return this.events({ range: options.range, tags: tags }).then((results: any) => { + const list = []; + for (let i = 0; i < results.data.length; i++) { + const e = results.data[i]; + + let tags = e.tags; + if (_.isString(e.tags)) { + tags = this.parseTags(e.tags); + } + + list.push({ + annotation: options.annotation, + time: e.when * 1000, + title: e.what, + tags: tags, + text: e.data, + }); + } + + return list; + }); + } + } + + events(options: { range: TimeRange; tags: any; timezone?: any }) { + try { + let tags = ''; + if (options.tags) { + tags = '&tags=' + options.tags; + } + return this.doGraphiteRequest({ + method: 'GET', + url: + '/events/get_data?from=' + + this.translateTime(options.range.raw.from, false, options.timezone) + + '&until=' + + this.translateTime(options.range.raw.to, true, options.timezone) + + tags, + }); + } catch (err) { + return Promise.reject(err); + } + } + + targetContainsTemplate(target: GraphiteQuery) { + return this.templateSrv.variableExists(target.target ?? ''); + } + + translateTime(date: any, roundUp: any, timezone: any) { + if (_.isString(date)) { + if (date === 'now') { + return 'now'; + } else if (date.indexOf('now-') >= 0 && date.indexOf('/') === -1) { + date = date.substring(3); + date = date.replace('m', 'min'); + date = date.replace('M', 'mon'); + return date; + } + date = dateMath.parse(date, roundUp, timezone); + } + + // graphite' s from filter is exclusive + // here we step back one minute in order + // to guarantee that we get all the data that + // exists for the specified range + if (roundUp) { + if (date.get('s')) { + date.add(1, 's'); + } + } else if (roundUp === false) { + if (date.get('s')) { + date.subtract(1, 's'); + } + } + + return date.unix(); + } + + metricFindQuery(query: string, optionalOptions?: any) { + const options: any = optionalOptions || {}; + let interpolatedQuery = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '', options: optionalOptions }) + ); + + // special handling for tag_values([,]*), this is used for template variables + let allParams = interpolatedQuery.match(/^tag_values\((.*)\)$/); + let expressions = allParams ? allParams[1].split(',').filter((p) => !!p) : undefined; + if (expressions) { + options.limit = 10000; + return this.getTagValuesAutoComplete(expressions.slice(1), expressions[0], undefined, options); + } + + // special handling for tags([,]*), this is used for template variables + allParams = interpolatedQuery.match(/^tags\((.*)\)$/); + expressions = allParams ? allParams[1].split(',').filter((p) => !!p) : undefined; + if (expressions) { + options.limit = 10000; + return this.getTagsAutoComplete(expressions, undefined, options); + } + + interpolatedQuery = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '*', options: optionalOptions }) + ); + + const httpOptions: any = { + method: 'POST', + url: '/metrics/find', + params: {}, + data: `query=${interpolatedQuery}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + // for cancellations + requestId: options.requestId, + }; + + if (options.range) { + httpOptions.params.from = this.translateTime(options.range.from, false, options.timezone); + httpOptions.params.until = this.translateTime(options.range.to, true, options.timezone); + } + + return this.doGraphiteRequest(httpOptions).then((results: any) => { + return _.map(results.data, (metric) => { + return { + text: metric.text, + expandable: metric.expandable ? true : false, + }; + }); + }); + } + + getTags(optionalOptions: any) { + const options = optionalOptions || {}; + + const httpOptions: any = { + method: 'GET', + url: '/tags', + // for cancellations + requestId: options.requestId, + }; + + if (options.range) { + httpOptions.params.from = this.translateTime(options.range.from, false, options.timezone); + httpOptions.params.until = this.translateTime(options.range.to, true, options.timezone); + } + + return this.doGraphiteRequest(httpOptions).then((results: any) => { + return _.map(results.data, (tag) => { + return { + text: tag.tag, + id: tag.id, + }; + }); + }); + } + + getTagValues(options: any = {}) { + const httpOptions: any = { + method: 'GET', + url: '/tags/' + this.templateSrv.replace(options.key), + // for cancellations + requestId: options.requestId, + }; + + if (options.range) { + httpOptions.params.from = this.translateTime(options.range.from, false, options.timezone); + httpOptions.params.until = this.translateTime(options.range.to, true, options.timezone); + } + + return this.doGraphiteRequest(httpOptions).then((results: any) => { + if (results.data && results.data.values) { + return _.map(results.data.values, (value) => { + return { + text: value.value, + id: value.id, + }; + }); + } else { + return []; + } + }); + } + + getTagsAutoComplete(expressions: any[], tagPrefix: any, optionalOptions: any) { + const options = optionalOptions || {}; + + const httpOptions: any = { + method: 'GET', + url: '/tags/autoComplete/tags', + params: { + expr: _.map(expressions, (expression) => this.templateSrv.replace((expression || '').trim())), + }, + // for cancellations + requestId: options.requestId, + }; + + if (tagPrefix) { + httpOptions.params.tagPrefix = tagPrefix; + } + if (options.limit) { + httpOptions.params.limit = options.limit; + } + if (options.range) { + httpOptions.params.from = this.translateTime(options.range.from, false, options.timezone); + httpOptions.params.until = this.translateTime(options.range.to, true, options.timezone); + } + + return this.doGraphiteRequest(httpOptions).then((results: any) => { + if (results.data) { + return _.map(results.data, (tag) => { + return { text: tag }; + }); + } else { + return []; + } + }); + } + + getTagValuesAutoComplete(expressions: any[], tag: any, valuePrefix: any, optionalOptions: any) { + const options = optionalOptions || {}; + + const httpOptions: any = { + method: 'GET', + url: '/tags/autoComplete/values', + params: { + expr: _.map(expressions, (expression) => this.templateSrv.replace((expression || '').trim())), + tag: this.templateSrv.replace((tag || '').trim()), + }, + // for cancellations + requestId: options.requestId, + }; + + if (valuePrefix) { + httpOptions.params.valuePrefix = valuePrefix; + } + if (options.limit) { + httpOptions.params.limit = options.limit; + } + if (options.range) { + httpOptions.params.from = this.translateTime(options.range.from, false, options.timezone); + httpOptions.params.until = this.translateTime(options.range.to, true, options.timezone); + } + + return this.doGraphiteRequest(httpOptions).then((results: any) => { + if (results.data) { + return _.map(results.data, (value) => { + return { text: value }; + }); + } else { + return []; + } + }); + } + + getVersion(optionalOptions: any) { + const options = optionalOptions || {}; + + const httpOptions = { + method: 'GET', + url: '/version', + requestId: options.requestId, + }; + + return this.doGraphiteRequest(httpOptions) + .then((results: any) => { + if (results.data) { + const semver = new SemVersion(results.data); + return semver.isValid() ? results.data : ''; + } + return ''; + }) + .catch(() => { + return ''; + }); + } + + createFuncInstance(funcDef: any, options?: any) { + return gfunc.createFuncInstance(funcDef, options, this.funcDefs); + } + + getFuncDef(name: string) { + return gfunc.getFuncDef(name, this.funcDefs); + } + + waitForFuncDefsLoaded() { + return this.getFuncDefs(); + } + + getFuncDefs() { + if (this.funcDefsPromise !== null) { + return this.funcDefsPromise; + } + + if (!supportsFunctionIndex(this.graphiteVersion)) { + this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion); + this.funcDefsPromise = Promise.resolve(this.funcDefs); + return this.funcDefsPromise; + } + + const httpOptions = { + method: 'GET', + url: '/functions', + }; + + this.funcDefsPromise = this.doGraphiteRequest(httpOptions) + .then((results: any) => { + if (results.status !== 200 || typeof results.data !== 'object') { + this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion); + } else { + this.funcDefs = gfunc.parseFuncDefs(results.data); + } + return this.funcDefs; + }) + .catch((err: any) => { + console.error('Fetching graphite functions error', err); + this.funcDefs = gfunc.getFuncDefs(this.graphiteVersion); + return this.funcDefs; + }); + + return this.funcDefsPromise; + } + + testDatasource() { + const query = ({ + panelId: 3, + rangeRaw: { from: 'now-1h', to: 'now' }, + range: { + raw: { from: 'now-1h', to: 'now' }, + }, + targets: [{ target: 'constantLine(100)' }], + maxDataPoints: 300, + } as unknown) as DataQueryRequest; + return this.query(query).then(() => { + return { status: 'success', message: 'Data source is working' }; + }); + } + + doGraphiteRequest(options: { + method?: string; + url: any; + requestId?: any; + withCredentials?: any; + headers?: any; + inspect?: any; + }) { + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + if (this.basicAuth) { + options.headers = options.headers || {}; + options.headers.Authorization = this.basicAuth; + } + + options.url = this.url + options.url; + options.inspect = { type: 'graphite' }; + + return getBackendSrv().datasourceRequest(options); + } + + buildGraphiteParams(options: any, scopedVars?: ScopedVars): string[] { + const graphiteOptions = ['from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout']; + const cleanOptions = [], + targets: any = {}; + let target, targetValue, i; + const regex = /\#([A-Z])/g; + const intervalFormatFixRegex = /'(\d+)m'/gi; + let hasTargets = false; + + options['format'] = 'json'; + + function fixIntervalFormat(match: any) { + return match.replace('m', 'min').replace('M', 'mon'); + } + + for (i = 0; i < options.targets.length; i++) { + target = options.targets[i]; + if (!target.target) { + continue; + } + + if (!target.refId) { + target.refId = this._seriesRefLetters[i]; + } + + targetValue = this.templateSrv.replace(target.target, scopedVars); + targetValue = targetValue.replace(intervalFormatFixRegex, fixIntervalFormat); + targets[target.refId] = targetValue; + } + + function nestedSeriesRegexReplacer(match: any, g1: string | number) { + return targets[g1] || match; + } + + for (i = 0; i < options.targets.length; i++) { + target = options.targets[i]; + if (!target.target) { + continue; + } + + targetValue = targets[target.refId]; + targetValue = targetValue.replace(regex, nestedSeriesRegexReplacer); + targets[target.refId] = targetValue; + + if (!target.hide) { + hasTargets = true; + cleanOptions.push('target=' + encodeURIComponent(targetValue)); + } + } + + _.each(options, (value, key) => { + if (_.indexOf(graphiteOptions, key) === -1) { + return; + } + if (value) { + cleanOptions.push(key + '=' + encodeURIComponent(value)); + } + }); + + if (!hasTargets) { + return []; + } + + return cleanOptions; + } +} + +function supportsTags(version: string): boolean { + return isVersionGtOrEq(version, '1.1'); +} + +function supportsFunctionIndex(version: string): boolean { + return isVersionGtOrEq(version, '1.1'); +} diff --git a/public/app/plugins/datasource/graphite/func_editor.ts b/public/app/plugins/datasource/graphite/func_editor.ts new file mode 100644 index 0000000..1425b61 --- /dev/null +++ b/public/app/plugins/datasource/graphite/func_editor.ts @@ -0,0 +1,248 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from 'app/core/core_module'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + +/** @ngInject */ +export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) { + const funcSpanTemplate = ` + + + ( + `; + const paramTemplate = + ''; + + return { + restrict: 'A', + link: function postLink($scope: any, elem: JQuery) { + const $funcLink = $(funcSpanTemplate); + const ctrl = $scope.ctrl; + const func = $scope.func; + let scheduledRelink = false; + let paramCountAtLink = 0; + let cancelBlur: any = null; + + ctrl.handleRemoveFunction = (func: any) => { + ctrl.removeFunction(func); + }; + + ctrl.handleMoveLeft = (func: any) => { + ctrl.moveFunction(func, -1); + }; + + ctrl.handleMoveRight = (func: any) => { + ctrl.moveFunction(func, 1); + }; + + function clickFuncParam(this: any, paramIndex: any) { + const $link = $(this); + const $comma = $link.prev('.comma'); + const $input = $link.next(); + + $input.val(func.params[paramIndex]); + + $comma.removeClass('query-part__last'); + $link.hide(); + $input.show(); + $input.focus(); + $input.select(); + + const typeahead = $input.data('typeahead'); + if (typeahead) { + $input.val(''); + typeahead.lookup(); + } + } + + function scheduledRelinkIfNeeded() { + if (paramCountAtLink === func.params.length) { + return; + } + + if (!scheduledRelink) { + scheduledRelink = true; + setTimeout(() => { + relink(); + scheduledRelink = false; + }, 200); + } + } + + function paramDef(index: number) { + if (index < func.def.params.length) { + return func.def.params[index]; + } + if ((_.last(func.def.params) as any).multiple) { + return _.assign({}, _.last(func.def.params), { optional: true }); + } + return {}; + } + + function switchToLink(inputElem: HTMLElement, paramIndex: any) { + const $input = $(inputElem); + + clearTimeout(cancelBlur); + cancelBlur = null; + + const $link = $input.prev(); + const $comma = $link.prev('.comma'); + const newValue = $input.val(); + + // remove optional empty params + if (newValue !== '' || paramDef(paramIndex).optional) { + func.updateParam(newValue, paramIndex); + $link.html(newValue ? templateSrv.highlightVariablesAsHtml(newValue) : ' '); + } + + scheduledRelinkIfNeeded(); + + $scope.$apply(() => { + ctrl.targetChanged(); + }); + + if ($link.hasClass('query-part__last') && newValue === '') { + $comma.addClass('query-part__last'); + } else { + $link.removeClass('query-part__last'); + } + + $input.hide(); + $link.show(); + } + + // this = input element + function inputBlur(this: any, paramIndex: any) { + const inputElem = this; + // happens long before the click event on the typeahead options + // need to have long delay because the blur + cancelBlur = setTimeout(() => { + switchToLink(inputElem, paramIndex); + }, 200); + } + + function inputKeyPress(this: any, paramIndex: any, e: any) { + if (e.which === 13) { + $(this).blur(); + } + } + + function inputKeyDown(this: any) { + this.style.width = (3 + this.value.length) * 8 + 'px'; + } + + function addTypeahead($input: any, paramIndex: any) { + $input.attr('data-provide', 'typeahead'); + + let options = paramDef(paramIndex).options; + if (paramDef(paramIndex).type === 'int') { + options = _.map(options, (val) => { + return val.toString(); + }); + } + + $input.typeahead({ + source: options, + minLength: 0, + items: 20, + updater: (value: any) => { + $input.val(value); + switchToLink($input[0], paramIndex); + return value; + }, + }); + + const typeahead = $input.data('typeahead'); + typeahead.lookup = function () { + this.query = this.$element.val() || ''; + return this.process(this.source); + }; + } + + function addElementsAndCompile() { + $funcLink.appendTo(elem); + + const defParams: any = _.clone(func.def.params); + const lastParam: any = _.last(func.def.params); + + while (func.params.length >= defParams.length && lastParam && lastParam.multiple) { + defParams.push(_.assign({}, lastParam, { optional: true })); + } + + _.each(defParams, (param: any, index: number) => { + if (param.optional && func.params.length < index) { + return false; + } + + let paramValue = templateSrv.highlightVariablesAsHtml(func.params[index]); + const hasValue = paramValue !== null && paramValue !== undefined && paramValue !== ''; + const last = index >= func.params.length - 1 && param.optional && !hasValue; + let linkClass = 'query-part__link'; + + if (last) { + linkClass += ' query-part__last'; + } + + if (last && param.multiple) { + paramValue = '+'; + } else if (!hasValue) { + // for params with no value default to param name + paramValue = param.name; + linkClass += ' query-part__link--no-value'; + } + + if (index > 0) { + $(', ').appendTo(elem); + } + + const $paramLink = $(`${paramValue}`); + const $input = $(paramTemplate); + $input.attr('placeholder', param.name); + + paramCountAtLink++; + + $paramLink.appendTo(elem); + $input.appendTo(elem); + + $input.blur(_.partial(inputBlur, index)); + $input.keyup(inputKeyDown); + $input.keypress(_.partial(inputKeyPress, index)); + $paramLink.click(_.partial(clickFuncParam, index)); + + if (param.options) { + addTypeahead($input, index); + } + + return true; + }); + + $(')').appendTo(elem); + + $compile(elem.contents())($scope); + } + + function ifJustAddedFocusFirstParam() { + if ($scope.func.added) { + $scope.func.added = false; + setTimeout(() => { + elem.find('.query-part__link').first().click(); + }, 10); + } + } + + function relink() { + elem.children().remove(); + addElementsAndCompile(); + ifJustAddedFocusFirstParam(); + } + + relink(); + }, + }; +} + +coreModule.directive('graphiteFuncEditor', graphiteFuncEditor); diff --git a/public/app/plugins/datasource/graphite/gfunc.ts b/public/app/plugins/datasource/graphite/gfunc.ts new file mode 100644 index 0000000..c050d8a --- /dev/null +++ b/public/app/plugins/datasource/graphite/gfunc.ts @@ -0,0 +1,1204 @@ +import _ from 'lodash'; +import { isVersionGtOrEq } from 'app/core/utils/version'; +import { InterpolateFunction } from '@grafana/data'; + +const index: any = {}; + +export interface FuncDef { + name: any; + category?: string; + params?: any; + defaultParams?: any; + shortName?: any; + fake?: boolean; + version?: string; + description?: string; +} + +function addFuncDef(funcDef: FuncDef) { + funcDef.params = funcDef.params || []; + funcDef.defaultParams = funcDef.defaultParams || []; + + index[funcDef.name] = funcDef; + if (funcDef.shortName) { + index[funcDef.shortName] = funcDef; + } +} + +const optionalSeriesRefArgs = [{ name: 'other', type: 'value_or_series', optional: true, multiple: true }]; + +addFuncDef({ + name: 'scaleToSeconds', + category: 'Transform', + params: [{ name: 'seconds', type: 'int' }], + defaultParams: [1], +}); + +addFuncDef({ + name: 'perSecond', + category: 'Transform', + params: [{ name: 'max value', type: 'int', optional: true }], + defaultParams: [], +}); + +addFuncDef({ + name: 'holtWintersForecast', + category: 'Calculate', +}); + +addFuncDef({ + name: 'holtWintersConfidenceBands', + category: 'Calculate', + params: [{ name: 'delta', type: 'int' }], + defaultParams: [3], +}); + +addFuncDef({ + name: 'holtWintersAberration', + category: 'Calculate', + params: [{ name: 'delta', type: 'int' }], + defaultParams: [3], +}); + +addFuncDef({ + name: 'nPercentile', + category: 'Calculate', + params: [{ name: 'Nth percentile', type: 'int' }], + defaultParams: [95], +}); + +addFuncDef({ + name: 'diffSeries', + params: optionalSeriesRefArgs, + defaultParams: ['#A'], + category: 'Combine', +}); + +addFuncDef({ + name: 'stddevSeries', + params: optionalSeriesRefArgs, + defaultParams: [''], + category: 'Combine', +}); + +addFuncDef({ + name: 'divideSeries', + params: optionalSeriesRefArgs, + defaultParams: ['#A'], + category: 'Combine', +}); + +addFuncDef({ + name: 'multiplySeries', + params: optionalSeriesRefArgs, + defaultParams: ['#A'], + category: 'Combine', +}); + +addFuncDef({ + name: 'asPercent', + params: optionalSeriesRefArgs, + defaultParams: ['#A'], + category: 'Combine', +}); + +addFuncDef({ + name: 'group', + params: optionalSeriesRefArgs, + defaultParams: ['#A', '#B'], + category: 'Combine', +}); + +addFuncDef({ + name: 'sumSeries', + shortName: 'sum', + category: 'Combine', + params: optionalSeriesRefArgs, + defaultParams: [''], +}); + +addFuncDef({ + name: 'averageSeries', + shortName: 'avg', + category: 'Combine', + params: optionalSeriesRefArgs, + defaultParams: [''], +}); + +addFuncDef({ + name: 'rangeOfSeries', + category: 'Combine', +}); + +addFuncDef({ + name: 'percentileOfSeries', + category: 'Combine', + params: [ + { name: 'n', type: 'int' }, + { name: 'interpolate', type: 'boolean', options: ['true', 'false'] }, + ], + defaultParams: [95, 'false'], +}); + +addFuncDef({ + name: 'sumSeriesWithWildcards', + category: 'Combine', + params: [{ name: 'node', type: 'int', multiple: true }], + defaultParams: [3], +}); + +addFuncDef({ + name: 'maxSeries', + shortName: 'max', + category: 'Combine', +}); + +addFuncDef({ + name: 'minSeries', + shortName: 'min', + category: 'Combine', +}); + +addFuncDef({ + name: 'averageSeriesWithWildcards', + category: 'Combine', + params: [{ name: 'node', type: 'int', multiple: true }], + defaultParams: [3], +}); + +addFuncDef({ + name: 'alias', + category: 'Alias', + params: [{ name: 'alias', type: 'string' }], + defaultParams: ['alias'], +}); + +addFuncDef({ + name: 'aliasSub', + category: 'Alias', + params: [ + { name: 'search', type: 'string' }, + { name: 'replace', type: 'string' }, + ], + defaultParams: ['', '\\1'], +}); + +addFuncDef({ + name: 'consolidateBy', + category: 'Special', + params: [ + { + name: 'function', + type: 'string', + options: ['sum', 'average', 'min', 'max'], + }, + ], + defaultParams: ['max'], +}); + +addFuncDef({ + name: 'cumulative', + category: 'Special', + params: [], + defaultParams: [], +}); + +addFuncDef({ + name: 'groupByNode', + category: 'Combine', + params: [ + { + name: 'node', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + }, + { + name: 'function', + type: 'string', + options: ['sum', 'avg', 'maxSeries'], + }, + ], + defaultParams: [3, 'sum'], +}); + +addFuncDef({ + name: 'aliasByNode', + category: 'Alias', + params: [ + { + name: 'node', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + multiple: true, + }, + ], + defaultParams: [3], +}); + +addFuncDef({ + name: 'substr', + category: 'Special', + params: [ + { + name: 'start', + type: 'int', + options: [-6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + }, + { + name: 'stop', + type: 'int', + options: [-6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + }, + ], + defaultParams: [0, 0], +}); + +addFuncDef({ + name: 'sortByName', + category: 'Sorting', + params: [ + { + name: 'natural', + type: 'boolean', + options: ['true', 'false'], + optional: true, + }, + ], + defaultParams: ['false'], +}); + +addFuncDef({ + name: 'sortByMaxima', + category: 'Sorting', +}); + +addFuncDef({ + name: 'sortByMinima', + category: 'Sorting', +}); + +addFuncDef({ + name: 'sortByTotal', + category: 'Sorting', +}); + +addFuncDef({ + name: 'aliasByMetric', + category: 'Alias', +}); + +addFuncDef({ + name: 'randomWalk', + fake: true, + category: 'Special', + params: [{ name: 'name', type: 'string' }], + defaultParams: ['randomWalk'], +}); + +addFuncDef({ + name: 'countSeries', + category: 'Combine', +}); + +addFuncDef({ + name: 'constantLine', + category: 'Special', + params: [{ name: 'value', type: 'int' }], + defaultParams: [10], +}); + +addFuncDef({ + name: 'cactiStyle', + category: 'Special', +}); + +addFuncDef({ + name: 'keepLastValue', + category: 'Transform', + params: [{ name: 'n', type: 'int' }], + defaultParams: [100], +}); + +addFuncDef({ + name: 'changed', + category: 'Special', + params: [], + defaultParams: [], +}); + +addFuncDef({ + name: 'scale', + category: 'Transform', + params: [{ name: 'factor', type: 'int' }], + defaultParams: [1], +}); + +addFuncDef({ + name: 'offset', + category: 'Transform', + params: [{ name: 'amount', type: 'int' }], + defaultParams: [10], +}); + +addFuncDef({ + name: 'transformNull', + category: 'Transform', + params: [{ name: 'amount', type: 'int' }], + defaultParams: [0], +}); + +addFuncDef({ + name: 'integral', + category: 'Transform', +}); + +addFuncDef({ + name: 'derivative', + category: 'Transform', +}); + +addFuncDef({ + name: 'nonNegativeDerivative', + category: 'Transform', + params: [{ name: 'max value or 0', type: 'int', optional: true }], + defaultParams: [''], +}); + +addFuncDef({ + name: 'timeShift', + category: 'Transform', + params: [ + { + name: 'amount', + type: 'select', + options: ['1h', '6h', '12h', '1d', '2d', '7d', '14d', '30d'], + }, + ], + defaultParams: ['1d'], +}); + +addFuncDef({ + name: 'timeStack', + category: 'Transform', + params: [ + { + name: 'timeShiftUnit', + type: 'select', + options: ['1h', '6h', '12h', '1d', '2d', '7d', '14d', '30d'], + }, + { name: 'timeShiftStart', type: 'int' }, + { name: 'timeShiftEnd', type: 'int' }, + ], + defaultParams: ['1d', 0, 7], +}); + +addFuncDef({ + name: 'summarize', + category: 'Transform', + params: [ + { name: 'interval', type: 'string' }, + { + name: 'func', + type: 'select', + options: ['sum', 'avg', 'min', 'max', 'last'], + }, + { + name: 'alignToFrom', + type: 'boolean', + optional: true, + options: ['false', 'true'], + }, + ], + defaultParams: ['1h', 'sum', 'false'], +}); + +addFuncDef({ + name: 'smartSummarize', + category: 'Transform', + params: [ + { name: 'interval', type: 'string' }, + { + name: 'func', + type: 'select', + options: ['sum', 'avg', 'min', 'max', 'last'], + }, + ], + defaultParams: ['1h', 'sum'], +}); + +addFuncDef({ + name: 'absolute', + category: 'Transform', +}); + +addFuncDef({ + name: 'hitcount', + category: 'Transform', + params: [{ name: 'interval', type: 'string' }], + defaultParams: ['10s'], +}); + +addFuncDef({ + name: 'log', + category: 'Transform', + params: [{ name: 'base', type: 'int' }], + defaultParams: ['10'], +}); + +addFuncDef({ + name: 'averageAbove', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [25], +}); + +addFuncDef({ + name: 'averageBelow', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [25], +}); + +addFuncDef({ + name: 'currentAbove', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [25], +}); + +addFuncDef({ + name: 'currentBelow', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [25], +}); + +addFuncDef({ + name: 'maximumAbove', + category: 'Filter Series', + params: [{ name: 'value', type: 'int' }], + defaultParams: [0], +}); + +addFuncDef({ + name: 'maximumBelow', + category: 'Filter Series', + params: [{ name: 'value', type: 'int' }], + defaultParams: [0], +}); + +addFuncDef({ + name: 'minimumAbove', + category: 'Filter Series', + params: [{ name: 'value', type: 'int' }], + defaultParams: [0], +}); + +addFuncDef({ + name: 'minimumBelow', + category: 'Filter Series', + params: [{ name: 'value', type: 'int' }], + defaultParams: [0], +}); + +addFuncDef({ + name: 'limit', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'mostDeviant', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [10], +}); + +addFuncDef({ + name: 'exclude', + category: 'Filter Series', + params: [{ name: 'exclude', type: 'string' }], + defaultParams: ['exclude'], +}); + +addFuncDef({ + name: 'highestCurrent', + category: 'Filter Series', + params: [{ name: 'count', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'highestMax', + category: 'Filter Series', + params: [{ name: 'count', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'lowestCurrent', + category: 'Filter Series', + params: [{ name: 'count', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'movingAverage', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: [10], +}); + +addFuncDef({ + name: 'movingMedian', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: ['5'], +}); + +addFuncDef({ + name: 'stdev', + category: 'Calculate', + params: [ + { name: 'n', type: 'int' }, + { name: 'tolerance', type: 'int' }, + ], + defaultParams: [5, 0.1], +}); + +addFuncDef({ + name: 'highestAverage', + category: 'Filter Series', + params: [{ name: 'count', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'lowestAverage', + category: 'Filter Series', + params: [{ name: 'count', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'removeAbovePercentile', + category: 'Filter Data', + params: [{ name: 'n', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'removeAboveValue', + category: 'Filter Data', + params: [{ name: 'n', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'removeBelowPercentile', + category: 'Filter Data', + params: [{ name: 'n', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'removeBelowValue', + category: 'Filter Data', + params: [{ name: 'n', type: 'int' }], + defaultParams: [5], +}); + +addFuncDef({ + name: 'useSeriesAbove', + category: 'Filter Series', + params: [ + { name: 'value', type: 'int' }, + { name: 'search', type: 'string' }, + { name: 'replace', type: 'string' }, + ], + defaultParams: [0, 'search', 'replace'], +}); + +//////////////////// +// Graphite 1.0.x // +//////////////////// + +addFuncDef({ + name: 'aggregateLine', + category: 'Calculate', + params: [ + { + name: 'func', + type: 'select', + options: ['sum', 'avg', 'min', 'max', 'last'], + }, + ], + defaultParams: ['avg'], + version: '1.0', +}); + +addFuncDef({ + name: 'averageOutsidePercentile', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [95], + version: '1.0', +}); + +addFuncDef({ + name: 'delay', + category: 'Transform', + params: [{ name: 'steps', type: 'int' }], + defaultParams: [1], + version: '1.0', +}); + +addFuncDef({ + name: 'exponentialMovingAverage', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: [10], + version: '1.0', +}); + +addFuncDef({ + name: 'fallbackSeries', + category: 'Special', + params: [{ name: 'fallback', type: 'string' }], + defaultParams: ['constantLine(0)'], + version: '1.0', +}); + +addFuncDef({ + name: 'grep', + category: 'Filter Series', + params: [{ name: 'grep', type: 'string' }], + defaultParams: ['grep'], + version: '1.0', +}); + +addFuncDef({ + name: 'groupByNodes', + category: 'Combine', + params: [ + { + name: 'function', + type: 'string', + options: ['sum', 'avg', 'maxSeries'], + }, + { + name: 'node', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + multiple: true, + }, + ], + defaultParams: ['sum', 3], + version: '1.0', +}); + +addFuncDef({ + name: 'integralByInterval', + category: 'Transform', + params: [ + { + name: 'intervalUnit', + type: 'select', + options: ['1h', '6h', '12h', '1d', '2d', '7d', '14d', '30d'], + }, + ], + defaultParams: ['1d'], + version: '1.0', +}); + +addFuncDef({ + name: 'interpolate', + category: 'Transform', + params: [{ name: 'limit', type: 'int', optional: true }], + defaultParams: [], + version: '1.0', +}); + +addFuncDef({ + name: 'invert', + category: 'Transform', + version: '1.0', +}); + +addFuncDef({ + name: 'isNonNull', + category: 'Combine', + version: '1.0', +}); + +addFuncDef({ + name: 'linearRegression', + category: 'Calculate', + params: [ + { + name: 'startSourceAt', + type: 'select', + options: ['-1h', '-6h', '-12h', '-1d', '-2d', '-7d', '-14d', '-30d'], + optional: true, + }, + { + name: 'endSourceAt', + type: 'select', + options: ['-1h', '-6h', '-12h', '-1d', '-2d', '-7d', '-14d', '-30d'], + optional: true, + }, + ], + defaultParams: [], + version: '1.0', +}); + +addFuncDef({ + name: 'mapSeries', + shortName: 'map', + params: [{ name: 'node', type: 'int' }], + defaultParams: [3], + category: 'Combine', + version: '1.0', +}); + +addFuncDef({ + name: 'movingMin', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: [10], + version: '1.0', +}); + +addFuncDef({ + name: 'movingMax', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: [10], + version: '1.0', +}); + +addFuncDef({ + name: 'movingSum', + category: 'Calculate', + params: [ + { + name: 'windowSize', + type: 'int_or_interval', + options: ['5', '7', '10', '5min', '10min', '30min', '1hour'], + }, + ], + defaultParams: [10], + version: '1.0', +}); + +addFuncDef({ + name: 'multiplySeriesWithWildcards', + category: 'Combine', + params: [ + { + name: 'position', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + multiple: true, + }, + ], + defaultParams: [2], + version: '1.0', +}); + +addFuncDef({ + name: 'offsetToZero', + category: 'Transform', + version: '1.0', +}); + +addFuncDef({ + name: 'pow', + category: 'Transform', + params: [{ name: 'factor', type: 'int' }], + defaultParams: [10], + version: '1.0', +}); + +addFuncDef({ + name: 'powSeries', + category: 'Transform', + params: optionalSeriesRefArgs, + defaultParams: [''], + version: '1.0', +}); + +addFuncDef({ + name: 'reduceSeries', + shortName: 'reduce', + params: [ + { + name: 'function', + type: 'string', + options: ['asPercent', 'diffSeries', 'divideSeries'], + }, + { + name: 'reduceNode', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { name: 'reduceMatchers', type: 'string', multiple: true }, + ], + defaultParams: ['asPercent', 2, 'used_bytes'], + category: 'Combine', + version: '1.0', +}); + +addFuncDef({ + name: 'removeBetweenPercentile', + category: 'Filter Series', + params: [{ name: 'n', type: 'int' }], + defaultParams: [95], + version: '1.0', +}); + +addFuncDef({ + name: 'removeEmptySeries', + category: 'Filter Series', + version: '1.0', +}); + +addFuncDef({ + name: 'squareRoot', + category: 'Transform', + version: '1.0', +}); + +addFuncDef({ + name: 'timeSlice', + category: 'Transform', + params: [ + { + name: 'startSliceAt', + type: 'select', + options: ['-1h', '-6h', '-12h', '-1d', '-2d', '-7d', '-14d', '-30d'], + }, + { + name: 'endSliceAt', + type: 'select', + options: ['-1h', '-6h', '-12h', '-1d', '-2d', '-7d', '-14d', '-30d'], + optional: true, + }, + ], + defaultParams: ['-1h'], + version: '1.0', +}); + +addFuncDef({ + name: 'weightedAverage', + category: 'Combine', + params: [ + { name: 'other', type: 'value_or_series', optional: true }, + { + name: 'node', + type: 'int', + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12], + }, + ], + defaultParams: ['#A', 4], + version: '1.0', +}); + +addFuncDef({ + name: 'seriesByTag', + category: 'Special', + params: [{ name: 'tagExpression', type: 'string', multiple: true }], + version: '1.1', +}); + +addFuncDef({ + name: 'groupByTags', + category: 'Combine', + params: [ + { + name: 'function', + type: 'string', + options: ['sum', 'avg', 'maxSeries'], + }, + { name: 'tag', type: 'string', multiple: true }, + ], + defaultParams: ['sum', 'tag'], + version: '1.1', +}); + +addFuncDef({ + name: 'aliasByTags', + category: 'Alias', + params: [{ name: 'tag', type: 'string', multiple: true }], + defaultParams: ['tag'], + version: '1.1', +}); + +function isVersionRelatedFunction(obj: { version: string }, graphiteVersion: string) { + return !obj.version || isVersionGtOrEq(graphiteVersion, obj.version); +} + +export class FuncInstance { + def: any; + params: any; + text: any; + added: boolean; + + constructor(funcDef: any, options?: { withDefaultParams: any }) { + this.def = funcDef; + this.params = []; + + if (options && options.withDefaultParams) { + this.params = funcDef.defaultParams.slice(0); + } + + this.updateText(); + } + + render(metricExp: string, replaceVariables: InterpolateFunction): string { + const str = this.def.name + '('; + + const parameters = _.map(this.params, (value, index) => { + let paramType; + + if (index < this.def.params.length) { + paramType = this.def.params[index].type; + } else if (_.get(_.last(this.def.params), 'multiple')) { + paramType = _.get(_.last(this.def.params), 'type'); + } + + // param types that should never be quoted + if (_.includes(['value_or_series', 'boolean', 'int', 'float', 'node'], paramType)) { + return value; + } + + const valueInterpolated = _.isString(value) ? replaceVariables(value) : value; + + // param types that might be quoted + // To quote variables correctly we need to interpolate it to check if it contains a numeric or string value + if (_.includes(['int_or_interval', 'node_or_tag'], paramType) && _.isFinite(+valueInterpolated)) { + return _.toString(value); + } + + return "'" + value + "'"; + }); + + // don't send any blank parameters to graphite + while (parameters[parameters.length - 1] === '') { + parameters.pop(); + } + + if (metricExp) { + parameters.unshift(metricExp); + } + + return str + parameters.join(', ') + ')'; + } + + _hasMultipleParamsInString(strValue: any, index: number) { + if (strValue.indexOf(',') === -1) { + return false; + } + + if (this.def.params[index + 1] && this.def.params[index + 1].optional) { + return true; + } + + if (index + 1 >= this.def.params.length && _.get(_.last(this.def.params), 'multiple')) { + return true; + } + + return false; + } + + updateParam(strValue: any, index: any) { + // handle optional parameters + // if string contains ',' and next param is optional, split and update both + if (this._hasMultipleParamsInString(strValue, index)) { + _.each(strValue.split(','), (partVal, idx) => { + this.updateParam(partVal.trim(), index + idx); + }); + return; + } + + if (strValue === '' && (index >= this.def.params.length || this.def.params[index].optional)) { + this.params.splice(index, 1); + } else { + this.params[index] = strValue; + } + + this.updateText(); + } + + updateText() { + if (this.params.length === 0) { + this.text = this.def.name + '()'; + return; + } + + let text = this.def.name + '('; + text += this.params.join(', '); + text += ')'; + this.text = text; + } +} + +function createFuncInstance(funcDef: any, options?: { withDefaultParams: any }, idx?: any) { + if (_.isString(funcDef)) { + funcDef = getFuncDef(funcDef, idx); + } + return new FuncInstance(funcDef, options); +} + +function getFuncDef(name: string, idx?: any) { + if (!(idx || index)[name]) { + throw { message: 'Method not found ' + name }; + } + return (idx || index)[name]; +} + +function getFuncDefs(graphiteVersion: string, idx?: any) { + const funcs: any = {}; + _.forEach(idx || index, (funcDef) => { + if (isVersionRelatedFunction(funcDef, graphiteVersion)) { + funcs[funcDef.name] = _.assign({}, funcDef, { + params: _.filter(funcDef.params, (param) => { + return isVersionRelatedFunction(param, graphiteVersion); + }), + }); + } + }); + return funcs; +} + +// parse response from graphite /functions endpoint into internal format +function parseFuncDefs(rawDefs: any) { + const funcDefs: any = {}; + + _.forEach(rawDefs || {}, (funcDef, funcName) => { + // skip graphite graph functions + if (funcDef.group === 'Graph') { + return; + } + + let description = funcDef.description; + if (description) { + // tidy up some pydoc syntax that rst2html can't handle + description = description + .replace(/:py:func:`(.+)( <[^>]*>)?`/g, '``$1``') + .replace(/.. seealso:: /g, 'See also: ') + .replace(/.. code-block *:: *none/g, '.. code-block::'); + } + + const func: FuncDef = { + name: funcDef.name, + description, + category: funcDef.group, + params: [], + defaultParams: [], + fake: false, + }; + + // get rid of the first "seriesList" param + if (/^seriesLists?$/.test(_.get(funcDef, 'params[0].type', ''))) { + // handle functions that accept multiple seriesLists + // we leave the param in place but mark it optional, so users can add more series if they wish + if (funcDef.params[0].multiple) { + funcDef.params[0].required = false; + // otherwise chop off the first param, it'll be handled separately + } else { + funcDef.params.shift(); + } + // tag function as fake + } else { + func.fake = true; + } + + _.forEach(funcDef.params, (rawParam) => { + const param: any = { + name: rawParam.name, + type: 'string', + optional: !rawParam.required, + multiple: !!rawParam.multiple, + options: undefined, + }; + + if (rawParam.default !== undefined) { + func.defaultParams.push(_.toString(rawParam.default)); + } else if (rawParam.suggestions) { + func.defaultParams.push(_.toString(rawParam.suggestions[0])); + } else { + func.defaultParams.push(''); + } + + if (rawParam.type === 'boolean') { + param.type = 'boolean'; + param.options = ['true', 'false']; + } else if (rawParam.type === 'integer') { + param.type = 'int'; + } else if (rawParam.type === 'float') { + param.type = 'float'; + } else if (rawParam.type === 'node') { + param.type = 'node'; + param.options = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']; + } else if (rawParam.type === 'nodeOrTag') { + param.type = 'node_or_tag'; + param.options = ['name', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']; + } else if (rawParam.type === 'intOrInterval') { + param.type = 'int_or_interval'; + } else if (rawParam.type === 'seriesList') { + param.type = 'value_or_series'; + } + + if (rawParam.options) { + param.options = _.map(rawParam.options, _.toString); + } else if (rawParam.suggestions) { + param.options = _.map(rawParam.suggestions, _.toString); + } + + func.params.push(param); + }); + + funcDefs[funcName] = func; + }); + + return funcDefs; +} + +export default { + createFuncInstance: createFuncInstance, + getFuncDef: getFuncDef, + getFuncDefs: getFuncDefs, + parseFuncDefs: parseFuncDefs, +}; diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts new file mode 100644 index 0000000..b491b68 --- /dev/null +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -0,0 +1,303 @@ +import _ from 'lodash'; +import { arrayMove } from 'app/core/utils/arrayMove'; +import { Parser } from './parser'; +import { TemplateSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +export default class GraphiteQuery { + datasource: any; + target: any; + functions: any[]; + segments: any[]; + tags: any[]; + error: any; + seriesByTagUsed: boolean; + checkOtherSegmentsIndex: number; + removeTagValue: string; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(datasource: any, target: any, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { + this.datasource = datasource; + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + this.parseTarget(); + + this.removeTagValue = '-- remove tag --'; + } + + parseTarget() { + this.functions = []; + this.segments = []; + this.tags = []; + this.seriesByTagUsed = false; + this.error = null; + + if (this.target.textEditor) { + return; + } + + const parser = new Parser(this.target.target); + const astNode = parser.getAst(); + if (astNode === null) { + this.checkOtherSegmentsIndex = 0; + return; + } + + if (astNode.type === 'error') { + this.error = astNode.message + ' at position: ' + astNode.pos; + this.target.textEditor = true; + return; + } + + try { + this.parseTargetRecursive(astNode, null); + } catch (err) { + console.error('error parsing target:', err.message); + this.error = err.message; + this.target.textEditor = true; + } + + this.checkOtherSegmentsIndex = this.segments.length - 1; + } + + getSegmentPathUpTo(index: number) { + const arr = this.segments.slice(0, index); + + return _.reduce( + arr, + (result, segment) => { + return result ? result + '.' + segment.value : segment.value; + }, + '' + ); + } + + parseTargetRecursive(astNode: any, func: any): any { + if (astNode === null) { + return null; + } + + switch (astNode.type) { + case 'function': + const innerFunc = this.datasource.createFuncInstance(astNode.name, { + withDefaultParams: false, + }); + _.each(astNode.params, (param) => { + this.parseTargetRecursive(param, innerFunc); + }); + + innerFunc.updateText(); + this.functions.push(innerFunc); + + // extract tags from seriesByTag function and hide function + if (innerFunc.def.name === 'seriesByTag' && !this.seriesByTagUsed) { + this.seriesByTagUsed = true; + innerFunc.hidden = true; + this.tags = this.splitSeriesByTagParams(innerFunc); + } + + break; + case 'series-ref': + if (this.segments.length > 0 || this.getSeriesByTagFuncIndex() >= 0) { + this.addFunctionParameter(func, astNode.value); + } else { + this.segments.push(astNode); + } + break; + case 'bool': + case 'string': + case 'number': + this.addFunctionParameter(func, astNode.value); + break; + case 'metric': + if (this.segments.length || this.tags.length) { + this.addFunctionParameter(func, _.join(_.map(astNode.segments, 'value'), '.')); + } else { + this.segments = astNode.segments; + } + break; + } + } + + updateSegmentValue(segment: any, index: number) { + this.segments[index].value = segment.value; + } + + addSelectMetricSegment() { + this.segments.push({ value: 'select metric' }); + } + + addFunction(newFunc: any) { + this.functions.push(newFunc); + } + + addFunctionParameter(func: any, value: string) { + if (func.params.length >= func.def.params.length && !_.get(_.last(func.def.params), 'multiple', false)) { + throw { message: 'too many parameters for function ' + func.def.name }; + } + func.params.push(value); + } + + removeFunction(func: any) { + this.functions = _.without(this.functions, func); + } + + moveFunction(func: any, offset: number) { + const index = this.functions.indexOf(func); + arrayMove(this.functions, index, index + offset); + } + + updateModelTarget(targets: any) { + const wrapFunction = (target: string, func: any) => { + return func.render(target, (value: string) => { + return this.templateSrv.replace(value, this.scopedVars); + }); + }; + + if (!this.target.textEditor) { + const metricPath = this.getSegmentPathUpTo(this.segments.length).replace(/\.select metric$/, ''); + this.target.target = _.reduce(this.functions, wrapFunction, metricPath); + } + + this.updateRenderedTarget(this.target, targets); + + // loop through other queries and update targetFull as needed + for (const target of targets || []) { + if (target.refId !== this.target.refId) { + this.updateRenderedTarget(target, targets); + } + } + } + + updateRenderedTarget(target: { refId: string | number; target: any; targetFull: any }, targets: any) { + // render nested query + const targetsByRefId = _.keyBy(targets, 'refId'); + + // no references to self + delete targetsByRefId[target.refId]; + + const nestedSeriesRefRegex = /\#([A-Z])/g; + let targetWithNestedQueries = target.target; + + // Use ref count to track circular references + function countTargetRefs(targetsByRefId: any, refId: string) { + let refCount = 0; + _.each(targetsByRefId, (t, id) => { + if (id !== refId) { + const match = nestedSeriesRefRegex.exec(t.target); + const count = match && match.length ? match.length - 1 : 0; + refCount += count; + } + }); + targetsByRefId[refId].refCount = refCount; + } + _.each(targetsByRefId, (t, id) => { + countTargetRefs(targetsByRefId, id); + }); + + // Keep interpolating until there are no query references + // The reason for the loop is that the referenced query might contain another reference to another query + while (targetWithNestedQueries.match(nestedSeriesRefRegex)) { + const updated = targetWithNestedQueries.replace(nestedSeriesRefRegex, (match: string, g1: string) => { + const t = targetsByRefId[g1]; + if (!t) { + return match; + } + + // no circular references + if (t.refCount === 0) { + delete targetsByRefId[g1]; + } + t.refCount--; + + return t.target; + }); + + if (updated === targetWithNestedQueries) { + break; + } + + targetWithNestedQueries = updated; + } + + delete target.targetFull; + if (target.target !== targetWithNestedQueries) { + target.targetFull = targetWithNestedQueries; + } + } + + splitSeriesByTagParams(func: { params: any }) { + const tagPattern = /([^\!=~]+)(\!?=~?)(.*)/; + return _.flatten( + _.map(func.params, (param: string) => { + const matches = tagPattern.exec(param); + if (matches) { + const tag = matches.slice(1); + if (tag.length === 3) { + return { + key: tag[0], + operator: tag[1], + value: tag[2], + }; + } + } + return []; + }) + ); + } + + getSeriesByTagFuncIndex() { + return _.findIndex(this.functions, (func) => func.def.name === 'seriesByTag'); + } + + getSeriesByTagFunc() { + const seriesByTagFuncIndex = this.getSeriesByTagFuncIndex(); + if (seriesByTagFuncIndex >= 0) { + return this.functions[seriesByTagFuncIndex]; + } else { + return undefined; + } + } + + addTag(tag: { key: any; operator: string; value: string }) { + const newTagParam = renderTagString(tag); + this.getSeriesByTagFunc().params.push(newTagParam); + this.tags.push(tag); + } + + removeTag(index: number) { + this.getSeriesByTagFunc().params.splice(index, 1); + this.tags.splice(index, 1); + } + + updateTag(tag: { key: string }, tagIndex: number) { + this.error = null; + + if (tag.key === this.removeTagValue) { + this.removeTag(tagIndex); + return; + } + + const newTagParam = renderTagString(tag); + this.getSeriesByTagFunc().params[tagIndex] = newTagParam; + this.tags[tagIndex] = tag; + } + + renderTagExpressions(excludeIndex = -1) { + return _.compact( + _.map(this.tags, (tagExpr, index) => { + // Don't render tag that we want to lookup + if (index !== excludeIndex) { + return tagExpr.key + tagExpr.operator + tagExpr.value; + } + }) + ); + } +} + +function renderTagString(tag: { key: any; operator?: any; value?: any }) { + return tag.key + tag.operator + tag.value; +} diff --git a/public/app/plugins/datasource/graphite/img/graphite_logo.png b/public/app/plugins/datasource/graphite/img/graphite_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9a56f072db327c4c688b90ad3a4ba18bf12eeb GIT binary patch literal 4451 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 literal 0 HcmV?d00001 diff --git a/public/app/plugins/datasource/graphite/lexer.ts b/public/app/plugins/datasource/graphite/lexer.ts new file mode 100644 index 0000000..57609bc --- /dev/null +++ b/public/app/plugins/datasource/graphite/lexer.ts @@ -0,0 +1,1455 @@ +import _ from 'lodash'; + +// This is auto generated from the unicode tables. +// The tables are at: +// http://www.fileformat.info/info/unicode/category/Lu/list.htm +// http://www.fileformat.info/info/unicode/category/Ll/list.htm +// http://www.fileformat.info/info/unicode/category/Lt/list.htm +// http://www.fileformat.info/info/unicode/category/Lm/list.htm +// http://www.fileformat.info/info/unicode/category/Lo/list.htm +// http://www.fileformat.info/info/unicode/category/Nl/list.htm + +const unicodeLetterTable = [ + 170, + 170, + 181, + 181, + 186, + 186, + 192, + 214, + 216, + 246, + 248, + 705, + 710, + 721, + 736, + 740, + 748, + 748, + 750, + 750, + 880, + 884, + 886, + 887, + 890, + 893, + 902, + 902, + 904, + 906, + 908, + 908, + 910, + 929, + 931, + 1013, + 1015, + 1153, + 1162, + 1319, + 1329, + 1366, + 1369, + 1369, + 1377, + 1415, + 1488, + 1514, + 1520, + 1522, + 1568, + 1610, + 1646, + 1647, + 1649, + 1747, + 1749, + 1749, + 1765, + 1766, + 1774, + 1775, + 1786, + 1788, + 1791, + 1791, + 1808, + 1808, + 1810, + 1839, + 1869, + 1957, + 1969, + 1969, + 1994, + 2026, + 2036, + 2037, + 2042, + 2042, + 2048, + 2069, + 2074, + 2074, + 2084, + 2084, + 2088, + 2088, + 2112, + 2136, + 2308, + 2361, + 2365, + 2365, + 2384, + 2384, + 2392, + 2401, + 2417, + 2423, + 2425, + 2431, + 2437, + 2444, + 2447, + 2448, + 2451, + 2472, + 2474, + 2480, + 2482, + 2482, + 2486, + 2489, + 2493, + 2493, + 2510, + 2510, + 2524, + 2525, + 2527, + 2529, + 2544, + 2545, + 2565, + 2570, + 2575, + 2576, + 2579, + 2600, + 2602, + 2608, + 2610, + 2611, + 2613, + 2614, + 2616, + 2617, + 2649, + 2652, + 2654, + 2654, + 2674, + 2676, + 2693, + 2701, + 2703, + 2705, + 2707, + 2728, + 2730, + 2736, + 2738, + 2739, + 2741, + 2745, + 2749, + 2749, + 2768, + 2768, + 2784, + 2785, + 2821, + 2828, + 2831, + 2832, + 2835, + 2856, + 2858, + 2864, + 2866, + 2867, + 2869, + 2873, + 2877, + 2877, + 2908, + 2909, + 2911, + 2913, + 2929, + 2929, + 2947, + 2947, + 2949, + 2954, + 2958, + 2960, + 2962, + 2965, + 2969, + 2970, + 2972, + 2972, + 2974, + 2975, + 2979, + 2980, + 2984, + 2986, + 2990, + 3001, + 3024, + 3024, + 3077, + 3084, + 3086, + 3088, + 3090, + 3112, + 3114, + 3123, + 3125, + 3129, + 3133, + 3133, + 3160, + 3161, + 3168, + 3169, + 3205, + 3212, + 3214, + 3216, + 3218, + 3240, + 3242, + 3251, + 3253, + 3257, + 3261, + 3261, + 3294, + 3294, + 3296, + 3297, + 3313, + 3314, + 3333, + 3340, + 3342, + 3344, + 3346, + 3386, + 3389, + 3389, + 3406, + 3406, + 3424, + 3425, + 3450, + 3455, + 3461, + 3478, + 3482, + 3505, + 3507, + 3515, + 3517, + 3517, + 3520, + 3526, + 3585, + 3632, + 3634, + 3635, + 3648, + 3654, + 3713, + 3714, + 3716, + 3716, + 3719, + 3720, + 3722, + 3722, + 3725, + 3725, + 3732, + 3735, + 3737, + 3743, + 3745, + 3747, + 3749, + 3749, + 3751, + 3751, + 3754, + 3755, + 3757, + 3760, + 3762, + 3763, + 3773, + 3773, + 3776, + 3780, + 3782, + 3782, + 3804, + 3805, + 3840, + 3840, + 3904, + 3911, + 3913, + 3948, + 3976, + 3980, + 4096, + 4138, + 4159, + 4159, + 4176, + 4181, + 4186, + 4189, + 4193, + 4193, + 4197, + 4198, + 4206, + 4208, + 4213, + 4225, + 4238, + 4238, + 4256, + 4293, + 4304, + 4346, + 4348, + 4348, + 4352, + 4680, + 4682, + 4685, + 4688, + 4694, + 4696, + 4696, + 4698, + 4701, + 4704, + 4744, + 4746, + 4749, + 4752, + 4784, + 4786, + 4789, + 4792, + 4798, + 4800, + 4800, + 4802, + 4805, + 4808, + 4822, + 4824, + 4880, + 4882, + 4885, + 4888, + 4954, + 4992, + 5007, + 5024, + 5108, + 5121, + 5740, + 5743, + 5759, + 5761, + 5786, + 5792, + 5866, + 5870, + 5872, + 5888, + 5900, + 5902, + 5905, + 5920, + 5937, + 5952, + 5969, + 5984, + 5996, + 5998, + 6000, + 6016, + 6067, + 6103, + 6103, + 6108, + 6108, + 6176, + 6263, + 6272, + 6312, + 6314, + 6314, + 6320, + 6389, + 6400, + 6428, + 6480, + 6509, + 6512, + 6516, + 6528, + 6571, + 6593, + 6599, + 6656, + 6678, + 6688, + 6740, + 6823, + 6823, + 6917, + 6963, + 6981, + 6987, + 7043, + 7072, + 7086, + 7087, + 7104, + 7141, + 7168, + 7203, + 7245, + 7247, + 7258, + 7293, + 7401, + 7404, + 7406, + 7409, + 7424, + 7615, + 7680, + 7957, + 7960, + 7965, + 7968, + 8005, + 8008, + 8013, + 8016, + 8023, + 8025, + 8025, + 8027, + 8027, + 8029, + 8029, + 8031, + 8061, + 8064, + 8116, + 8118, + 8124, + 8126, + 8126, + 8130, + 8132, + 8134, + 8140, + 8144, + 8147, + 8150, + 8155, + 8160, + 8172, + 8178, + 8180, + 8182, + 8188, + 8305, + 8305, + 8319, + 8319, + 8336, + 8348, + 8450, + 8450, + 8455, + 8455, + 8458, + 8467, + 8469, + 8469, + 8473, + 8477, + 8484, + 8484, + 8486, + 8486, + 8488, + 8488, + 8490, + 8493, + 8495, + 8505, + 8508, + 8511, + 8517, + 8521, + 8526, + 8526, + 8544, + 8584, + 11264, + 11310, + 11312, + 11358, + 11360, + 11492, + 11499, + 11502, + 11520, + 11557, + 11568, + 11621, + 11631, + 11631, + 11648, + 11670, + 11680, + 11686, + 11688, + 11694, + 11696, + 11702, + 11704, + 11710, + 11712, + 11718, + 11720, + 11726, + 11728, + 11734, + 11736, + 11742, + 11823, + 11823, + 12293, + 12295, + 12321, + 12329, + 12337, + 12341, + 12344, + 12348, + 12353, + 12438, + 12445, + 12447, + 12449, + 12538, + 12540, + 12543, + 12549, + 12589, + 12593, + 12686, + 12704, + 12730, + 12784, + 12799, + 13312, + 13312, + 19893, + 19893, + 19968, + 19968, + 40907, + 40907, + 40960, + 42124, + 42192, + 42237, + 42240, + 42508, + 42512, + 42527, + 42538, + 42539, + 42560, + 42606, + 42623, + 42647, + 42656, + 42735, + 42775, + 42783, + 42786, + 42888, + 42891, + 42894, + 42896, + 42897, + 42912, + 42921, + 43002, + 43009, + 43011, + 43013, + 43015, + 43018, + 43020, + 43042, + 43072, + 43123, + 43138, + 43187, + 43250, + 43255, + 43259, + 43259, + 43274, + 43301, + 43312, + 43334, + 43360, + 43388, + 43396, + 43442, + 43471, + 43471, + 43520, + 43560, + 43584, + 43586, + 43588, + 43595, + 43616, + 43638, + 43642, + 43642, + 43648, + 43695, + 43697, + 43697, + 43701, + 43702, + 43705, + 43709, + 43712, + 43712, + 43714, + 43714, + 43739, + 43741, + 43777, + 43782, + 43785, + 43790, + 43793, + 43798, + 43808, + 43814, + 43816, + 43822, + 43968, + 44002, + 44032, + 44032, + 55203, + 55203, + 55216, + 55238, + 55243, + 55291, + 63744, + 64045, + 64048, + 64109, + 64112, + 64217, + 64256, + 64262, + 64275, + 64279, + 64285, + 64285, + 64287, + 64296, + 64298, + 64310, + 64312, + 64316, + 64318, + 64318, + 64320, + 64321, + 64323, + 64324, + 64326, + 64433, + 64467, + 64829, + 64848, + 64911, + 64914, + 64967, + 65008, + 65019, + 65136, + 65140, + 65142, + 65276, + 65313, + 65338, + 65345, + 65370, + 65382, + 65470, + 65474, + 65479, + 65482, + 65487, + 65490, + 65495, + 65498, + 65500, + 65536, + 65547, + 65549, + 65574, + 65576, + 65594, + 65596, + 65597, + 65599, + 65613, + 65616, + 65629, + 65664, + 65786, + 65856, + 65908, + 66176, + 66204, + 66208, + 66256, + 66304, + 66334, + 66352, + 66378, + 66432, + 66461, + 66464, + 66499, + 66504, + 66511, + 66513, + 66517, + 66560, + 66717, + 67584, + 67589, + 67592, + 67592, + 67594, + 67637, + 67639, + 67640, + 67644, + 67644, + 67647, + 67669, + 67840, + 67861, + 67872, + 67897, + 68096, + 68096, + 68112, + 68115, + 68117, + 68119, + 68121, + 68147, + 68192, + 68220, + 68352, + 68405, + 68416, + 68437, + 68448, + 68466, + 68608, + 68680, + 69635, + 69687, + 69763, + 69807, + 73728, + 74606, + 74752, + 74850, + 77824, + 78894, + 92160, + 92728, + 110592, + 110593, + 119808, + 119892, + 119894, + 119964, + 119966, + 119967, + 119970, + 119970, + 119973, + 119974, + 119977, + 119980, + 119982, + 119993, + 119995, + 119995, + 119997, + 120003, + 120005, + 120069, + 120071, + 120074, + 120077, + 120084, + 120086, + 120092, + 120094, + 120121, + 120123, + 120126, + 120128, + 120132, + 120134, + 120134, + 120138, + 120144, + 120146, + 120485, + 120488, + 120512, + 120514, + 120538, + 120540, + 120570, + 120572, + 120596, + 120598, + 120628, + 120630, + 120654, + 120656, + 120686, + 120688, + 120712, + 120714, + 120744, + 120746, + 120770, + 120772, + 120779, + 131072, + 131072, + 173782, + 173782, + 173824, + 173824, + 177972, + 177972, + 177984, + 177984, + 178205, + 178205, + 194560, + 195101, +]; + +const identifierStartTable: any[] = []; + +for (let i = 0; i < 128; i++) { + identifierStartTable[i] = + (i >= 48 && i <= 57) || // 0-9 + i === 36 || // $ + i === 126 || // ~ + i === 124 || // | + (i >= 65 && i <= 90) || // A-Z + i === 95 || // _ + i === 45 || // - + i === 42 || // * + i === 58 || // : + i === 91 || // templateStart [ + i === 93 || // templateEnd ] + i === 63 || // ? + i === 37 || // % + i === 35 || // # + i === 61 || // = + (i >= 97 && i <= 122); // a-z +} + +const identifierPartTable = identifierStartTable; + +export class Lexer { + input: any; + char: number; + from: number; + + constructor(expression: any) { + this.input = expression; + this.char = 1; + this.from = 1; + } + + peek(i?: number) { + return this.input.charAt(i || 0); + } + + skip(i?: number) { + i = i || 1; + this.char += i; + this.input = this.input.slice(i); + } + + tokenize() { + const list = []; + let token = this.next(); + while (token) { + list.push(token); + token = this.next(); + } + return list; + } + + next() { + this.from = this.char; + + // Move to the next non-space character. + if (/\s/.test(this.peek())) { + while (/\s/.test(this.peek())) { + this.from += 1; + this.skip(); + } + + if (this.peek() === '') { + // EOL + return null; + } + } + + let match = this.scanStringLiteral(); + if (match) { + return match; + } + + match = this.scanPunctuator() || this.scanNumericLiteral() || this.scanIdentifier() || this.scanTemplateSequence(); + + if (match) { + this.skip(match.value.length); + return match; + } + + // No token could be matched, give up. + return null; + } + + scanTemplateSequence() { + if (this.peek() === '[' && this.peek(1) === '[') { + return { + type: 'templateStart', + value: '[[', + pos: this.char, + }; + } + + if (this.peek() === ']' && this.peek(1) === ']') { + return { + type: 'templateEnd', + value: '[[', + pos: this.char, + }; + } + + return null; + } + + /* + * Extract a JavaScript identifier out of the next sequence of + * characters or return 'null' if its not possible. In addition, + * to Identifier this method can also produce BooleanLiteral + * (true/false) and NullLiteral (null). + */ + scanIdentifier() { + let id = ''; + let index = 0; + let type, char; + + // Detects any character in the Unicode categories "Uppercase + // letter (Lu)", "Lowercase letter (Ll)", "Titlecase letter + // (Lt)", "Modifier letter (Lm)", "Other letter (Lo)", or + // "Letter number (Nl)". + // + // Both approach and unicodeLetterTable were borrowed from + // Google's Traceur. + + function isUnicodeLetter(code: number) { + for (let i = 0; i < unicodeLetterTable.length; ) { + if (code < unicodeLetterTable[i++]) { + return false; + } + + if (code <= unicodeLetterTable[i++]) { + return true; + } + } + + return false; + } + + function isHexDigit(str: string) { + return /^[0-9a-fA-F]$/.test(str); + } + + const readUnicodeEscapeSequence = _.bind(function (this: any) { + index += 1; + + if (this.peek(index) !== 'u') { + return null; + } + + const ch1 = this.peek(index + 1); + const ch2 = this.peek(index + 2); + const ch3 = this.peek(index + 3); + const ch4 = this.peek(index + 4); + let code; + + if (isHexDigit(ch1) && isHexDigit(ch2) && isHexDigit(ch3) && isHexDigit(ch4)) { + code = parseInt(ch1 + ch2 + ch3 + ch4, 16); + + if (isUnicodeLetter(code)) { + index += 5; + return '\\u' + ch1 + ch2 + ch3 + ch4; + } + + return null; + } + + return null; + }, this); + + const getIdentifierStart = _.bind(function (this: any) { + const chr = this.peek(index); + const code = chr.charCodeAt(0); + + if (chr === '*') { + index += 1; + return chr; + } + + if (code === 92) { + return readUnicodeEscapeSequence(); + } + + if (code < 128) { + if (identifierStartTable[code]) { + index += 1; + return chr; + } + + return null; + } + + if (isUnicodeLetter(code)) { + index += 1; + return chr; + } + + return null; + }, this); + + const getIdentifierPart = _.bind(function (this: any) { + const chr = this.peek(index); + const code = chr.charCodeAt(0); + + if (code === 92) { + return readUnicodeEscapeSequence(); + } + + if (code < 128) { + if (identifierPartTable[code]) { + index += 1; + return chr; + } + + return null; + } + + if (isUnicodeLetter(code)) { + index += 1; + return chr; + } + + return null; + }, this); + + char = getIdentifierStart(); + if (char === null) { + return null; + } + + id = char; + for (;;) { + char = getIdentifierPart(); + + if (char === null) { + break; + } + + id += char; + } + + switch (id) { + case 'true': { + type = 'bool'; + break; + } + case 'false': { + type = 'bool'; + break; + } + default: + type = 'identifier'; + } + + return { + type: type, + value: id, + pos: this.char, + }; + } + + /* + * Extract a numeric literal out of the next sequence of + * characters or return 'null' if its not possible. This method + * supports all numeric literals described in section 7.8.3 + * of the EcmaScript 5 specification. + * + * This method's implementation was heavily influenced by the + * scanNumericLiteral function in the Esprima parser's source code. + */ + scanNumericLiteral(): any { + let index = 0; + let value = ''; + const length = this.input.length; + let char = this.peek(index); + let bad; + + function isDecimalDigit(str: string) { + return /^[0-9]$/.test(str); + } + + function isOctalDigit(str: string) { + return /^[0-7]$/.test(str); + } + + function isHexDigit(str: string) { + return /^[0-9a-fA-F]$/.test(str); + } + + function isIdentifierStart(ch: string) { + return ch === '$' || ch === '_' || ch === '\\' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); + } + + // handle negative num literals + if (char === '-') { + value += char; + index += 1; + char = this.peek(index); + } + + // Numbers must start either with a decimal digit or a point. + if (char !== '.' && !isDecimalDigit(char)) { + return null; + } + + if (char !== '.') { + value += this.peek(index); + index += 1; + char = this.peek(index); + + if (value === '0') { + // Base-16 numbers. + if (char === 'x' || char === 'X') { + index += 1; + value += char; + + while (index < length) { + char = this.peek(index); + if (!isHexDigit(char)) { + break; + } + value += char; + index += 1; + } + + if (value.length <= 2) { + // 0x + return { + type: 'number', + value: value, + isMalformed: true, + pos: this.char, + }; + } + + if (index < length) { + char = this.peek(index); + if (isIdentifierStart(char)) { + return null; + } + } + + return { + type: 'number', + value: value, + base: 16, + isMalformed: false, + pos: this.char, + }; + } + + // Base-8 numbers. + if (isOctalDigit(char)) { + index += 1; + value += char; + bad = false; + + while (index < length) { + char = this.peek(index); + + // Numbers like '019' (note the 9) are not valid octals + // but we still parse them and mark as malformed. + + if (isDecimalDigit(char)) { + bad = true; + } + if (!isOctalDigit(char)) { + // if the char is a non punctuator then its not a valid number + if (!this.isPunctuator(char)) { + return null; + } + break; + } + value += char; + index += 1; + } + + if (index < length) { + char = this.peek(index); + if (isIdentifierStart(char)) { + return null; + } + } + + return { + type: 'number', + value: value, + base: 8, + isMalformed: bad, + }; + } + + // Decimal numbers that start with '0' such as '09' are illegal + // but we still parse them and return as malformed. + + if (isDecimalDigit(char)) { + index += 1; + value += char; + } + } + + while (index < length) { + char = this.peek(index); + if (!isDecimalDigit(char)) { + break; + } + value += char; + index += 1; + } + } + + // Decimal digits. + + if (char === '.') { + value += char; + index += 1; + + while (index < length) { + char = this.peek(index); + if (!isDecimalDigit(char)) { + break; + } + value += char; + index += 1; + } + } + + // Exponent part. + + if (char === 'e' || char === 'E') { + value += char; + index += 1; + char = this.peek(index); + + if (char === '+' || char === '-') { + value += this.peek(index); + index += 1; + } + + char = this.peek(index); + if (isDecimalDigit(char)) { + value += char; + index += 1; + + while (index < length) { + char = this.peek(index); + if (!isDecimalDigit(char)) { + break; + } + value += char; + index += 1; + } + } else { + return null; + } + } + + if (index < length) { + char = this.peek(index); + if (!this.isPunctuator(char)) { + return null; + } + } + + return { + type: 'number', + value: value, + base: 10, + pos: this.char, + isMalformed: !isFinite(+value), + }; + } + + isPunctuator(ch1: string) { + switch (ch1) { + case '.': + case '(': + case ')': + case ',': + case '{': + case '}': + return true; + } + + return false; + } + + scanPunctuator() { + const ch1 = this.peek(); + + if (this.isPunctuator(ch1)) { + return { + type: ch1, + value: ch1, + pos: this.char, + }; + } + + return null; + } + + /* + * Extract a string out of the next sequence of characters and/or + * lines or return 'null' if its not possible. Since strings can + * span across multiple lines this method has to move the char + * pointer. + * + * This method recognizes pseudo-multiline JavaScript strings: + * + * var str = "hello\ + * world"; + */ + scanStringLiteral() { + const quote = this.peek(); + + // String must start with a quote. + if (quote !== '"' && quote !== "'") { + return null; + } + + let value = ''; + + this.skip(); + + while (this.peek() !== quote) { + if (this.peek() === '') { + // End Of Line + return { + type: 'string', + value: value, + isUnclosed: true, + quote: quote, + pos: this.char, + }; + } + + const char = this.peek(); + const jump = 1; // A length of a jump, after we're done + // parsing this character. + + value += char; + this.skip(jump); + } + + this.skip(); + return { + type: 'string', + value: value, + isUnclosed: false, + quote: quote, + pos: this.char, + }; + } +} diff --git a/public/app/plugins/datasource/graphite/meta.test.ts b/public/app/plugins/datasource/graphite/meta.test.ts new file mode 100644 index 0000000..6473410 --- /dev/null +++ b/public/app/plugins/datasource/graphite/meta.test.ts @@ -0,0 +1,40 @@ +import { parseSchemaRetentions } from './meta'; + +describe('metadata parsing', () => { + it('should parse schema retentions', () => { + const retentions = '1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2'; + const info = parseSchemaRetentions(retentions); + expect(info).toMatchInlineSnapshot(` + Array [ + Object { + "chunkspan": "20min", + "interval": "1s", + "numchunks": 5, + "ready": 1542274085, + "retention": "35d", + }, + Object { + "chunkspan": "2h", + "interval": "1min", + "numchunks": 1, + "ready": true, + "retention": "38d", + }, + Object { + "chunkspan": "6h", + "interval": "10min", + "numchunks": 1, + "ready": true, + "retention": "120d", + }, + Object { + "chunkspan": "6h", + "interval": "2h", + "numchunks": 2, + "ready": undefined, + "retention": "2y", + }, + ] + `); + }); +}); diff --git a/public/app/plugins/datasource/graphite/meta.ts b/public/app/plugins/datasource/graphite/meta.ts new file mode 100644 index 0000000..6bd24bd --- /dev/null +++ b/public/app/plugins/datasource/graphite/meta.ts @@ -0,0 +1,86 @@ +import { MetricTankSeriesMeta } from './types'; +import { QueryResultMetaNotice } from '@grafana/data'; + +// https://github.com/grafana/metrictank/blob/master/scripts/config/storage-schemas.conf#L15-L46 + +export interface RetentionInfo { + interval: string; + retention?: string; + chunkspan?: string; + numchunks?: number; + ready?: boolean | number; // whether, or as of what data timestamp, the archive is ready for querying. +} + +function toInteger(val?: string): number | undefined { + if (val) { + return parseInt(val, 10); + } + return undefined; +} + +function toBooleanOrTimestamp(val?: string): number | boolean | undefined { + if (val) { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return parseInt(val, 10); + } + return undefined; +} + +export function getRollupNotice(metaList: MetricTankSeriesMeta[]): QueryResultMetaNotice | null { + for (const meta of metaList) { + const archiveIndex = meta['archive-read']; + + if (archiveIndex > 0) { + const schema = parseSchemaRetentions(meta['schema-retentions']); + const intervalString = schema[archiveIndex].interval; + const func = (meta['consolidator-normfetch'] ?? '').replace('Consolidator', ''); + + return { + text: `Data is rolled up, aggregated over ${intervalString} using ${func} function`, + severity: 'info', + inspect: 'meta', + }; + } + } + + return null; +} + +export function getRuntimeConsolidationNotice(metaList: MetricTankSeriesMeta[]): QueryResultMetaNotice | null { + for (const meta of metaList) { + const runtimeNr = meta['aggnum-rc']; + + if (runtimeNr > 0) { + const func = (meta['consolidator-rc'] ?? '').replace('Consolidator', ''); + + return { + text: `Data is runtime consolidated, ${runtimeNr} datapoints combined using ${func} function`, + severity: 'info', + inspect: 'meta', + }; + } + } + + return null; +} + +export function parseSchemaRetentions(spec: string): RetentionInfo[] { + if (!spec) { + return []; + } + return spec.split(',').map((str) => { + const vals = str.split(':'); + return { + interval: vals[0], + retention: vals[1], + chunkspan: vals[2], + numchunks: toInteger(vals[3]), + ready: toBooleanOrTimestamp(vals[4]), + }; + }); +} diff --git a/public/app/plugins/datasource/graphite/module.ts b/public/app/plugins/datasource/graphite/module.ts new file mode 100644 index 0000000..1740616 --- /dev/null +++ b/public/app/plugins/datasource/graphite/module.ts @@ -0,0 +1,15 @@ +import { GraphiteDatasource } from './datasource'; +import { GraphiteQueryCtrl } from './query_ctrl'; +import { DataSourcePlugin } from '@grafana/data'; +import { ConfigEditor } from './configuration/ConfigEditor'; +import { MetricTankMetaInspector } from './MetricTankMetaInspector'; + +class AnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; +} + +export const plugin = new DataSourcePlugin(GraphiteDatasource) + .setQueryCtrl(GraphiteQueryCtrl) + .setConfigEditor(ConfigEditor) + .setMetadataInspector(MetricTankMetaInspector) + .setAnnotationQueryCtrl(AnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/graphite/parser.ts b/public/app/plugins/datasource/graphite/parser.ts new file mode 100644 index 0000000..b276ee2 --- /dev/null +++ b/public/app/plugins/datasource/graphite/parser.ts @@ -0,0 +1,257 @@ +import { Lexer } from './lexer'; + +export class Parser { + expression: any; + lexer: Lexer; + tokens: any; + index: number; + + constructor(expression: any) { + this.expression = expression; + this.lexer = new Lexer(expression); + this.tokens = this.lexer.tokenize(); + this.index = 0; + } + + getAst() { + return this.start(); + } + + start() { + try { + return this.functionCall() || this.metricExpression(); + } catch (e) { + return { + type: 'error', + message: e.message, + pos: e.pos, + }; + } + } + + curlyBraceSegment() { + if (this.match('identifier', '{') || this.match('{')) { + let curlySegment = ''; + + while (!this.match('') && !this.match('}')) { + curlySegment += this.consumeToken().value; + } + + if (!this.match('}')) { + this.errorMark("Expected closing '}'"); + } + + curlySegment += this.consumeToken().value; + + // if curly segment is directly followed by identifier + // include it in the segment + if (this.match('identifier')) { + curlySegment += this.consumeToken().value; + } + + return { + type: 'segment', + value: curlySegment, + }; + } else { + return null; + } + } + + metricSegment() { + const curly = this.curlyBraceSegment(); + if (curly) { + return curly; + } + + if (this.match('identifier') || this.match('number')) { + // hack to handle float numbers in metric segments + const parts = this.consumeToken().value.split('.'); + if (parts.length === 2) { + this.tokens.splice(this.index, 0, { type: '.' }); + this.tokens.splice(this.index + 1, 0, { + type: 'number', + value: parts[1], + }); + } + + return { + type: 'segment', + value: parts[0], + }; + } + + if (!this.match('templateStart')) { + this.errorMark('Expected metric identifier'); + } + + this.consumeToken(); + + if (!this.match('identifier')) { + this.errorMark('Expected identifier after templateStart'); + } + + const node = { + type: 'template', + value: this.consumeToken().value, + }; + + if (!this.match('templateEnd')) { + this.errorMark('Expected templateEnd'); + } + + this.consumeToken(); + return node; + } + + metricExpression() { + if (!this.match('templateStart') && !this.match('identifier') && !this.match('number') && !this.match('{')) { + return null; + } + + const node: any = { + type: 'metric', + segments: [], + }; + + node.segments.push(this.metricSegment()); + + while (this.match('.')) { + this.consumeToken(); + + const segment = this.metricSegment(); + if (!segment) { + this.errorMark('Expected metric identifier'); + } + + node.segments.push(segment); + } + + return node; + } + + functionCall() { + if (!this.match('identifier', '(')) { + return null; + } + + const node: any = { + type: 'function', + name: this.consumeToken().value, + }; + + // consume left parenthesis + this.consumeToken(); + + node.params = this.functionParameters(); + + if (!this.match(')')) { + this.errorMark('Expected closing parenthesis'); + } + + this.consumeToken(); + + return node; + } + + boolExpression() { + if (!this.match('bool')) { + return null; + } + + return { + type: 'bool', + value: this.consumeToken().value === 'true', + }; + } + + functionParameters(): any { + if (this.match(')') || this.match('')) { + return []; + } + + const param = + this.functionCall() || + this.numericLiteral() || + this.seriesRefExpression() || + this.boolExpression() || + this.metricExpression() || + this.stringLiteral(); + + if (!this.match(',')) { + return [param]; + } + + this.consumeToken(); + return [param].concat(this.functionParameters()); + } + + seriesRefExpression() { + if (!this.match('identifier')) { + return null; + } + + const value = this.tokens[this.index].value; + if (!value.match(/\#[A-Z]/)) { + return null; + } + + const token = this.consumeToken(); + + return { + type: 'series-ref', + value: token.value, + }; + } + + numericLiteral() { + if (!this.match('number')) { + return null; + } + + return { + type: 'number', + value: parseFloat(this.consumeToken().value), + }; + } + + stringLiteral() { + if (!this.match('string')) { + return null; + } + + const token = this.consumeToken(); + if (token.isUnclosed) { + throw { message: 'Unclosed string parameter', pos: token.pos }; + } + + return { + type: 'string', + value: token.value, + }; + } + + errorMark(text: string) { + const currentToken = this.tokens[this.index]; + const type = currentToken ? currentToken.type : 'end of string'; + throw { + message: text + ' instead found ' + type, + pos: currentToken ? currentToken.pos : this.lexer.char, + }; + } + + // returns token value and incre + consumeToken() { + this.index++; + return this.tokens[this.index - 1]; + } + + matchToken(type: any, index: number) { + const token = this.tokens[this.index + index]; + return (token === undefined && type === '') || (token && token.type === type); + } + + match(token1: any, token2?: any) { + return this.matchToken(token1, 0) && (!token2 || this.matchToken(token2, 1)); + } +} diff --git a/public/app/plugins/datasource/graphite/partials/annotations.editor.html b/public/app/plugins/datasource/graphite/partials/annotations.editor.html new file mode 100644 index 0000000..9d228b8 --- /dev/null +++ b/public/app/plugins/datasource/graphite/partials/annotations.editor.html @@ -0,0 +1,13 @@ +
      +
      + Graphite query + +
      + +
      Or
      + +
      + Graphite events tags + +
      +
      diff --git a/public/app/plugins/datasource/graphite/partials/query.editor.html b/public/app/plugins/datasource/graphite/partials/query.editor.html new file mode 100644 index 0000000..fcef994 --- /dev/null +++ b/public/app/plugins/datasource/graphite/partials/query.editor.html @@ -0,0 +1,82 @@ + + +
      + +
      + +
      +
      +
      + +
      + +
      + + + + + +
      + + + + + +
      + +
      + +
      +
      +
      +
      + +
      +
      + +
      + +
      + +
      + + + +
      +
      +
      +
      +
      + +
      diff --git a/public/app/plugins/datasource/graphite/plugin.json b/public/app/plugins/datasource/graphite/plugin.json new file mode 100644 index 0000000..1d1ab82 --- /dev/null +++ b/public/app/plugins/datasource/graphite/plugin.json @@ -0,0 +1,39 @@ +{ + "name": "Graphite", + "type": "datasource", + "id": "graphite", + "category": "tsdb", + + "includes": [ + { "type": "dashboard", "name": "Graphite Carbon Metrics", "path": "dashboards/carbon_metrics.json" }, + { "type": "dashboard", "name": "Metrictank (Graphite alternative)", "path": "dashboards/metrictank.json" } + ], + + "metrics": true, + "alerting": true, + "annotations": true, + + "queryOptions": { + "maxDataPoints": true, + "cacheTimeout": true + }, + + "info": { + "description": "Open source time series database", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/graphite_logo.png", + "large": "img/graphite_logo.png" + }, + "links": [ + { "name": "Learn more", "url": "https://graphiteapp.org/" }, + { + "name": "Graphite 1.1 Release", + "url": "https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/" + } + ] + } +} diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts new file mode 100644 index 0000000..1154173 --- /dev/null +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -0,0 +1,428 @@ +import './add_graphite_func'; +import './func_editor'; + +import _ from 'lodash'; +import GraphiteQuery from './graphite_query'; +import { QueryCtrl } from 'app/plugins/sdk'; +import appEvents from 'app/core/app_events'; +import { promiseToDigest } from 'app/core/utils/promiseToDigest'; +import { auto } from 'angular'; +import { TemplateSrv } from '@grafana/runtime'; +import { AppEvents } from '@grafana/data'; + +const GRAPHITE_TAG_OPERATORS = ['=', '!=', '=~', '!=~']; +const TAG_PREFIX = 'tag: '; + +export class GraphiteQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + queryModel: GraphiteQuery; + segments: any[]; + addTagSegments: any[]; + removeTagValue: string; + supportsTags: boolean; + paused: boolean; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private uiSegmentSrv: any, + private templateSrv: TemplateSrv, + $timeout: any + ) { + super($scope, $injector); + this.supportsTags = this.datasource.supportsTags; + this.paused = false; + this.target.target = this.target.target || ''; + + this.datasource.waitForFuncDefsLoaded().then(() => { + this.queryModel = new GraphiteQuery(this.datasource, this.target, templateSrv); + this.buildSegments(false); + }); + + this.removeTagValue = '-- remove tag --'; + } + + parseTarget() { + this.queryModel.parseTarget(); + this.buildSegments(); + } + + toggleEditorMode() { + this.target.textEditor = !this.target.textEditor; + this.parseTarget(); + } + + buildSegments(modifyLastSegment = true) { + this.segments = _.map(this.queryModel.segments, (segment) => { + return this.uiSegmentSrv.newSegment(segment); + }); + + const checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0; + + promiseToDigest(this.$scope)(this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment)); + + if (this.queryModel.seriesByTagUsed) { + this.fixTagSegments(); + } + } + + addSelectMetricSegment() { + this.queryModel.addSelectMetricSegment(); + this.segments.push(this.uiSegmentSrv.newSelectMetric()); + } + + checkOtherSegments(fromIndex: number, modifyLastSegment = true) { + if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') { + return Promise.resolve(); + } + + if (fromIndex === 0) { + this.addSelectMetricSegment(); + return Promise.resolve(); + } + + const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1); + if (path === '') { + return Promise.resolve(); + } + + return this.datasource + .metricFindQuery(path) + .then((segments: any) => { + if (segments.length === 0) { + if (path !== '' && modifyLastSegment) { + this.queryModel.segments = this.queryModel.segments.splice(0, fromIndex); + this.segments = this.segments.splice(0, fromIndex); + this.addSelectMetricSegment(); + } + } else if (segments[0].expandable) { + if (this.segments.length === fromIndex) { + this.addSelectMetricSegment(); + } else { + return this.checkOtherSegments(fromIndex + 1); + } + } + }) + .catch((err: any) => { + appEvents.emit(AppEvents.alertError, ['Error', err]); + }); + } + + setSegmentFocus(segmentIndex: any) { + _.each(this.segments, (segment, index) => { + segment.focus = segmentIndex === index; + }); + } + + getAltSegments(index: number, prefix: string) { + let query = prefix && prefix.length > 0 ? '*' + prefix + '*' : '*'; + if (index > 0) { + query = this.queryModel.getSegmentPathUpTo(index) + '.' + query; + } + const options = { + range: this.panelCtrl.range, + requestId: 'get-alt-segments', + }; + + return this.datasource + .metricFindQuery(query, options) + .then((segments: any[]) => { + const altSegments = _.map(segments, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (index > 0 && altSegments.length === 0) { + return altSegments; + } + + // add query references + if (index === 0) { + _.eachRight(this.panelCtrl.panel.targets, (target) => { + if (target.refId === this.queryModel.target.refId) { + return; + } + + altSegments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'series-ref', + value: '#' + target.refId, + expandable: false, + }) + ); + }); + } + + // add template variables + _.eachRight(this.templateSrv.getVariables(), (variable) => { + altSegments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: '$' + variable.name, + expandable: true, + }) + ); + }); + + // add wildcard option + altSegments.unshift(this.uiSegmentSrv.newSegment('*')); + + if (this.supportsTags && index === 0) { + this.removeTaggedEntry(altSegments); + return this.addAltTagSegments(prefix, altSegments); + } else { + return altSegments; + } + }) + .catch((err: any): any[] => { + return []; + }); + } + + addAltTagSegments(prefix: string, altSegments: any[]) { + return this.getTagsAsSegments(prefix).then((tagSegments: any[]) => { + tagSegments = _.map(tagSegments, (segment) => { + segment.value = TAG_PREFIX + segment.value; + return segment; + }); + return altSegments.concat(...tagSegments); + }); + } + + removeTaggedEntry(altSegments: any[]) { + altSegments = _.remove(altSegments, (s) => s.value === '_tagged'); + } + + segmentValueChanged(segment: { type: string; value: string; expandable: any }, segmentIndex: number) { + this.error = null; + this.queryModel.updateSegmentValue(segment, segmentIndex); + + if (this.queryModel.functions.length > 0 && this.queryModel.functions[0].def.fake) { + this.queryModel.functions = []; + } + + if (segment.type === 'tag') { + const tag = removeTagPrefix(segment.value); + this.pause(); + this.addSeriesByTagFunc(tag); + return null; + } + + if (segment.expandable) { + return promiseToDigest(this.$scope)( + this.checkOtherSegments(segmentIndex + 1).then(() => { + this.setSegmentFocus(segmentIndex + 1); + this.targetChanged(); + }) + ); + } else { + this.spliceSegments(segmentIndex + 1); + } + + this.setSegmentFocus(segmentIndex + 1); + this.targetChanged(); + + return null; + } + + spliceSegments(index: any) { + this.segments = this.segments.splice(0, index); + this.queryModel.segments = this.queryModel.segments.splice(0, index); + } + + emptySegments() { + this.queryModel.segments = []; + this.segments = []; + } + + targetTextChanged() { + this.updateModelTarget(); + this.refresh(); + } + + updateModelTarget() { + this.queryModel.updateModelTarget(this.panelCtrl.panel.targets); + } + + targetChanged() { + if (this.queryModel.error) { + return; + } + + const oldTarget = this.queryModel.target.target; + this.updateModelTarget(); + + if (this.queryModel.target !== oldTarget && !this.paused) { + this.panelCtrl.refresh(); + } + } + + addFunction(funcDef: any) { + const newFunc = this.datasource.createFuncInstance(funcDef, { + withDefaultParams: true, + }); + newFunc.added = true; + this.queryModel.addFunction(newFunc); + this.smartlyHandleNewAliasByNode(newFunc); + + if (this.segments.length === 1 && this.segments[0].fake) { + this.emptySegments(); + } + + if (!newFunc.params.length && newFunc.added) { + this.targetChanged(); + } + + if (newFunc.def.name === 'seriesByTag') { + this.parseTarget(); + } + } + + removeFunction(func: any) { + this.queryModel.removeFunction(func); + this.targetChanged(); + } + + moveFunction(func: any, offset: any) { + this.queryModel.moveFunction(func, offset); + this.targetChanged(); + } + + addSeriesByTagFunc(tag: string) { + const newFunc = this.datasource.createFuncInstance('seriesByTag', { + withDefaultParams: false, + }); + const tagParam = `${tag}=`; + newFunc.params = [tagParam]; + this.queryModel.addFunction(newFunc); + newFunc.added = true; + + this.emptySegments(); + this.targetChanged(); + this.parseTarget(); + } + + smartlyHandleNewAliasByNode(func: { def: { name: string }; params: number[]; added: boolean }) { + if (func.def.name !== 'aliasByNode') { + return; + } + + for (let i = 0; i < this.segments.length; i++) { + if (this.segments[i].value.indexOf('*') >= 0) { + func.params[0] = i; + func.added = false; + this.targetChanged(); + return; + } + } + } + + getAllTags() { + return this.datasource.getTags().then((values: any[]) => { + const altTags = _.map(values, 'text'); + altTags.splice(0, 0, this.removeTagValue); + return mapToDropdownOptions(altTags); + }); + } + + getTags(index: number, tagPrefix: any) { + const tagExpressions = this.queryModel.renderTagExpressions(index); + return this.datasource.getTagsAutoComplete(tagExpressions, tagPrefix).then((values: any) => { + const altTags = _.map(values, 'text'); + altTags.splice(0, 0, this.removeTagValue); + return mapToDropdownOptions(altTags); + }); + } + + getTagsAsSegments(tagPrefix: string) { + const tagExpressions = this.queryModel.renderTagExpressions(); + return this.datasource.getTagsAutoComplete(tagExpressions, tagPrefix).then((values: any) => { + return _.map(values, (val) => { + return this.uiSegmentSrv.newSegment({ + value: val.text, + type: 'tag', + expandable: false, + }); + }); + }); + } + + getTagOperators() { + return mapToDropdownOptions(GRAPHITE_TAG_OPERATORS); + } + + getAllTagValues(tag: { key: any }) { + const tagKey = tag.key; + return this.datasource.getTagValues(tagKey).then((values: any[]) => { + const altValues = _.map(values, 'text'); + return mapToDropdownOptions(altValues); + }); + } + + getTagValues(tag: { key: any }, index: number, valuePrefix: any) { + const tagExpressions = this.queryModel.renderTagExpressions(index); + const tagKey = tag.key; + return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then((values: any[]) => { + const altValues = _.map(values, 'text'); + // Add template variables as additional values + _.eachRight(this.templateSrv.getVariables(), (variable) => { + altValues.push('${' + variable.name + ':regex}'); + }); + return mapToDropdownOptions(altValues); + }); + } + + tagChanged(tag: any, tagIndex: any) { + this.queryModel.updateTag(tag, tagIndex); + this.targetChanged(); + } + + addNewTag(segment: { value: any }) { + const newTagKey = segment.value; + const newTag = { key: newTagKey, operator: '=', value: '' }; + this.queryModel.addTag(newTag); + this.targetChanged(); + this.fixTagSegments(); + } + + removeTag(index: any) { + this.queryModel.removeTag(index); + this.targetChanged(); + } + + fixTagSegments() { + // Adding tag with the same name as just removed works incorrectly if single segment is used (instead of array) + this.addTagSegments = [this.uiSegmentSrv.newPlusButton()]; + } + + showDelimiter(index: number) { + return index !== this.queryModel.tags.length - 1; + } + + pause() { + this.paused = true; + } + + unpause() { + this.paused = false; + this.panelCtrl.refresh(); + } + + getCollapsedText() { + return this.target.target; + } +} + +function mapToDropdownOptions(results: any[]) { + return _.map(results, (value) => { + return { text: value, value: value }; + }); +} + +function removeTagPrefix(value: string): string { + return value.replace(TAG_PREFIX, ''); +} diff --git a/public/app/plugins/datasource/graphite/query_help.md b/public/app/plugins/datasource/graphite/query_help.md new file mode 100644 index 0000000..f39a374 --- /dev/null +++ b/public/app/plugins/datasource/graphite/query_help.md @@ -0,0 +1,30 @@ +#### Get Shorter legend names + +- alias() function to specify a custom series name +- aliasByNode(2) to alias by a specific part of your metric path +- groupByNode(2, 'sum') is useful if you have 2 wildcards in your metric path and want to sumSeries and group by. + +#### Series as parameter + +- Some graphite functions allow you to have many series arguments +- Use #[A-Z] to use a graphite query as parameter to a function +- Examples: + - asPercent(#A, #B) + - divideSeries(#A, #B) + +If a query is added only to be used as a parameter, hide it from the graph with the eye icon + +#### Max data points +- Every graphite request is issued with a maxDataPoints parameter +- Graphite uses this parameter to consolidate the real number of values down to this number +- If there are more real values, then by default they will be consolidated using averages +- This could hide real peaks and max values in your series +- You can change how point consolidation is made using the consolidateBy graphite function +- Point consolidation will effect series legend values (min,max,total,current) +- if you override maxDataPoint and set a high value performance can be severely effected + +#### Documentation links: + +[Grafana's Graphite Documentation](http://docs.grafana.org/features/datasources/graphite) + +[Official Graphite Documentation](https://graphite.readthedocs.io) diff --git a/public/app/plugins/datasource/graphite/specs/datasource.test.ts b/public/app/plugins/datasource/graphite/specs/datasource.test.ts new file mode 100644 index 0000000..2e9f906 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/datasource.test.ts @@ -0,0 +1,505 @@ +import { GraphiteDatasource } from '../datasource'; +import _ from 'lodash'; + +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { dateTime, getFrameDisplayName } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +interface Context { + templateSrv: TemplateSrv; + ds: GraphiteDatasource; +} + +describe('graphiteDatasource', () => { + const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + + let ctx = {} as Context; + + beforeEach(() => { + jest.clearAllMocks(); + + const instanceSettings = { + url: '/api/datasources/proxy/1', + name: 'graphiteProd', + jsonData: { + rollupIndicatorEnabled: true, + }, + }; + const templateSrv = new TemplateSrv(); + const ds = new GraphiteDatasource(instanceSettings, templateSrv); + ctx = { templateSrv, ds }; + }); + + describe('convertResponseToDataFrames', () => { + it('should transform regular result', () => { + const result = ctx.ds.convertResponseToDataFrames({ + data: { + meta: { + stats: { + 'executeplan.cache-hit-partial.count': 5, + 'executeplan.cache-hit.count': 10, + }, + }, + series: [ + { + target: 'seriesA', + datapoints: [ + [100, 200], + [101, 201], + ], + meta: [ + { + 'aggnum-norm': 1, + 'aggnum-rc': 7, + 'archive-interval': 3600, + 'archive-read': 1, + 'consolidator-normfetch': 'AverageConsolidator', + 'consolidator-rc': 'AverageConsolidator', + count: 1, + 'schema-name': 'wpUsageMetrics', + 'schema-retentions': '1h:35d:6h:2,2h:2y:6h:2', + }, + ], + }, + { + target: 'seriesB', + meta: [ + { + 'aggnum-norm': 1, + 'aggnum-rc': 0, + 'archive-interval': 3600, + 'archive-read': 0, + 'consolidator-normfetch': 'AverageConsolidator', + 'consolidator-rc': 'NoneConsolidator', + count: 1, + 'schema-name': 'wpUsageMetrics', + 'schema-retentions': '1h:35d:6h:2,2h:2y:6h:2', + }, + ], + datapoints: [ + [200, 300], + [201, 301], + ], + }, + ], + }, + }); + + expect(result.data.length).toBe(2); + expect(getFrameDisplayName(result.data[0])).toBe('seriesA'); + expect(getFrameDisplayName(result.data[1])).toBe('seriesB'); + expect(result.data[0].length).toBe(2); + expect(result.data[0].meta.notices.length).toBe(1); + expect(result.data[0].meta.notices[0].text).toBe('Data is rolled up, aggregated over 2h using Average function'); + expect(result.data[1].meta.notices).toBeUndefined(); + }); + }); + + describe('When querying graphite with one target using query editor target spec', () => { + const query = { + panelId: 3, + dashboardId: 5, + range: { raw: { from: 'now-1h', to: 'now' } }, + targets: [{ target: 'prod1.count' }, { target: 'prod2.count' }], + maxDataPoints: 500, + }; + + let results: any; + let requestOptions: any; + + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: any) => { + requestOptions = options; + return Promise.resolve({ + data: [ + { + target: 'prod1.count', + datapoints: [ + [10, 1], + [12, 1], + ], + }, + ], + }); + }); + + await ctx.ds.query(query as any).then((data: any) => { + results = data; + }); + }); + + it('X-Dashboard and X-Panel headers to be set!', () => { + expect(requestOptions.headers['X-Dashboard-Id']).toBe(5); + expect(requestOptions.headers['X-Panel-Id']).toBe(3); + }); + + it('should generate the correct query', () => { + expect(requestOptions.url).toBe('/api/datasources/proxy/1/render'); + }); + + it('should set unique requestId', () => { + expect(requestOptions.requestId).toBe('graphiteProd.panelId.3'); + }); + + it('should query correctly', () => { + const params = requestOptions.data.split('&'); + expect(params).toContain('target=prod1.count'); + expect(params).toContain('target=prod2.count'); + expect(params).toContain('from=-1h'); + expect(params).toContain('until=now'); + }); + + it('should exclude undefined params', () => { + const params = requestOptions.data.split('&'); + expect(params).not.toContain('cacheTimeout=undefined'); + }); + + it('should return series list', () => { + expect(results.data.length).toBe(1); + expect(results.data[0].name).toBe('prod1.count'); + }); + + it('should convert to millisecond resolution', () => { + expect(results.data[0].fields[1].values.get(0)).toBe(10); + }); + }); + + describe('when fetching Graphite Events as annotations', () => { + let results: any; + + const options = { + annotation: { + tags: 'tag1', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + raw: { from: 'now-24h', to: 'now' }, + }, + }; + + describe('and tags are returned as string', () => { + const response = { + data: [ + { + when: 1507222850, + tags: 'tag1 tag2', + data: 'some text', + id: 2, + what: 'Event - deploy', + }, + ], + }; + + beforeEach(async () => { + datasourceRequestMock.mockImplementation((options: any) => { + return Promise.resolve(response); + }); + await ctx.ds.annotationQuery(options).then((data: any) => { + results = data; + }); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).toEqual(true); + expect(results[0].tags.length).toEqual(2); + expect(results[0].tags[0]).toEqual('tag1'); + expect(results[0].tags[1]).toEqual('tag2'); + }); + }); + + describe('and tags are returned as an array', () => { + const response = { + data: [ + { + when: 1507222850, + tags: ['tag1', 'tag2'], + data: 'some text', + id: 2, + what: 'Event - deploy', + }, + ], + }; + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: any) => { + return Promise.resolve(response); + }); + + ctx.ds.annotationQuery(options).then((data: any) => { + results = data; + }); + // ctx.$rootScope.$apply(); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).toEqual(true); + expect(results[0].tags.length).toEqual(2); + expect(results[0].tags[0]).toEqual('tag1'); + expect(results[0].tags[1]).toEqual('tag2'); + }); + }); + }); + + describe('building graphite params', () => { + it('should return empty array if no targets', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{}], + }); + expect(results.length).toBe(0); + }); + + it('should uri escape targets', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'prod1.{test,test2}' }, { target: 'prod2.count' }], + }); + expect(results).toContain('target=prod1.%7Btest%2Ctest2%7D'); + }); + + it('should replace target placeholder', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: 'series2' }, { target: 'asPercent(#A,#B)' }], + }); + expect(results[2]).toBe('target=asPercent(series1%2Cseries2)'); + }); + + it('should replace target placeholder for hidden series', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [ + { target: 'series1', hide: true }, + { target: 'sumSeries(#A)', hide: true }, + { target: 'asPercent(#A,#B)' }, + ], + }); + expect(results[0]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); + }); + + it('should replace target placeholder when nesting query references', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: 'sumSeries(#A)' }, { target: 'asPercent(#A,#B)' }], + }); + expect(results[2]).toBe('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); + }); + + it('should fix wrong minute interval parameters', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: "summarize(prod.25m.count, '25m', 'sum')" }], + }); + expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')")); + }); + + it('should fix wrong month interval parameters', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: "summarize(prod.5M.count, '5M', 'sum')" }], + }); + expect(results[0]).toBe('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')")); + }); + + it('should ignore empty targets', () => { + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'series1' }, { target: '' }], + }); + expect(results.length).toBe(2); + }); + + describe('when formatting targets', () => { + it('does not attempt to glob for one variable', () => { + ctx.templateSrv.init([ + { + type: 'query', + name: 'metric', + current: { value: ['b'] }, + }, + ]); + + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'my.$metric.*' }], + }); + expect(results).toStrictEqual(['target=my.b.*', 'format=json']); + }); + + it('globs for more than one variable', () => { + ctx.templateSrv.init([ + { + type: 'query', + name: 'metric', + current: { value: ['a', 'b'] }, + }, + ]); + + const results = ctx.ds.buildGraphiteParams({ + targets: [{ target: 'my.[[metric]].*' }], + }); + + expect(results).toStrictEqual(['target=my.%7Ba%2Cb%7D.*', 'format=json']); + }); + }); + }); + + describe('querying for template variables', () => { + let results: any; + let requestOptions: any; + + beforeEach(() => { + datasourceRequestMock.mockImplementation((options: any) => { + requestOptions = options; + return Promise.resolve({ + data: ['backend_01', 'backend_02'], + }); + }); + }); + + it('should generate tags query', () => { + ctx.ds.metricFindQuery('tags()').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tags query with a filter expression', () => { + ctx.ds.metricFindQuery('tags(server=backend_01)').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual(['server=backend_01']); + expect(results).not.toBe(null); + }); + + it('should generate tags query for an expression with whitespace after', () => { + ctx.ds.metricFindQuery('tags(server=backend_01 )').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/tags'); + expect(requestOptions.params.expr).toEqual(['server=backend_01']); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for one tag', () => { + ctx.ds.metricFindQuery('tag_values(server)').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag and expression', () => { + ctx.ds.metricFindQuery('tag_values(server,server=~backend*)').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual(['server=~backend*']); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag with whitespace after', () => { + ctx.ds.metricFindQuery('tag_values(server )').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual([]); + expect(results).not.toBe(null); + }); + + it('should generate tag values query for a tag and expression with whitespace after', () => { + ctx.ds.metricFindQuery('tag_values(server , server=~backend* )').then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/tags/autoComplete/values'); + expect(requestOptions.params.tag).toBe('server'); + expect(requestOptions.params.expr).toEqual(['server=~backend*']); + expect(results).not.toBe(null); + }); + + it('/metrics/find should be POST', () => { + ctx.templateSrv.init([ + { + type: 'query', + name: 'foo', + current: { value: ['bar'] }, + }, + ]); + ctx.ds.metricFindQuery('[[foo]]').then((data: any) => { + results = data; + }); + expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find'); + expect(requestOptions.method).toEqual('POST'); + expect(requestOptions.headers).toHaveProperty('Content-Type', 'application/x-www-form-urlencoded'); + expect(requestOptions.data).toMatch(`query=bar`); + expect(requestOptions).toHaveProperty('params'); + }); + + it('should interpolate $__searchFilter with searchFilter', () => { + ctx.ds.metricFindQuery('app.$__searchFilter', { searchFilter: 'backend' }).then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find'); + expect(requestOptions.params).toEqual({}); + expect(requestOptions.data).toEqual('query=app.backend*'); + expect(results).not.toBe(null); + }); + + it('should interpolate $__searchFilter with default when searchFilter is missing', () => { + ctx.ds.metricFindQuery('app.$__searchFilter', {}).then((data: any) => { + results = data; + }); + + expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find'); + expect(requestOptions.params).toEqual({}); + expect(requestOptions.data).toEqual('query=app.*'); + expect(results).not.toBe(null); + }); + }); +}); + +function accessScenario(name: string, url: string, fn: any) { + describe('access scenario ' + name, () => { + const ctx: any = { + // @ts-ignore + templateSrv: new TemplateSrv(), + instanceSettings: { url: 'url', name: 'graphiteProd', jsonData: {} }, + }; + + const httpOptions = { + headers: {}, + }; + + describe('when using proxy mode', () => { + const options = { dashboardId: 1, panelId: 2 }; + + it('tracing headers should be added', () => { + ctx.instanceSettings.url = url; + const ds = new GraphiteDatasource(ctx.instanceSettings, ctx.templateSrv); + ds.addTracingHeaders(httpOptions, options); + fn(httpOptions); + }); + }); + }); +} + +accessScenario('with proxy access', '/api/datasources/proxy/1', (httpOptions: any) => { + expect(httpOptions.headers['X-Dashboard-Id']).toBe(1); + expect(httpOptions.headers['X-Panel-Id']).toBe(2); +}); + +accessScenario('with direct access', 'http://localhost:8080', (httpOptions: any) => { + expect(httpOptions.headers['X-Dashboard-Id']).toBe(undefined); + expect(httpOptions.headers['X-Panel-Id']).toBe(undefined); +}); diff --git a/public/app/plugins/datasource/graphite/specs/gfunc.test.ts b/public/app/plugins/datasource/graphite/specs/gfunc.test.ts new file mode 100644 index 0000000..fb17caf --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/gfunc.test.ts @@ -0,0 +1,170 @@ +import gfunc from '../gfunc'; + +describe('when creating func instance from func names', () => { + it('should return func instance', () => { + const func = gfunc.createFuncInstance('sumSeries'); + expect(func).toBeTruthy(); + expect(func.def.name).toEqual('sumSeries'); + expect(func.def.params.length).toEqual(1); + expect(func.def.params[0].multiple).toEqual(true); + expect(func.def.defaultParams.length).toEqual(1); + }); + + it('should return func instance with shortName', () => { + const func = gfunc.createFuncInstance('sum'); + expect(func).toBeTruthy(); + }); + + it('should return func instance from funcDef', () => { + const func = gfunc.createFuncInstance('sum'); + const func2 = gfunc.createFuncInstance(func.def); + expect(func2).toBeTruthy(); + }); + + it('func instance should have text representation', () => { + const func = gfunc.createFuncInstance('groupByNode'); + func.params[0] = 5; + func.params[1] = 'avg'; + func.updateText(); + expect(func.text).toEqual('groupByNode(5, avg)'); + }); +}); + +function replaceVariablesDummy(str: string) { + // important that this does replace + return str.replace('asdasdas', 'asdsad'); +} + +describe('when rendering func instance', () => { + it('should handle single metric param', () => { + const func = gfunc.createFuncInstance('sumSeries'); + expect(func.render('hello.metric', replaceVariablesDummy)).toEqual('sumSeries(hello.metric)'); + }); + + it('should include default params if options enable it', () => { + const func = gfunc.createFuncInstance('scaleToSeconds', { + withDefaultParams: true, + }); + expect(func.render('hello', replaceVariablesDummy)).toEqual('scaleToSeconds(hello, 1)'); + }); + + it('should handle int or interval params with number', () => { + const func = gfunc.createFuncInstance('movingMedian'); + func.params[0] = '5'; + expect(func.render('hello', replaceVariablesDummy)).toEqual('movingMedian(hello, 5)'); + }); + + it('should handle int or interval params with interval string', () => { + const func = gfunc.createFuncInstance('movingMedian'); + func.params[0] = '5min'; + expect(func.render('hello', replaceVariablesDummy)).toEqual("movingMedian(hello, '5min')"); + }); + + it('should never quote boolean paramater', () => { + const func = gfunc.createFuncInstance('sortByName'); + func.params[0] = '$natural'; + expect(func.render('hello', replaceVariablesDummy)).toEqual('sortByName(hello, $natural)'); + }); + + it('should never quote int paramater', () => { + const func = gfunc.createFuncInstance('maximumAbove'); + func.params[0] = '$value'; + expect(func.render('hello', replaceVariablesDummy)).toEqual('maximumAbove(hello, $value)'); + }); + + it('should never quote node paramater', () => { + const func = gfunc.createFuncInstance('aliasByNode'); + func.params[0] = '$node'; + expect(func.render('hello', replaceVariablesDummy)).toEqual('aliasByNode(hello, $node)'); + }); + + it('should handle metric param and int param and string param', () => { + const func = gfunc.createFuncInstance('groupByNode'); + func.params[0] = 5; + func.params[1] = 'avg'; + expect(func.render('hello.metric', replaceVariablesDummy)).toEqual("groupByNode(hello.metric, 5, 'avg')"); + }); + + it('should handle function with no metric param', () => { + const func = gfunc.createFuncInstance('randomWalk'); + func.params[0] = 'test'; + expect(func.render((undefined as unknown) as string, replaceVariablesDummy)).toEqual("randomWalk('test')"); + }); + + it('should handle function multiple series params', () => { + const func = gfunc.createFuncInstance('asPercent'); + func.params[0] = '#B'; + expect(func.render('#A', replaceVariablesDummy)).toEqual('asPercent(#A, #B)'); + }); + + it('should not quote variables that have numeric value', () => { + const func = gfunc.createFuncInstance('movingAverage'); + func.params[0] = '$variable'; + + const replaceVariables = (str: string) => { + return str.replace('$variable', '60'); + }; + + expect(func.render('metric', replaceVariables)).toBe('movingAverage(metric, $variable)'); + }); + + it('should quote variables that have string value', () => { + const func = gfunc.createFuncInstance('movingAverage'); + func.params[0] = '$variable'; + + const replaceVariables = (str: string) => { + return str.replace('$variable', '10min'); + }; + + expect(func.render('metric', replaceVariables)).toBe("movingAverage(metric, '$variable')"); + }); +}); + +describe('when requesting function definitions', () => { + it('should return function definitions', () => { + const funcIndex = gfunc.getFuncDefs('1.0'); + expect(Object.keys(funcIndex).length).toBeGreaterThan(8); + }); +}); + +describe('when updating func param', () => { + it('should update param value and update text representation', () => { + const func = gfunc.createFuncInstance('summarize', { + withDefaultParams: true, + }); + func.updateParam('1h', 0); + expect(func.params[0]).toBe('1h'); + expect(func.text).toBe('summarize(1h, sum, false)'); + }); + + it('should parse numbers as float', () => { + const func = gfunc.createFuncInstance('scale'); + func.updateParam('0.001', 0); + expect(func.params[0]).toBe('0.001'); + }); +}); + +describe('when updating func param with optional second parameter', () => { + it('should update value and text', () => { + const func = gfunc.createFuncInstance('aliasByNode'); + func.updateParam('1', 0); + expect(func.params[0]).toBe('1'); + }); + + it('should slit text and put value in second param', () => { + const func = gfunc.createFuncInstance('aliasByNode'); + func.updateParam('4,-5', 0); + expect(func.params[0]).toBe('4'); + expect(func.params[1]).toBe('-5'); + expect(func.text).toBe('aliasByNode(4, -5)'); + }); + + it('should remove second param when empty string is set', () => { + const func = gfunc.createFuncInstance('aliasByNode'); + func.updateParam('4,-5', 0); + func.updateParam('', 1); + expect(func.params[0]).toBe('4'); + expect(func.params[1]).toBe(undefined); + expect(func.text).toBe('aliasByNode(4)'); + }); +}); diff --git a/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts b/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts new file mode 100644 index 0000000..62e5c08 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts @@ -0,0 +1,77 @@ +import gfunc from '../gfunc'; +import GraphiteQuery from '../graphite_query'; +import { TemplateSrvStub } from 'test/specs/helpers'; + +describe('Graphite query model', () => { + const ctx: any = { + datasource: { + getFuncDef: gfunc.getFuncDef, + getFuncDefs: jest.fn().mockReturnValue(Promise.resolve(gfunc.getFuncDefs('1.0'))), + waitForFuncDefsLoaded: jest.fn().mockReturnValue(Promise.resolve(null)), + createFuncInstance: gfunc.createFuncInstance, + }, + // @ts-ignore + templateSrv: new TemplateSrvStub(), + targets: [], + }; + + beforeEach(() => { + ctx.target = { refId: 'A', target: 'scaleToSeconds(#A, 60)' }; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + describe('when updating targets with nested queries', () => { + beforeEach(() => { + ctx.target = { refId: 'D', target: 'asPercent(#A, #C)' }; + ctx.targets = [ + { refId: 'A', target: 'first.query.count' }, + { refId: 'B', target: 'second.query.count' }, + { refId: 'C', target: 'diffSeries(#A, #B)' }, + { refId: 'D', target: 'asPercent(#A, #C)' }, + ]; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + it('targetFull should include nested queries', () => { + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + const targetFullExpected = 'asPercent(first.query.count, diffSeries(first.query.count, second.query.count))'; + expect(ctx.queryModel.target.targetFull).toBe(targetFullExpected); + }); + + it('should not hang on circular references', () => { + ctx.target.target = 'asPercent(#A, #B)'; + ctx.targets = [ + { refId: 'A', target: 'asPercent(#B, #C)' }, + { refId: 'B', target: 'asPercent(#A, #C)' }, + ]; + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + // Just ensure updateRenderedTarget() is completed and doesn't hang + expect(ctx.queryModel.target.targetFull).toBeDefined(); + }); + }); + + describe('when query seriesByTag and series ref', () => { + beforeEach(() => { + ctx.target = { refId: 'A', target: `group(seriesByTag('namespace=asd'), #A)` }; + ctx.targets = [ctx.target]; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + it('should keep group function series ref', () => { + expect(ctx.queryModel.functions[1].params[0]).toBe('#A'); + }); + }); + + describe('when query has seriesByTag and highestMax with variable param', () => { + beforeEach(() => { + ctx.target = { refId: 'A', target: `highestMax(seriesByTag('namespace=asd'), $limit)` }; + ctx.targets = [ctx.target]; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + it('should add $limit to highestMax function param', () => { + expect(ctx.queryModel.segments.length).toBe(0); + expect(ctx.queryModel.functions[1].params[0]).toBe('$limit'); + }); + }); +}); diff --git a/public/app/plugins/datasource/graphite/specs/lexer.test.ts b/public/app/plugins/datasource/graphite/specs/lexer.test.ts new file mode 100644 index 0000000..4bfe721 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/lexer.test.ts @@ -0,0 +1,123 @@ +import { Lexer } from '../lexer'; + +describe('when lexing graphite expression', () => { + it('should tokenize metric expression', () => { + const lexer = new Lexer('metric.test.*.asd.count'); + const tokens = lexer.tokenize(); + expect(tokens[0].value).toBe('metric'); + expect(tokens[1].value).toBe('.'); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[4].type).toBe('identifier'); + expect(tokens[4].pos).toBe(13); + }); + + it('should tokenize metric expression with dash', () => { + const lexer = new Lexer('metric.test.se1-server-*.asd.count'); + const tokens = lexer.tokenize(); + expect(tokens[4].type).toBe('identifier'); + expect(tokens[4].value).toBe('se1-server-*'); + }); + + it('should tokenize metric expression with dash2', () => { + const lexer = new Lexer('net.192-168-1-1.192-168-1-9.ping_value.*'); + const tokens = lexer.tokenize(); + expect(tokens[0].value).toBe('net'); + expect(tokens[2].value).toBe('192-168-1-1'); + }); + + it('should tokenize metric expression with equal sign', () => { + const lexer = new Lexer('apps=test'); + const tokens = lexer.tokenize(); + expect(tokens[0].value).toBe('apps=test'); + }); + + it('simple function2', () => { + const lexer = new Lexer('offset(test.metric, -100)'); + const tokens = lexer.tokenize(); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[4].type).toBe('identifier'); + expect(tokens[6].type).toBe('number'); + }); + + it('should tokenize metric expression with curly braces', () => { + const lexer = new Lexer('metric.se1-{first, second}.count'); + const tokens = lexer.tokenize(); + expect(tokens.length).toBe(10); + expect(tokens[3].type).toBe('{'); + expect(tokens[4].value).toBe('first'); + expect(tokens[5].value).toBe(','); + expect(tokens[6].value).toBe('second'); + }); + + it('should tokenize metric expression with number segments', () => { + const lexer = new Lexer('metric.10.12_10.test'); + const tokens = lexer.tokenize(); + expect(tokens[0].type).toBe('identifier'); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[2].value).toBe('10'); + expect(tokens[4].value).toBe('12_10'); + expect(tokens[4].type).toBe('identifier'); + }); + + it('should tokenize metric expression with segment that start with number', () => { + const lexer = new Lexer('metric.001-server'); + const tokens = lexer.tokenize(); + expect(tokens[0].type).toBe('identifier'); + expect(tokens[2].type).toBe('identifier'); + expect(tokens.length).toBe(3); + }); + + it('should tokenize func call with numbered metric and number arg', () => { + const lexer = new Lexer('scale(metric.10, 15)'); + const tokens = lexer.tokenize(); + expect(tokens[0].type).toBe('identifier'); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[2].value).toBe('metric'); + expect(tokens[4].value).toBe('10'); + expect(tokens[4].type).toBe('number'); + expect(tokens[6].type).toBe('number'); + }); + + it('should tokenize metric with template parameter', () => { + const lexer = new Lexer('metric.[[server]].test'); + const tokens = lexer.tokenize(); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[2].value).toBe('[[server]]'); + expect(tokens[4].type).toBe('identifier'); + }); + + it('should tokenize metric with question mark', () => { + const lexer = new Lexer('metric.server_??.test'); + const tokens = lexer.tokenize(); + expect(tokens[2].type).toBe('identifier'); + expect(tokens[2].value).toBe('server_??'); + expect(tokens[4].type).toBe('identifier'); + }); + + it('should handle error with unterminated string', () => { + const lexer = new Lexer("alias(metric, 'asd)"); + const tokens = lexer.tokenize(); + expect(tokens[0].value).toBe('alias'); + expect(tokens[1].value).toBe('('); + expect(tokens[2].value).toBe('metric'); + expect(tokens[3].value).toBe(','); + expect(tokens[4].type).toBe('string'); + expect(tokens[4].isUnclosed).toBe(true); + expect(tokens[4].pos).toBe(20); + }); + + it('should handle float parameters', () => { + const lexer = new Lexer('alias(metric, 0.002)'); + const tokens = lexer.tokenize(); + expect(tokens[4].type).toBe('number'); + expect(tokens[4].value).toBe('0.002'); + }); + + it('should handle bool parameters', () => { + const lexer = new Lexer('alias(metric, true, false)'); + const tokens = lexer.tokenize(); + expect(tokens[4].type).toBe('bool'); + expect(tokens[4].value).toBe('true'); + expect(tokens[6].type).toBe('bool'); + }); +}); diff --git a/public/app/plugins/datasource/graphite/specs/parser.test.ts b/public/app/plugins/datasource/graphite/specs/parser.test.ts new file mode 100644 index 0000000..25cabd5 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/parser.test.ts @@ -0,0 +1,181 @@ +import { Parser } from '../parser'; + +describe('when parsing', () => { + it('simple metric expression', () => { + const parser = new Parser('metric.test.*.asd.count'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(5); + expect(rootNode.segments[0].value).toBe('metric'); + }); + + it('simple metric expression with numbers in segments', () => { + const parser = new Parser('metric.10.15_20.5'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(4); + expect(rootNode.segments[1].value).toBe('10'); + expect(rootNode.segments[2].value).toBe('15_20'); + expect(rootNode.segments[3].value).toBe('5'); + }); + + it('simple metric expression with curly braces', () => { + const parser = new Parser('metric.se1-{count, max}'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(2); + expect(rootNode.segments[1].value).toBe('se1-{count,max}'); + }); + + it('simple metric expression with curly braces at start of segment and with post chars', () => { + const parser = new Parser('metric.{count, max}-something.count'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(3); + expect(rootNode.segments[1].value).toBe('{count,max}-something'); + }); + + it('simple function', () => { + const parser = new Parser('sum(test)'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params.length).toBe(1); + }); + + it('simple function2', () => { + const parser = new Parser('offset(test.metric, -100)'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params[0].type).toBe('metric'); + expect(rootNode.params[1].type).toBe('number'); + }); + + it('simple function with string arg', () => { + const parser = new Parser("randomWalk('test')"); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params.length).toBe(1); + expect(rootNode.params[0].type).toBe('string'); + }); + + it('function with multiple args', () => { + const parser = new Parser("sum(test, 1, 'test')"); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('function'); + expect(rootNode.params.length).toBe(3); + expect(rootNode.params[0].type).toBe('metric'); + expect(rootNode.params[1].type).toBe('number'); + expect(rootNode.params[2].type).toBe('string'); + }); + + it('function with nested function', () => { + const parser = new Parser('sum(scaleToSeconds(test, 1))'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('function'); + expect(rootNode.params.length).toBe(1); + expect(rootNode.params[0].type).toBe('function'); + expect(rootNode.params[0].name).toBe('scaleToSeconds'); + expect(rootNode.params[0].params.length).toBe(2); + expect(rootNode.params[0].params[0].type).toBe('metric'); + expect(rootNode.params[0].params[1].type).toBe('number'); + }); + + it('function with multiple series', () => { + const parser = new Parser('sum(test.test.*.count, test.timers.*.count)'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('function'); + expect(rootNode.params.length).toBe(2); + expect(rootNode.params[0].type).toBe('metric'); + expect(rootNode.params[1].type).toBe('metric'); + }); + + it('function with templated series', () => { + const parser = new Parser('sum(test.[[server]].count)'); + const rootNode = parser.getAst(); + + expect(rootNode.message).toBe(undefined); + expect(rootNode.params[0].type).toBe('metric'); + expect(rootNode.params[0].segments[1].type).toBe('segment'); + expect(rootNode.params[0].segments[1].value).toBe('[[server]]'); + }); + + it('invalid metric expression', () => { + const parser = new Parser('metric.test.*.asd.'); + const rootNode = parser.getAst(); + + expect(rootNode.message).toBe('Expected metric identifier instead found end of string'); + expect(rootNode.pos).toBe(19); + }); + + it('invalid function expression missing closing parenthesis', () => { + const parser = new Parser('sum(test'); + const rootNode = parser.getAst(); + + expect(rootNode.message).toBe('Expected closing parenthesis instead found end of string'); + expect(rootNode.pos).toBe(9); + }); + + it('unclosed string in function', () => { + const parser = new Parser("sum('test)"); + const rootNode = parser.getAst(); + + expect(rootNode.message).toBe('Unclosed string parameter'); + expect(rootNode.pos).toBe(11); + }); + + it('handle issue #69', () => { + const parser = new Parser('cactiStyle(offset(scale(net.192-168-1-1.192-168-1-9.ping_value.*,0.001),-100))'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + }); + + it('handle float function arguments', () => { + const parser = new Parser('scale(test, 0.002)'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params[1].type).toBe('number'); + expect(rootNode.params[1].value).toBe(0.002); + }); + + it('handle curly brace pattern at start', () => { + const parser = new Parser('{apps}.test'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments[0].value).toBe('{apps}'); + expect(rootNode.segments[1].value).toBe('test'); + }); + + it('series parameters', () => { + const parser = new Parser('asPercent(#A, #B)'); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params[0].type).toBe('series-ref'); + expect(rootNode.params[0].value).toBe('#A'); + expect(rootNode.params[1].value).toBe('#B'); + }); + + it('series parameters, issue 2788', () => { + const parser = new Parser("summarize(diffSeries(#A, #B), '10m', 'sum', false)"); + const rootNode = parser.getAst(); + expect(rootNode.type).toBe('function'); + expect(rootNode.params[0].type).toBe('function'); + expect(rootNode.params[1].value).toBe('10m'); + expect(rootNode.params[3].type).toBe('bool'); + }); + + it('should parse metric expression with ip number segments', () => { + const parser = new Parser('5.10.123.5'); + const rootNode = parser.getAst(); + expect(rootNode.segments[0].value).toBe('5'); + expect(rootNode.segments[1].value).toBe('10'); + expect(rootNode.segments[2].value).toBe('123'); + expect(rootNode.segments[3].value).toBe('5'); + }); +}); diff --git a/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts new file mode 100644 index 0000000..11b4ce1 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts @@ -0,0 +1,361 @@ +import { uiSegmentSrv } from 'app/core/services/segment_srv'; +import gfunc from '../gfunc'; +import { GraphiteQueryCtrl } from '../query_ctrl'; +import { TemplateSrvStub } from 'test/specs/helpers'; + +jest.mock('app/core/utils/promiseToDigest', () => ({ + promiseToDigest: (scope: any) => { + return (p: Promise) => p; + }, +})); + +describe('GraphiteQueryCtrl', () => { + const ctx = { + datasource: { + metricFindQuery: jest.fn(() => Promise.resolve([])), + getFuncDefs: jest.fn(() => Promise.resolve(gfunc.getFuncDefs('1.0'))), + getFuncDef: gfunc.getFuncDef, + waitForFuncDefsLoaded: jest.fn(() => Promise.resolve(null)), + createFuncInstance: gfunc.createFuncInstance, + }, + target: { target: 'aliasByNode(scaleToSeconds(test.prod.*,1),2)' }, + panelCtrl: { + refresh: jest.fn(), + }, + } as any; + + ctx.panelCtrl.panel = { + targets: [ctx.target], + }; + + beforeEach(() => { + GraphiteQueryCtrl.prototype.target = ctx.target; + GraphiteQueryCtrl.prototype.datasource = ctx.datasource; + GraphiteQueryCtrl.prototype.panelCtrl = ctx.panelCtrl; + + ctx.ctrl = new GraphiteQueryCtrl( + {}, + {} as any, + //@ts-ignore + new uiSegmentSrv({ trustAsHtml: (html) => html }, { highlightVariablesAsHtml: () => {} }), + //@ts-ignore + new TemplateSrvStub(), + {} + ); + }); + + describe('init', () => { + it('should validate metric key exists', () => { + expect(ctx.datasource.metricFindQuery.mock.calls[0][0]).toBe('test.prod.*'); + }); + + it('should not delete last segment if no metrics are found', () => { + expect(ctx.ctrl.segments[2].value).not.toBe('select metric'); + expect(ctx.ctrl.segments[2].value).toBe('*'); + }); + + it('should parse expression and build function model', () => { + expect(ctx.ctrl.queryModel.functions.length).toBe(2); + }); + }); + + describe('when toggling edit mode to raw and back again', () => { + beforeEach(() => { + ctx.ctrl.toggleEditorMode(); + ctx.ctrl.toggleEditorMode(); + }); + + it('should validate metric key exists', () => { + const lastCallIndex = ctx.datasource.metricFindQuery.mock.calls.length - 1; + expect(ctx.datasource.metricFindQuery.mock.calls[lastCallIndex][0]).toBe('test.prod.*'); + }); + + it('should delete last segment if no metrics are found', () => { + expect(ctx.ctrl.segments[0].value).toBe('test'); + expect(ctx.ctrl.segments[1].value).toBe('prod'); + expect(ctx.ctrl.segments[2].value).toBe('select metric'); + }); + + it('should parse expression and build function model', () => { + expect(ctx.ctrl.queryModel.functions.length).toBe(2); + }); + }); + + describe('when middle segment value of test.prod.* is changed', () => { + beforeEach(() => { + const segment = { type: 'segment', value: 'test', expandable: true }; + ctx.ctrl.segmentValueChanged(segment, 1); + }); + + it('should validate metric key exists', () => { + const lastCallIndex = ctx.datasource.metricFindQuery.mock.calls.length - 1; + expect(ctx.datasource.metricFindQuery.mock.calls[lastCallIndex][0]).toBe('test.test.*'); + }); + + it('should delete last segment if no metrics are found', () => { + expect(ctx.ctrl.segments[0].value).toBe('test'); + expect(ctx.ctrl.segments[1].value).toBe('test'); + expect(ctx.ctrl.segments[2].value).toBe('select metric'); + }); + + it('should parse expression and build function model', () => { + expect(ctx.ctrl.queryModel.functions.length).toBe(2); + }); + }); + + describe('when adding function', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'test.prod.*.count'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.addFunction(gfunc.getFuncDef('aliasByNode')); + }); + + it('should add function with correct node number', () => { + expect(ctx.ctrl.queryModel.functions[0].params[0]).toBe(2); + }); + + it('should update target', () => { + expect(ctx.ctrl.target.target).toBe('aliasByNode(test.prod.*.count, 2)'); + }); + + it('should call refresh', () => { + expect(ctx.panelCtrl.refresh).toHaveBeenCalled(); + }); + }); + + describe('when adding function before any metric segment', () => { + beforeEach(() => { + ctx.ctrl.target.target = ''; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: true }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.addFunction(gfunc.getFuncDef('asPercent')); + }); + + it('should add function and remove select metric link', () => { + expect(ctx.ctrl.segments.length).toBe(0); + }); + }); + + describe('when initializing a target with single param func using variable', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'movingAverage(prod.count, $var)'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); + ctx.ctrl.parseTarget(); + }); + + it('should add 2 segments', () => { + expect(ctx.ctrl.segments.length).toBe(2); + }); + + it('should add function param', () => { + expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); + }); + }); + + describe('when initializing target without metric expression and function with series-ref', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'asPercent(metric.node.count, #A)'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); + ctx.ctrl.parseTarget(); + }); + + it('should add segments', () => { + expect(ctx.ctrl.segments.length).toBe(3); + }); + + it('should have correct func params', () => { + expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); + }); + }); + + describe('when getting altSegments and metricFindQuery returns empty array', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'test.count'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); + ctx.ctrl.parseTarget(); + ctx.ctrl.getAltSegments(1).then((results: any) => { + ctx.altSegments = results; + }); + }); + + it('should have no segments', () => { + expect(ctx.altSegments.length).toBe(0); + }); + }); + + describe('targetChanged', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.target.target = ''; + ctx.ctrl.targetChanged(); + }); + + it('should rebuild target after expression model', () => { + expect(ctx.ctrl.target.target).toBe('aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'); + }); + + it('should call panelCtrl.refresh', () => { + expect(ctx.panelCtrl.refresh).toHaveBeenCalled(); + }); + }); + + describe('when updating targets with nested query', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'scaleToSeconds(#A, 60)'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + }); + + it('should add function params', () => { + expect(ctx.ctrl.queryModel.segments.length).toBe(1); + expect(ctx.ctrl.queryModel.segments[0].value).toBe('#A'); + + expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); + expect(ctx.ctrl.queryModel.functions[0].params[0]).toBe(60); + }); + + it('target should remain the same', () => { + expect(ctx.ctrl.target.target).toBe('scaleToSeconds(#A, 60)'); + }); + + it('targetFull should include nested queries', () => { + ctx.ctrl.panelCtrl.panel.targets = [ + { + target: 'nested.query.count', + refId: 'A', + }, + ]; + + ctx.ctrl.updateModelTarget(); + + expect(ctx.ctrl.target.target).toBe('scaleToSeconds(#A, 60)'); + + expect(ctx.ctrl.target.targetFull).toBe('scaleToSeconds(nested.query.count, 60)'); + }); + }); + + describe('when updating target used in other query', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'metrics.a.count'; + ctx.ctrl.target.refId = 'A'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + + ctx.ctrl.panelCtrl.panel.targets = [ctx.ctrl.target, { target: 'sumSeries(#A)', refId: 'B' }]; + + ctx.ctrl.updateModelTarget(); + }); + + it('targetFull of other query should update', () => { + expect(ctx.ctrl.panel.targets[1].targetFull).toBe('sumSeries(metrics.a.count)'); + }); + }); + + describe('when adding seriesByTag function', () => { + beforeEach(() => { + ctx.ctrl.target.target = ''; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.addFunction(gfunc.getFuncDef('seriesByTag')); + }); + + it('should update functions', () => { + expect(ctx.ctrl.queryModel.getSeriesByTagFuncIndex()).toBe(0); + }); + + it('should update seriesByTagUsed flag', () => { + expect(ctx.ctrl.queryModel.seriesByTagUsed).toBe(true); + }); + + it('should update target', () => { + expect(ctx.ctrl.target.target).toBe('seriesByTag()'); + }); + + it('should call refresh', () => { + expect(ctx.panelCtrl.refresh).toHaveBeenCalled(); + }); + }); + + describe('when parsing seriesByTag function', () => { + beforeEach(() => { + ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + }); + + it('should add tags', () => { + const expected = [ + { key: 'tag1', operator: '=', value: 'value1' }, + { key: 'tag2', operator: '!=~', value: 'value2' }, + ]; + expect(ctx.ctrl.queryModel.tags).toEqual(expected); + }); + + it('should add plus button', () => { + expect(ctx.ctrl.addTagSegments.length).toBe(1); + }); + }); + + describe('when tag added', () => { + beforeEach(() => { + ctx.ctrl.target.target = 'seriesByTag()'; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.addNewTag({ value: 'tag1' }); + }); + + it('should update tags with default value', () => { + const expected = [{ key: 'tag1', operator: '=', value: '' }]; + expect(ctx.ctrl.queryModel.tags).toEqual(expected); + }); + + it('should update target', () => { + const expected = "seriesByTag('tag1=')"; + expect(ctx.ctrl.target.target).toEqual(expected); + }); + }); + + describe('when tag changed', () => { + beforeEach(() => { + ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.tagChanged({ key: 'tag1', operator: '=', value: 'new_value' }, 0); + }); + + it('should update tags', () => { + const expected = [ + { key: 'tag1', operator: '=', value: 'new_value' }, + { key: 'tag2', operator: '!=~', value: 'value2' }, + ]; + expect(ctx.ctrl.queryModel.tags).toEqual(expected); + }); + + it('should update target', () => { + const expected = "seriesByTag('tag1=new_value', 'tag2!=~value2')"; + expect(ctx.ctrl.target.target).toEqual(expected); + }); + }); + + describe('when tag removed', () => { + beforeEach(() => { + ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; + ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + ctx.ctrl.parseTarget(); + ctx.ctrl.tagChanged({ key: ctx.ctrl.removeTagValue }); + }); + + it('should update tags', () => { + const expected = [{ key: 'tag2', operator: '!=~', value: 'value2' }]; + expect(ctx.ctrl.queryModel.tags).toEqual(expected); + }); + + it('should update target', () => { + const expected = "seriesByTag('tag2!=~value2')"; + expect(ctx.ctrl.target.target).toEqual(expected); + }); + }); +}); diff --git a/public/app/plugins/datasource/graphite/types.ts b/public/app/plugins/datasource/graphite/types.ts new file mode 100644 index 0000000..46b6bde --- /dev/null +++ b/public/app/plugins/datasource/graphite/types.ts @@ -0,0 +1,37 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export interface GraphiteQuery extends DataQuery { + target?: string; +} + +export interface GraphiteOptions extends DataSourceJsonData { + graphiteVersion: string; + graphiteType: GraphiteType; + rollupIndicatorEnabled?: boolean; +} + +export enum GraphiteType { + Default = 'default', + Metrictank = 'metrictank', +} + +export interface MetricTankRequestMeta { + [key: string]: any; +} + +export interface MetricTankSeriesMeta { + 'schema-name': string; + 'schema-retentions': string; //"1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2", + 'archive-read': number; + 'archive-interval': number; + 'aggnum-norm': number; + 'consolidator-normfetch': string; //"MaximumConsolidator", + 'aggnum-rc': number; + 'consolidator-rc': string; //"MaximumConsolidator", + count: number; +} + +export interface MetricTankMeta { + request: MetricTankRequestMeta; + info: MetricTankSeriesMeta[]; +} diff --git a/public/app/plugins/datasource/influxdb/README.md b/public/app/plugins/datasource/influxdb/README.md new file mode 100644 index 0000000..3cfb8c0 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/README.md @@ -0,0 +1,9 @@ +# InfluxDB data source + +Grafana ships with built-in support for InfluxDB releases >0.9.x. + +There are separate data sources for InfluxDB in Grafana: the latest InfluxDB release and [InfluxDB 0.8.x](https://grafana.com/plugins/grafana-influxdb-08-datasource) that Grafana maintains support for as a convenience for its existing users. InfluxDB no longer maintains release 0.8. + +The API and capabilities of the latest release of InfluxDB are completely different from those of InfluxDB 0.8.x, which is why Grafana handles them as different data sources. + +For more information, refer to [Using InfluxDB in Grafana](http://docs.grafana.org/datasources/influxdb/). diff --git a/public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx b/public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx new file mode 100644 index 0000000..7c34ba7 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ConfigEditor, { Props } from './ConfigEditor'; + +const setup = (propOverrides?: object) => { + const props: Props = { + options: { + id: 21, + orgId: 1, + name: 'InfluxDB-3', + type: 'influxdb', + typeName: 'Influx', + typeLogoUrl: '', + access: 'proxy', + url: '', + password: '', + user: '', + database: '', + basicAuth: false, + basicAuthUser: '', + basicAuthPassword: '', + withCredentials: false, + isDefault: false, + jsonData: { + httpMode: 'POST', + timeInterval: '4', + }, + secureJsonFields: {}, + version: 1, + readOnly: false, + }, + onOptionsChange: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should disable basic auth password input', () => { + const wrapper = setup({ + secureJsonFields: { + basicAuthPassword: true, + }, + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should hide white listed cookies input when browser access chosen', () => { + const wrapper = setup({ + access: 'direct', + }); + expect(wrapper).toMatchSnapshot(); + }); + + it('should hide basic auth fields when switch off', () => { + const wrapper = setup({ + basicAuth: false, + }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx b/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx new file mode 100644 index 0000000..8721dfe --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/ConfigEditor.tsx @@ -0,0 +1,321 @@ +import React, { PureComponent } from 'react'; +import { + DataSourcePluginOptionsEditorProps, + SelectableValue, + onUpdateDatasourceOption, + updateDatasourcePluginResetOption, + onUpdateDatasourceJsonDataOption, + onUpdateDatasourceJsonDataOptionSelect, + onUpdateDatasourceSecureJsonDataOption, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { DataSourceHttpSettings, InfoBox, InlineField, InlineFormLabel, LegacyForms } from '@grafana/ui'; +const { Select, Input, SecretFormField } = LegacyForms; +import { InfluxOptions, InfluxSecureJsonData, InfluxVersion } from '../types'; + +const httpModes = [ + { label: 'GET', value: 'GET' }, + { label: 'POST', value: 'POST' }, +] as SelectableValue[]; + +const versions = [ + { + label: 'InfluxQL', + value: InfluxVersion.InfluxQL, + description: 'The InfluxDB SQL-like query language. Supported in InfluxDB 1.x', + }, + { + label: 'Flux', + value: InfluxVersion.Flux, + description: 'Advanced data scripting and query language. Supported in InfluxDB 2.x and 1.8+ (beta)', + }, +] as Array>; + +export type Props = DataSourcePluginOptionsEditorProps; +type State = { + maxSeries: string | undefined; +}; + +export class ConfigEditor extends PureComponent { + state = { + maxSeries: '', + }; + + constructor(props: Props) { + super(props); + this.state.maxSeries = props.options.jsonData.maxSeries?.toString() || ''; + } + + // 1x + onResetPassword = () => { + updateDatasourcePluginResetOption(this.props, 'password'); + }; + + // 2x + onResetToken = () => { + updateDatasourcePluginResetOption(this.props, 'token'); + }; + + onVersionChanged = (selected: SelectableValue) => { + const { options, onOptionsChange } = this.props; + + const copy: any = { + ...options, + jsonData: { + ...options.jsonData, + version: selected.value, + }, + }; + if (selected.value === InfluxVersion.Flux) { + copy.access = 'proxy'; + copy.basicAuth = true; + copy.jsonData.httpMode = 'POST'; + + // Remove old 1x configs + delete copy.user; + delete copy.database; + } + + onOptionsChange(copy); + }; + + renderInflux2x() { + const { options } = this.props; + const { secureJsonFields } = options; + const secureJsonData = (options.secureJsonData || {}) as InfluxSecureJsonData; + + return ( + <> +
      +
      + Organization +
      + +
      +
      +
      +
      +
      + +
      +
      +
      +
      + Default Bucket +
      + +
      +
      +
      + +
      +
      + + Min time interval + +
      + +
      +
      +
      + + ); + } + + renderInflux1x() { + const { options } = this.props; + const { secureJsonFields } = options; + const secureJsonData = (options.secureJsonData || {}) as InfluxSecureJsonData; + + return ( + <> + +
      Database Access
      +

      + Setting the database for this datasource does not deny access to other databases. The InfluxDB query syntax + allows switching the database in the query. For example: + SHOW MEASUREMENTS ON _internal or + SELECT * FROM "_internal".."database" LIMIT 10 +
      +
      + To support data isolation and security, make sure appropriate permissions are configured in InfluxDB. +

      +
      +
      +
      + Database +
      + +
      +
      +
      +
      +
      + User +
      + +
      +
      +
      +
      +
      + +
      +
      +
      +
      + + HTTP Method + + +
      +
      +
      + + ); + } + + render() { + const { options, onOptionsChange } = this.props; + + return ( + <> +

      Query Language

      +
      +
      +
      + { + // We duplicate this state so that we allow to write freely inside the input. We don't have + // any influence over saving so this seems to be only way to do this. + this.setState({ maxSeries: event.currentTarget.value }); + const val = parseInt(event.currentTarget.value, 10); + updateDatasourcePluginJsonDataOption(this.props, 'maxSeries', Number.isFinite(val) ? val : undefined); + }} + /> + +
      +
      + + ); + } +} + +export default ConfigEditor; diff --git a/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx new file mode 100644 index 0000000..c156372 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx @@ -0,0 +1,213 @@ +import React, { PureComponent } from 'react'; +import coreModule from 'app/core/core_module'; +import { InfluxQuery } from '../types'; +import { SelectableValue, QueryEditorProps } from '@grafana/data'; +import { cx, css } from 'emotion'; +import { + InlineFormLabel, + LinkButton, + Segment, + CodeEditor, + CodeEditorSuggestionItem, + CodeEditorSuggestionItemKind, +} from '@grafana/ui'; +import { getTemplateSrv } from '@grafana/runtime'; +import InfluxDatasource from '../datasource'; + +// @ts-ignore -- complicated since the datasource is not really reactified yet! +type Props = QueryEditorProps; + +const samples: Array> = [ + { label: 'Show buckets', description: 'List the available buckets (table)', value: 'buckets()' }, + { + label: 'Simple query', + description: 'filter by measurement and field', + value: `from(bucket: "db/rp") + |> range(start: v.timeRangeStart, stop:v.timeRangeStop) + |> filter(fn: (r) => + r._measurement == "example-measurement" and + r._field == "example-field" + )`, + }, + { + label: 'Grouped Query', + description: 'Group by (min/max/sum/median)', + value: `// v.windowPeriod is a variable referring to the current optimized window period (currently: $interval) +from(bucket: v.bucket) + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "measurement1" or r["_measurement"] =~ /^.*?regex.*$/) + |> filter(fn: (r) => r["_field"] == "field2" or r["_field"] =~ /^.*?regex.*$/) + |> aggregateWindow(every: v.windowPeriod, fn: mean|median|max|count|derivative|sum) + |> yield(name: "some-name")`, + }, + { + label: 'Filter by value', + description: 'Results between a min/max', + value: `// v.bucket, v.timeRangeStart, and v.timeRange stop are all variables supported by the flux plugin and influxdb +from(bucket: v.bucket) + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_value"] >= 10 and r["_value"] <= 20)`, + }, + { + label: 'Schema Exploration: (measurements)', + description: 'Get a list of measurement using flux', + value: `import "influxdata/influxdb/v1" +v1.measurements(bucket: v.bucket)`, + }, + { + label: 'Schema Exploration: (fields)', + description: 'Return every possible key in a single table', + value: `from(bucket: v.bucket) + |> range(start: v.timeRangeStart, stop:v.timeRangeStop) + |> keys() + |> keep(columns: ["_value"]) + |> group() + |> distinct()`, + }, + { + label: 'Schema Exploration: (tag keys)', + description: 'Get a list of tag keys using flux', + value: `import "influxdata/influxdb/v1" +v1.tagKeys(bucket: v.bucket)`, + }, + { + label: 'Schema Exploration: (tag values)', + description: 'Get a list of tag values using flux', + value: `import "influxdata/influxdb/v1" +v1.tagValues( + bucket: v.bucket, + tag: "host", + predicate: (r) => true, + start: -1d +)`, + }, +]; + +export class FluxQueryEditor extends PureComponent { + onFluxQueryChange = (query: string) => { + this.props.onChange({ ...this.props.query, query }); + this.props.onRunQuery(); + }; + + onSampleChange = (val: SelectableValue) => { + this.props.onChange({ + ...this.props.query, + query: val.value!, + }); + + // Angular HACK: Since the target does not actually change! + this.forceUpdate(); + this.props.onRunQuery(); + }; + + getSuggestions = (): CodeEditorSuggestionItem[] => { + const sugs: CodeEditorSuggestionItem[] = [ + { + label: 'v.timeRangeStart', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'The start time', + }, + { + label: 'v.timeRangeStop', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'The stop time', + }, + { + label: 'v.windowPeriod', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'based on max data points', + }, + { + label: 'v.defaultBucket', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'bucket configured in the datsource', + }, + { + label: 'v.organization', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'org configured for the datsource', + }, + ]; + + const templateSrv = getTemplateSrv(); + templateSrv.getVariables().forEach((variable) => { + const label = '${' + variable.name + '}'; + let val = templateSrv.replace(label); + if (val === label) { + val = ''; + } + sugs.push({ + label, + kind: CodeEditorSuggestionItemKind.Text, + detail: `(Template Variable) ${val}`, + }); + }); + + return sugs; + }; + + // For some reason in angular, when this component gets re-mounted, the width + // is not set properly. This forces the layout shortly after mount so that it + // displays OK. Note: this is not an issue when used directly in react + editorDidMountCallbackHack = (editor: any) => { + setTimeout(() => editor.layout(), 100); + }; + + render() { + const { query } = this.props; + + const helpTooltip = ( +
      + Type: ctrl+space to show template variable suggestions
      + Many queries can be copied from chronograph +
      + ); + + return ( + <> + +
      + + Flux language syntax + + +
      +
      +
      + + Help + +
      + + ); + } +} + +coreModule.directive('fluxQueryEditor', [ + 'reactDirective', + (reactDirective: any) => { + return reactDirective(FluxQueryEditor, ['query', 'onChange', 'onRunQuery']); + }, +]); diff --git a/public/app/plugins/datasource/influxdb/components/InfluxCheatSheet.tsx b/public/app/plugins/datasource/influxdb/components/InfluxCheatSheet.tsx new file mode 100644 index 0000000..7bf562c --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/InfluxCheatSheet.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +const CHEAT_SHEET_ITEMS = [ + { + title: 'Getting started', + label: + 'Start by selecting a measurement and field from the dropdown above. You can then use the tag selector to further narrow your search.', + }, +]; + +const InfluxCheatSheet = (props: any) => ( +
      +

      InfluxDB Cheat Sheet

      + {CHEAT_SHEET_ITEMS.map((item) => ( +
      +
      {item.title}
      +
      {item.label}
      +
      + ))} +
      +); + +export default InfluxCheatSheet; diff --git a/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.test.tsx b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.test.tsx new file mode 100644 index 0000000..30851e8 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { InfluxLogsQueryField, pairsAreValid } from './InfluxLogsQueryField'; +import { InfluxDatasourceMock } from '../datasource.mock'; +import InfluxDatasource from '../datasource'; +import { InfluxQuery } from '../types'; +import { ButtonCascader } from '@grafana/ui'; +import { KeyValuePair } from '../../../../features/explore/AdHocFilterField'; + +describe('pairsAreValid()', () => { + describe('when all pairs are fully defined', () => { + it('should return true', () => { + const pairs = [ + { + key: 'a', + operator: '=', + value: '1', + }, + { + key: 'b', + operator: '!=', + value: '2', + }, + ]; + + expect(pairsAreValid(pairs as any)).toBe(true); + }); + }); + + describe('when no pairs are defined at all', () => { + it('should return true', () => { + expect(pairsAreValid([])).toBe(true); + }); + }); + + describe('when pairs are undefined', () => { + it('should return true', () => { + expect(pairsAreValid((undefined as unknown) as KeyValuePair[])).toBe(true); + }); + }); + + describe('when one or more pairs are only partially defined', () => { + it('should return false', () => { + const pairs = [ + { + key: 'a', + operator: undefined, + value: '1', + }, + { + key: 'b', + operator: '!=', + value: '2', + }, + ]; + + expect(pairsAreValid(pairs as any)).toBe(false); + }); + }); +}); + +describe('InfluxLogsQueryField', () => { + it('should load and show correct measurements and fields in cascader', async () => { + const wrapper = getInfluxLogsQueryField(); + // Looks strange but we do async stuff in didMount and this will push the stack at the end of eval loop, effectively + // waiting for the didMount to finish. + await new Promise((resolve) => setImmediate(resolve)); + wrapper.update(); + const cascader = wrapper.find(ButtonCascader); + expect(cascader.prop('options')).toEqual([ + { label: 'logs', value: 'logs', children: [{ label: 'description', value: 'description', children: [] }] }, + ]); + }); +}); + +function getInfluxLogsQueryField(props?: any) { + const datasource: InfluxDatasource = new InfluxDatasourceMock( + props?.measurements || { + logs: [{ name: 'description', type: 'string' }], + } + ) as any; + + const defaultProps = { + datasource, + history: [] as any[], + onRunQuery: () => {}, + onChange: (query: InfluxQuery) => {}, + query: { + refId: '', + } as InfluxQuery, + }; + return mount( + + ); +} diff --git a/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx new file mode 100644 index 0000000..a70b415 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { ExploreQueryFieldProps } from '@grafana/data'; +import { ButtonCascader, CascaderOption } from '@grafana/ui'; + +import InfluxQueryModel from '../influx_query_model'; +import { AdHocFilterField, KeyValuePair } from 'app/features/explore/AdHocFilterField'; +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; +import InfluxDatasource from '../datasource'; +import { InfluxQueryBuilder } from '../query_builder'; +import { InfluxOptions, InfluxQuery } from '../types'; + +export interface Props extends ExploreQueryFieldProps {} + +export interface State { + measurements: CascaderOption[]; + measurement: string | null; + field: string | null; + error: string | null; +} + +interface ChooserOptions { + measurement: string | null; + field: string | null; + error: string | null; +} + +// Helper function for determining if a collection of pairs are valid +// where a valid pair is either fully defined, or not defined at all, but not partially defined +export function pairsAreValid(pairs: KeyValuePair[]) { + return ( + !pairs || + pairs.every((pair) => { + const allDefined = !!(pair.key && pair.operator && pair.value); + const allEmpty = pair.key === undefined && pair.operator === undefined && pair.value === undefined; + return allDefined || allEmpty; + }) + ); +} + +function getChooserText({ measurement, field, error }: ChooserOptions): string { + if (error) { + return '(No measurement found)'; + } + if (measurement) { + return `Measurements (${measurement}/${field})`; + } + return 'Measurements'; +} + +export class InfluxLogsQueryField extends React.PureComponent { + templateSrv: TemplateSrv = getTemplateSrv(); + state: State = { + measurements: [], + measurement: null, + field: null, + error: null, + }; + + async componentDidMount() { + const { datasource } = this.props; + try { + const queryBuilder = new InfluxQueryBuilder({ measurement: '', tags: [] }, datasource.database); + const measureMentsQuery = queryBuilder.buildExploreQuery('MEASUREMENTS'); + const influxMeasurements = await datasource.metricFindQuery(measureMentsQuery); + + const measurements = []; + for (let index = 0; index < influxMeasurements.length; index++) { + const measurementObj = influxMeasurements[index]; + const queryBuilder = new InfluxQueryBuilder( + { measurement: measurementObj.text, tags: [] }, + datasource.database + ); + const fieldsQuery = queryBuilder.buildExploreQuery('FIELDS'); + const influxFields = await datasource.metricFindQuery(fieldsQuery); + const fields: any[] = influxFields.map((field: any): any => ({ + label: field.text, + value: field.text, + children: [], + })); + measurements.push({ + label: measurementObj.text, + value: measurementObj.text, + children: fields, + }); + } + this.setState({ measurements }); + } catch (error) { + const message = error && error.message ? error.message : error; + console.error(error); + this.setState({ error: message }); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.query.measurement && !this.props.query.measurement) { + this.setState({ measurement: null, field: null }); + } + } + + onMeasurementsChange = async (values: string[]) => { + const { query } = this.props; + const measurement = values[0]; + const field = values[1]; + + this.setState({ measurement, field }, () => { + this.onPairsChanged((query as any).tags); + }); + }; + + onPairsChanged = (pairs: KeyValuePair[]) => { + const { query } = this.props; + const { measurement, field } = this.state; + const queryModel = new InfluxQueryModel( + { + ...query, + resultFormat: 'table', + groupBy: [], + select: [[{ type: 'field', params: [field ?? ''] }]], + tags: pairs, + limit: '1000', + measurement: measurement ?? '', + }, + this.templateSrv + ); + + this.props.onChange(queryModel.target); + + // Only run the query if measurement & field are set, and there are no invalid pairs + if (measurement && field && pairsAreValid(pairs)) { + this.props.onRunQuery(); + } + }; + + render() { + const { datasource } = this.props; + const { measurements, measurement, field, error } = this.state; + const cascadeText = getChooserText({ measurement, field, error }); + const hasMeasurement = measurements && measurements.length > 0; + + return ( +
      +
      + + {cascadeText} + +
      +
      + {measurement && ( + + )} + {error ? ( + {error} + ) : null} +
      +
      + ); + } +} diff --git a/public/app/plugins/datasource/influxdb/components/InfluxStartPage.tsx b/public/app/plugins/datasource/influxdb/components/InfluxStartPage.tsx new file mode 100644 index 0000000..846d015 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/InfluxStartPage.tsx @@ -0,0 +1,9 @@ +import React, { PureComponent } from 'react'; +import { QueryEditorHelpProps } from '@grafana/data'; +import InfluxCheatSheet from './InfluxCheatSheet'; + +export default class InfluxStartPage extends PureComponent { + render() { + return ; + } +} diff --git a/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx new file mode 100644 index 0000000..0ca9b69 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VariableQueryEditor.tsx @@ -0,0 +1,48 @@ +import React, { PureComponent } from 'react'; +import InfluxDatasource from '../datasource'; +import { InlineFormLabel, TextArea } from '@grafana/ui'; +import { FluxQueryEditor } from './FluxQueryEditor'; + +interface Props { + query: string; // before flux, it was always a string + onChange: (query?: string) => void; + datasource: InfluxDatasource; +} + +export default class VariableQueryEditor extends PureComponent { + onRefresh = () => { + // noop + }; + + render() { + let { query, datasource, onChange } = this.props; + if (datasource.isFlux) { + return ( + onChange(v.query)} + /> + ); + } + + return ( +
      + Query +
      + +
      +
      +
      + +
      + +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + +
      +
      +
      + + + + +
      + +
      + +
      + +
      + +
      + +
      +
      +
      +
      + +
      +
      + +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      +
      + + + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      + +
      +
      + +
      + +
      +
      +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      +
      +
      + \ No newline at end of file diff --git a/public/app/plugins/datasource/influxdb/plugin.json b/public/app/plugins/datasource/influxdb/plugin.json new file mode 100644 index 0000000..0c335c6 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/plugin.json @@ -0,0 +1,28 @@ +{ + "type": "datasource", + "name": "InfluxDB", + "id": "influxdb", + "category": "tsdb", + + "defaultMatchFormat": "regex values", + "metrics": true, + "logs": true, + "annotations": true, + "alerting": true, + + "queryOptions": { + "minInterval": true + }, + + "info": { + "description": "Open source time series database", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/influxdb_logo.svg", + "large": "img/influxdb_logo.svg" + } + } +} diff --git a/public/app/plugins/datasource/influxdb/query_builder.ts b/public/app/plugins/datasource/influxdb/query_builder.ts new file mode 100644 index 0000000..948435f --- /dev/null +++ b/public/app/plugins/datasource/influxdb/query_builder.ts @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import kbn from 'app/core/utils/kbn'; + +function renderTagCondition(tag: { operator: any; value: string; condition: any; key: string }, index: number) { + let str = ''; + let operator = tag.operator; + let value = tag.value; + if (index > 0) { + str = (tag.condition || 'AND') + ' '; + } + + if (!operator) { + if (/^\/.*\/$/.test(tag.value)) { + operator = '=~'; + } else { + operator = '='; + } + } + + // quote value unless regex or number + if (operator !== '=~' && operator !== '!~' && isNaN(+value)) { + value = "'" + value + "'"; + } + + return str + '"' + tag.key + '" ' + operator + ' ' + value; +} + +export class InfluxQueryBuilder { + constructor(private target: { measurement: any; tags: any; policy?: any }, private database?: string) {} + + buildExploreQuery(type: string, withKey?: string, withMeasurementFilter?: string): string { + let query = ''; + let measurement; + let policy; + + if (type === 'TAG_KEYS') { + query = 'SHOW TAG KEYS'; + measurement = this.target.measurement; + policy = this.target.policy; + } else if (type === 'TAG_VALUES') { + query = 'SHOW TAG VALUES'; + measurement = this.target.measurement; + policy = this.target.policy; + } else if (type === 'MEASUREMENTS') { + query = 'SHOW MEASUREMENTS'; + if (withMeasurementFilter) { + query += ' WITH MEASUREMENT =~ /' + kbn.regexEscape(withMeasurementFilter) + '/'; + } + } else if (type === 'FIELDS') { + measurement = this.target.measurement; + policy = this.target.policy; + + if (!measurement.match('^/.*/')) { + measurement = '"' + measurement + '"'; + + if (policy && policy !== 'default') { + policy = '"' + policy + '"'; + measurement = policy + '.' + measurement; + } + } + + return 'SHOW FIELD KEYS FROM ' + measurement; + } else if (type === 'RETENTION POLICIES') { + query = 'SHOW RETENTION POLICIES on "' + this.database + '"'; + return query; + } + + if (measurement) { + if (!measurement.match('^/.*/') && !measurement.match(/^merge\(.*\)/)) { + measurement = '"' + measurement + '"'; + } + + if (policy && policy !== 'default') { + policy = '"' + policy + '"'; + measurement = policy + '.' + measurement; + } + + query += ' FROM ' + measurement; + } + + if (withKey) { + query += ' WITH KEY = "' + withKey + '"'; + } + + if (this.target.tags && this.target.tags.length > 0) { + const whereConditions = _.reduce( + this.target.tags, + (memo, tag) => { + // do not add a condition for the key we want to explore for + if (tag.key === withKey) { + return memo; + } + + // value operators not supported in these types of queries + if (tag.operator === '>' || tag.operator === '<') { + return memo; + } + + memo.push(renderTagCondition(tag, memo.length)); + return memo; + }, + [] as string[] + ); + + if (whereConditions.length > 0) { + query += ' WHERE ' + whereConditions.join(' '); + } + } + + if (type === 'MEASUREMENTS') { + query += ' LIMIT 100'; + //Solve issue #2524 by limiting the number of measurements returned + //LIMIT must be after WITH MEASUREMENT and WHERE clauses + //This also could be used for TAG KEYS and TAG VALUES, if desired + } + + return query; + } +} diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts new file mode 100644 index 0000000..3981ea9 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/query_ctrl.ts @@ -0,0 +1,423 @@ +import angular, { auto } from 'angular'; +import _ from 'lodash'; +import { InfluxQueryBuilder } from './query_builder'; +import InfluxQueryModel from './influx_query_model'; +import queryPart from './query_part'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { TemplateSrv } from '@grafana/runtime'; +import { InfluxQuery } from './types'; +import InfluxDatasource from './datasource'; + +export class InfluxQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + datasource: InfluxDatasource; + queryModel: InfluxQueryModel; + queryBuilder: any; + groupBySegment: any; + resultFormats: any[]; + orderByTime: any[]; + policySegment: any; + tagSegments: any[]; + selectMenu: any; + measurementSegment: any; + removeTagFilterSegment: any; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private templateSrv: TemplateSrv, + private uiSegmentSrv: any + ) { + super($scope, $injector); + this.target = this.target; + this.queryModel = new InfluxQueryModel(this.target, templateSrv, this.panel.scopedVars); + this.queryBuilder = new InfluxQueryBuilder(this.target, this.datasource.database); + this.groupBySegment = this.uiSegmentSrv.newPlusButton(); + this.resultFormats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + { text: 'Logs', value: 'logs' }, + ]; + + this.policySegment = uiSegmentSrv.newSegment(this.target.policy); + + if (!this.target.measurement) { + this.measurementSegment = uiSegmentSrv.newSelectMeasurement(); + } else { + this.measurementSegment = uiSegmentSrv.newSegment(this.target.measurement); + } + + this.tagSegments = []; + for (const tag of this.target.tags) { + if (!tag.operator) { + if (/^\/.*\/$/.test(tag.value)) { + tag.operator = '=~'; + } else { + tag.operator = '='; + } + } + + if (tag.condition) { + this.tagSegments.push(uiSegmentSrv.newCondition(tag.condition)); + } + + this.tagSegments.push(uiSegmentSrv.newKey(tag.key)); + this.tagSegments.push(uiSegmentSrv.newOperator(tag.operator)); + this.tagSegments.push(uiSegmentSrv.newKeyValue(tag.value)); + } + + this.fixTagSegments(); + this.buildSelectMenu(); + this.removeTagFilterSegment = uiSegmentSrv.newSegment({ + fake: true, + value: '-- remove tag filter --', + }); + } + + /** + * Only called for flux + */ + onChange = (target: InfluxQuery) => { + this.target.query = target.query; + }; + + onRunQuery = () => { + this.panelCtrl.refresh(); + }; + + removeOrderByTime() { + this.target.orderByTime = 'ASC'; + } + + buildSelectMenu() { + const categories = queryPart.getCategories(); + this.selectMenu = _.reduce( + categories, + (memo, cat, key) => { + const menu = { + text: key, + submenu: cat.map((item: any) => { + return { text: item.type, value: item.type }; + }), + }; + memo.push(menu); + return memo; + }, + [] as any + ); + } + + getGroupByOptions() { + const query = this.queryBuilder.buildExploreQuery('TAG_KEYS'); + + return this.datasource + .metricFindQuery(query) + .then((tags: any) => { + const options = []; + if (!this.queryModel.hasFill()) { + options.push(this.uiSegmentSrv.newSegment({ value: 'fill(null)' })); + } + if (!this.target.limit) { + options.push(this.uiSegmentSrv.newSegment({ value: 'LIMIT' })); + } + if (!this.target.slimit) { + options.push(this.uiSegmentSrv.newSegment({ value: 'SLIMIT' })); + } + if (!this.target.tz) { + options.push(this.uiSegmentSrv.newSegment({ value: 'tz' })); + } + if (this.target.orderByTime === 'ASC') { + options.push(this.uiSegmentSrv.newSegment({ value: 'ORDER BY time DESC' })); + } + if (!this.queryModel.hasGroupByTime()) { + options.push(this.uiSegmentSrv.newSegment({ value: 'time($interval)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ value: 'tag(' + tag.text + ')' })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + groupByAction() { + switch (this.groupBySegment.value) { + case 'LIMIT': { + this.target.limit = 10; + break; + } + case 'SLIMIT': { + this.target.slimit = 10; + break; + } + case 'tz': { + this.target.tz = 'UTC'; + break; + } + case 'ORDER BY time DESC': { + this.target.orderByTime = 'DESC'; + break; + } + default: { + this.queryModel.addGroupBy(this.groupBySegment.value); + } + } + + const plusButton = this.uiSegmentSrv.newPlusButton(); + this.groupBySegment.value = plusButton.value; + this.groupBySegment.html = plusButton.html; + this.groupBySegment.fake = true; + this.panelCtrl.refresh(); + } + + addSelectPart(selectParts: any, cat: any, subitem: { value: any }) { + this.queryModel.addSelectPart(selectParts, subitem.value); + this.panelCtrl.refresh(); + } + + handleSelectPartEvent(selectParts: any, part: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + const fieldsQuery = this.queryBuilder.buildExploreQuery('FIELDS'); + return this.datasource + .metricFindQuery(fieldsQuery) + .then(this.transformToSegments(true)) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.panelCtrl.refresh(); + break; + } + case 'action': { + this.queryModel.removeSelectPart(selectParts, part); + this.panelCtrl.refresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + return Promise.resolve(); + } + + handleGroupByPartEvent(part: any, index: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + const tagsQuery = this.queryBuilder.buildExploreQuery('TAG_KEYS'); + return this.datasource + .metricFindQuery(tagsQuery) + .then(this.transformToSegments(true)) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.panelCtrl.refresh(); + break; + } + case 'action': { + this.queryModel.removeGroupByPart(part, index); + this.panelCtrl.refresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + return Promise.resolve(); + } + + fixTagSegments() { + const count = this.tagSegments.length; + const lastSegment = this.tagSegments[Math.max(count - 1, 0)]; + + if (!lastSegment || lastSegment.type !== 'plus-button') { + this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); + } + } + + measurementChanged() { + this.target.measurement = this.measurementSegment.value; + this.panelCtrl.refresh(); + } + + getPolicySegments() { + const policiesQuery = this.queryBuilder.buildExploreQuery('RETENTION POLICIES'); + return this.datasource + .metricFindQuery(policiesQuery) + .then(this.transformToSegments(false)) + .catch(this.handleQueryError.bind(this)); + } + + policyChanged() { + this.target.policy = this.policySegment.value; + this.panelCtrl.refresh(); + } + + // Only valid for InfluxQL queries + toggleEditorMode() { + if (this.datasource.isFlux) { + return; // nothing + } + + try { + this.target.query = this.queryModel.render(false); + } catch (err) { + console.error('query render error'); + } + this.target.rawQuery = !this.target.rawQuery; + } + + getMeasurements(measurementFilter: any) { + const query = this.queryBuilder.buildExploreQuery('MEASUREMENTS', undefined, measurementFilter); + return this.datasource + .metricFindQuery(query) + .then(this.transformToSegments(true)) + .catch(this.handleQueryError.bind(this)); + } + + handleQueryError(err: any): any[] { + this.error = err.message || 'Failed to issue metric query'; + return []; + } + + transformToSegments(addTemplateVars: any) { + return (results: any) => { + const segments = _.map(results, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (addTemplateVars) { + for (const variable of this.templateSrv.getVariables()) { + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'value', + value: '/^$' + variable.name + '$/', + expandable: true, + }) + ); + } + } + + return segments; + }; + } + + getTagsOrValues(segment: { type: string }, index: number) { + if (segment.type === 'condition') { + return Promise.resolve([this.uiSegmentSrv.newSegment('AND'), this.uiSegmentSrv.newSegment('OR')]); + } + + if (segment.type === 'operator') { + const nextValue = this.tagSegments[index + 1].value; + if (/^\/.*\/$/.test(nextValue)) { + return Promise.resolve(this.uiSegmentSrv.newOperators(['=~', '!~'])); + } else { + return Promise.resolve(this.uiSegmentSrv.newOperators(['=', '!=', '<>', '<', '>'])); + } + } + + let query, addTemplateVars; + if (segment.type === 'key' || segment.type === 'plus-button') { + query = this.queryBuilder.buildExploreQuery('TAG_KEYS'); + addTemplateVars = false; + } else if (segment.type === 'value') { + query = this.queryBuilder.buildExploreQuery('TAG_VALUES', this.tagSegments[index - 2].value); + addTemplateVars = true; + } + + return this.datasource + .metricFindQuery(query) + .then(this.transformToSegments(addTemplateVars)) + .then((results: any) => { + if (segment.type === 'key') { + results.splice(0, 0, angular.copy(this.removeTagFilterSegment)); + } + return results; + }) + .catch(this.handleQueryError.bind(this)); + } + + getFieldSegments() { + const fieldsQuery = this.queryBuilder.buildExploreQuery('FIELDS'); + return this.datasource + .metricFindQuery(fieldsQuery) + .then(this.transformToSegments(false)) + .catch(this.handleQueryError); + } + + tagSegmentUpdated(segment: { value: any; type: string; cssClass: string }, index: number) { + this.tagSegments[index] = segment; + + // handle remove tag condition + if (segment.value === this.removeTagFilterSegment.value) { + this.tagSegments.splice(index, 3); + if (this.tagSegments.length === 0) { + this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); + } else if (this.tagSegments.length > 2) { + this.tagSegments.splice(Math.max(index - 1, 0), 1); + if (this.tagSegments[this.tagSegments.length - 1].type !== 'plus-button') { + this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); + } + } + } else { + if (segment.type === 'plus-button') { + if (index > 2) { + this.tagSegments.splice(index, 0, this.uiSegmentSrv.newCondition('AND')); + } + this.tagSegments.push(this.uiSegmentSrv.newOperator('=')); + this.tagSegments.push(this.uiSegmentSrv.newFake('select tag value', 'value', 'query-segment-value')); + segment.type = 'key'; + segment.cssClass = 'query-segment-key'; + } + + if (index + 1 === this.tagSegments.length) { + this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); + } + } + + this.rebuildTargetTagConditions(); + } + + rebuildTargetTagConditions() { + const tags: any[] = []; + let tagIndex = 0; + let tagOperator: string | null = ''; + + _.each(this.tagSegments, (segment2, index) => { + if (segment2.type === 'key') { + if (tags.length === 0) { + tags.push({}); + } + tags[tagIndex].key = segment2.value; + } else if (segment2.type === 'value') { + tagOperator = this.getTagValueOperator(segment2.value, tags[tagIndex].operator); + if (tagOperator) { + this.tagSegments[index - 1] = this.uiSegmentSrv.newOperator(tagOperator); + tags[tagIndex].operator = tagOperator; + } + tags[tagIndex].value = segment2.value; + } else if (segment2.type === 'condition') { + tags.push({ condition: segment2.value }); + tagIndex += 1; + } else if (segment2.type === 'operator') { + tags[tagIndex].operator = segment2.value; + } + }); + + this.target.tags = tags; + this.panelCtrl.refresh(); + } + + getTagValueOperator(tagValue: string, tagOperator: string): string | null { + if (tagOperator !== '=~' && tagOperator !== '!~' && /^\/.*\/$/.test(tagValue)) { + return '=~'; + } else if ((tagOperator === '=~' || tagOperator === '!~') && /^(?!\/.*\/$)/.test(tagValue)) { + return '='; + } + return null; + } +} diff --git a/public/app/plugins/datasource/influxdb/query_help.md b/public/app/plugins/datasource/influxdb/query_help.md new file mode 100644 index 0000000..4930ccb --- /dev/null +++ b/public/app/plugins/datasource/influxdb/query_help.md @@ -0,0 +1,27 @@ +#### Alias patterns +- replaced with measurement name +- $measurement = replaced with measurement name +- $1 - $9 = replaced with part of measurement name (if you separate your measurement name with dots) +- $col = replaced with column name +- $tag_exampletag = replaced with the value of the exampletag tag +- You can also use [[tag_exampletag]] pattern replacement syntax + +#### Stacking and fill +- When stacking is enabled it is important that points align +- If there are missing points for one series it can cause gaps or missing bars +- You must use fill(0), and select a group by time low limit +- Use the group by time option below your queries and specify for example 10s if your metrics are written every 10 seconds +- This will insert zeros for series that are missing measurements and will make stacking work properly + +#### Group by time +- Group by time is important, otherwise the query could return many thousands of datapoints that will slow down Grafana +- Leave the group by time field empty for each query and it will be calculated based on time range and pixel width of the graph +- If you use fill(0) or fill(null) set a low limit for the auto group by time interval +- The low limit can only be set in the group by time option below your queries +- Example: 60s if you write metrics to InfluxDB every 60 seconds + +#### Documentation links: + +[Grafana's InfluxDB Documentation](http://docs.grafana.org/features/datasources/influxdb) + +[Official InfluxDB Documentation](https://docs.influxdata.com/influxdb) diff --git a/public/app/plugins/datasource/influxdb/query_part.ts b/public/app/plugins/datasource/influxdb/query_part.ts new file mode 100644 index 0000000..a3c807d --- /dev/null +++ b/public/app/plugins/datasource/influxdb/query_part.ts @@ -0,0 +1,460 @@ +import _ from 'lodash'; +import { QueryPartDef, QueryPart, functionRenderer, suffixRenderer } from 'app/core/components/query_part/query_part'; + +const index: any[] = []; +const categories: any = { + Aggregations: [], + Selectors: [], + Transformations: [], + Predictors: [], + Math: [], + Aliasing: [], + Fields: [], +}; + +function createPart(part: any): any { + const def = index[part.type]; + if (!def) { + throw { message: 'Could not find query part ' + part.type }; + } + + return new QueryPart(part, def); +} + +function register(options: any) { + index[options.type] = new QueryPartDef(options); + options.category.push(index[options.type]); +} + +const groupByTimeFunctions: any[] = []; + +function aliasRenderer(part: { params: string[] }, innerExpr: string) { + return innerExpr + ' AS ' + '"' + part.params[0] + '"'; +} + +function fieldRenderer(part: { params: string[] }, innerExpr: any) { + if (part.params[0] === '*') { + return '*'; + } + return '"' + part.params[0] + '"'; +} + +function replaceAggregationAddStrategy(selectParts: any[], partModel: { def: { type: string } }) { + // look for existing aggregation + for (let i = 0; i < selectParts.length; i++) { + const part = selectParts[i]; + if (part.def.category === categories.Aggregations) { + if (part.def.type === partModel.def.type) { + return; + } + // count distinct is allowed + if (part.def.type === 'count' && partModel.def.type === 'distinct') { + break; + } + // remove next aggregation if distinct was replaced + if (part.def.type === 'distinct') { + const morePartsAvailable = selectParts.length >= i + 2; + if (partModel.def.type !== 'count' && morePartsAvailable) { + const nextPart = selectParts[i + 1]; + if (nextPart.def.category === categories.Aggregations) { + selectParts.splice(i + 1, 1); + } + } else if (partModel.def.type === 'count') { + if (!morePartsAvailable || selectParts[i + 1].def.type !== 'count') { + selectParts.splice(i + 1, 0, partModel); + } + return; + } + } + selectParts[i] = partModel; + return; + } + if (part.def.category === categories.Selectors) { + selectParts[i] = partModel; + return; + } + } + + selectParts.splice(1, 0, partModel); +} + +function addTransformationStrategy(selectParts: any[], partModel: any) { + let i; + // look for index to add transformation + for (i = 0; i < selectParts.length; i++) { + const part = selectParts[i]; + if (part.def.category === categories.Math || part.def.category === categories.Aliasing) { + break; + } + } + + selectParts.splice(i, 0, partModel); +} + +function addMathStrategy(selectParts: any[], partModel: any) { + const partCount = selectParts.length; + if (partCount > 0) { + // if last is math, replace it + if (selectParts[partCount - 1].def.type === 'math') { + selectParts[partCount - 1] = partModel; + return; + } + // if next to last is math, replace it + if (partCount > 1 && selectParts[partCount - 2].def.type === 'math') { + selectParts[partCount - 2] = partModel; + return; + } else if (selectParts[partCount - 1].def.type === 'alias') { + // if last is alias add it before + selectParts.splice(partCount - 1, 0, partModel); + return; + } + } + selectParts.push(partModel); +} + +function addAliasStrategy(selectParts: any[], partModel: any) { + const partCount = selectParts.length; + if (partCount > 0) { + // if last is alias, replace it + if (selectParts[partCount - 1].def.type === 'alias') { + selectParts[partCount - 1] = partModel; + return; + } + } + selectParts.push(partModel); +} + +function addFieldStrategy(selectParts: any, partModel: any, query: { selectModels: any[][] }) { + // copy all parts + const parts = _.map(selectParts, (part: any) => { + return createPart({ type: part.def.type, params: _.clone(part.params) }); + }); + + query.selectModels.push(parts); +} + +register({ + type: 'field', + addStrategy: addFieldStrategy, + category: categories.Fields, + params: [{ type: 'field', dynamicLookup: true }], + defaultParams: ['value'], + renderer: fieldRenderer, +}); + +// Aggregations +register({ + type: 'count', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'distinct', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'integral', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'mean', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'median', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'mode', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'sum', + addStrategy: replaceAggregationAddStrategy, + category: categories.Aggregations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +// transformations + +register({ + type: 'derivative', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [ + { + name: 'duration', + type: 'interval', + options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + ], + defaultParams: ['10s'], + renderer: functionRenderer, +}); + +register({ + type: 'spread', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'non_negative_derivative', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [ + { + name: 'duration', + type: 'interval', + options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + ], + defaultParams: ['10s'], + renderer: functionRenderer, +}); + +register({ + type: 'difference', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'non_negative_difference', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'moving_average', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [{ name: 'window', type: 'int', options: [5, 10, 20, 30, 40] }], + defaultParams: [10], + renderer: functionRenderer, +}); + +register({ + type: 'cumulative_sum', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'stddev', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'time', + category: groupByTimeFunctions, + params: [ + { + name: 'interval', + type: 'time', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + ], + defaultParams: ['$__interval'], + renderer: functionRenderer, +}); + +register({ + type: 'fill', + category: groupByTimeFunctions, + params: [ + { + name: 'fill', + type: 'string', + options: ['none', 'null', '0', 'previous', 'linear'], + }, + ], + defaultParams: ['null'], + renderer: functionRenderer, +}); + +register({ + type: 'elapsed', + addStrategy: addTransformationStrategy, + category: categories.Transformations, + params: [ + { + name: 'duration', + type: 'interval', + options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + ], + defaultParams: ['10s'], + renderer: functionRenderer, +}); + +// predictions +register({ + type: 'holt_winters', + addStrategy: addTransformationStrategy, + category: categories.Predictors, + params: [ + { name: 'number', type: 'int', options: [5, 10, 20, 30, 40] }, + { name: 'season', type: 'int', options: [0, 1, 2, 5, 10] }, + ], + defaultParams: [10, 2], + renderer: functionRenderer, +}); + +register({ + type: 'holt_winters_with_fit', + addStrategy: addTransformationStrategy, + category: categories.Predictors, + params: [ + { name: 'number', type: 'int', options: [5, 10, 20, 30, 40] }, + { name: 'season', type: 'int', options: [0, 1, 2, 5, 10] }, + ], + defaultParams: [10, 2], + renderer: functionRenderer, +}); + +// Selectors +register({ + type: 'bottom', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [{ name: 'count', type: 'int' }], + defaultParams: [3], + renderer: functionRenderer, +}); + +register({ + type: 'first', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'last', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'max', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'min', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [], + defaultParams: [], + renderer: functionRenderer, +}); + +register({ + type: 'percentile', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [{ name: 'nth', type: 'int' }], + defaultParams: [95], + renderer: functionRenderer, +}); + +register({ + type: 'top', + addStrategy: replaceAggregationAddStrategy, + category: categories.Selectors, + params: [{ name: 'count', type: 'int' }], + defaultParams: [3], + renderer: functionRenderer, +}); + +register({ + type: 'tag', + category: groupByTimeFunctions, + params: [{ name: 'tag', type: 'string', dynamicLookup: true }], + defaultParams: ['tag'], + renderer: fieldRenderer, +}); + +register({ + type: 'math', + addStrategy: addMathStrategy, + category: categories.Math, + params: [{ name: 'expr', type: 'string' }], + defaultParams: [' / 100'], + renderer: suffixRenderer, +}); + +register({ + type: 'alias', + addStrategy: addAliasStrategy, + category: categories.Aliasing, + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], + renderMode: 'suffix', + renderer: aliasRenderer, +}); + +export default { + create: createPart, + getCategories: () => { + return categories; + }, + replaceAggregationAdd: replaceAggregationAddStrategy, +}; diff --git a/public/app/plugins/datasource/influxdb/response_parser.ts b/public/app/plugins/datasource/influxdb/response_parser.ts new file mode 100644 index 0000000..ff5cb1f --- /dev/null +++ b/public/app/plugins/datasource/influxdb/response_parser.ts @@ -0,0 +1,57 @@ +import _ from 'lodash'; + +export default class ResponseParser { + parse(query: string, results: { results: any }) { + if (!results || results.results.length === 0) { + return []; + } + + const influxResults = results.results[0]; + if (!influxResults.series) { + return []; + } + + const normalizedQuery = query.toLowerCase(); + const isValueFirst = + normalizedQuery.indexOf('show field keys') >= 0 || normalizedQuery.indexOf('show retention policies') >= 0; + + const res = {}; + _.each(influxResults.series, (serie) => { + _.each(serie.values, (value) => { + if (_.isArray(value)) { + // In general, there are 2 possible shapes for the returned value. + // The first one is a two-element array, + // where the first element is somewhat a metadata value: + // the tag name for SHOW TAG VALUES queries, + // the time field for SELECT queries, etc. + // The second shape is an one-element array, + // that is containing an immediate value. + // For example, SHOW FIELD KEYS queries return such shape. + // Note, pre-0.11 versions return + // the second shape for SHOW TAG VALUES queries + // (while the newer versions—first). + + if (isValueFirst) { + addUnique(res, value[0]); + } else if (value[1] !== undefined) { + addUnique(res, value[1]); + } else { + addUnique(res, value[0]); + } + } else { + addUnique(res, value); + } + }); + }); + + // @ts-ignore problems with typings for this _.map only accepts [] but this needs to be object + return _.map(res, (value) => { + // @ts-ignore + return { text: value.toString() }; + }); + } +} + +function addUnique(arr: { [x: string]: any }, value: string | number) { + arr[value] = value; +} diff --git a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts new file mode 100644 index 0000000..d42c54b --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts @@ -0,0 +1,176 @@ +import InfluxDatasource from '../datasource'; + +import { TemplateSrvStub } from 'test/specs/helpers'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { of } from 'rxjs'; +import { FetchResponse } from '@grafana/runtime'; + +//@ts-ignore +const templateSrv = new TemplateSrvStub(); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +describe('InfluxDataSource', () => { + const ctx: any = { + instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'GET' } }, + }; + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + beforeEach(() => { + jest.clearAllMocks(); + ctx.instanceSettings.url = '/api/datasources/proxy/1'; + ctx.ds = new InfluxDatasource(ctx.instanceSettings, templateSrv); + }); + + describe('When issuing metricFindQuery', () => { + const query = 'SELECT max(value) FROM measurement WHERE $timeFilter'; + const queryOptions: any = { + range: { + from: '2018-01-01T00:00:00Z', + to: '2018-01-02T00:00:00Z', + }, + }; + let requestQuery: any, requestMethod: any, requestData: any, response: any; + + beforeEach(async () => { + fetchMock.mockImplementation((req: any) => { + requestMethod = req.method; + requestQuery = req.params.q; + requestData = req.data; + return of({ + data: { + status: 'success', + results: [ + { + series: [ + { + name: 'measurement', + columns: ['name'], + values: [['cpu']], + }, + ], + }, + ], + }, + } as FetchResponse); + }); + + response = await ctx.ds.metricFindQuery(query, queryOptions); + }); + + it('should replace $timefilter', () => { + expect(requestQuery).toMatch('time >= 1514764800000ms and time <= 1514851200000ms'); + }); + + it('should use the HTTP GET method', () => { + expect(requestMethod).toBe('GET'); + }); + + it('should not have any data in request body', () => { + expect(requestData).toBeNull(); + }); + + it('parse response correctly', () => { + expect(response).toEqual([{ text: 'cpu' }]); + }); + }); + + describe('When getting error on 200 after issuing a query', () => { + const queryOptions: any = { + range: { + from: '2018-01-01T00:00:00Z', + to: '2018-01-02T00:00:00Z', + }, + rangeRaw: { + from: '2018-01-01T00:00:00Z', + to: '2018-01-02T00:00:00Z', + }, + targets: [{}], + timezone: 'UTC', + scopedVars: { + interval: { text: '1m', value: '1m' }, + __interval: { text: '1m', value: '1m' }, + __interval_ms: { text: 60000, value: 60000 }, + }, + }; + + it('throws an error', async () => { + fetchMock.mockImplementation((req: any) => { + return of({ + data: { + results: [ + { + error: 'Query timeout', + }, + ], + }, + } as FetchResponse); + }); + + try { + await ctx.ds.query(queryOptions).toPromise(); + } catch (err) { + expect(err.message).toBe('InfluxDB Error: Query timeout'); + } + }); + }); + + describe('InfluxDataSource in POST query mode', () => { + const ctx: any = { + instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'POST' } }, + }; + + beforeEach(() => { + ctx.instanceSettings.url = '/api/datasources/proxy/1'; + ctx.ds = new InfluxDatasource(ctx.instanceSettings, templateSrv); + }); + + describe('When issuing metricFindQuery', () => { + const query = 'SELECT max(value) FROM measurement'; + const queryOptions: any = {}; + let requestMethod: any, requestQueryParameter: any, queryEncoded: any, requestQuery: any; + + beforeEach(async () => { + fetchMock.mockImplementation((req: any) => { + requestMethod = req.method; + requestQueryParameter = req.params; + requestQuery = req.data; + return of({ + data: { + results: [ + { + series: [ + { + name: 'measurement', + columns: ['max'], + values: [[1]], + }, + ], + }, + ], + }, + } as FetchResponse); + }); + + queryEncoded = await ctx.ds.serializeParams({ q: query }); + await ctx.ds.metricFindQuery(query, queryOptions).then(() => {}); + }); + + it('should have the query form urlencoded', () => { + expect(requestQuery).toBe(queryEncoded); + }); + + it('should use the HTTP POST method', () => { + expect(requestMethod).toBe('POST'); + }); + + it('should not have q as a query parameter', () => { + expect(requestQueryParameter).not.toHaveProperty('q'); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/influx_query_model.test.ts b/public/app/plugins/datasource/influxdb/specs/influx_query_model.test.ts new file mode 100644 index 0000000..1e9ff1b --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/influx_query_model.test.ts @@ -0,0 +1,398 @@ +import InfluxQueryModel from '../influx_query_model'; + +describe('InfluxQuery', () => { + const templateSrv: any = { replace: (val: any) => val }; + + describe('render series with mesurement only', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe('SELECT mean("value") FROM "cpu" WHERE $timeFilter GROUP BY time($__interval) fill(null)'); + }); + }); + + describe('render series with policy only', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + policy: '5m_avg', + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") FROM "5m_avg"."cpu" WHERE $timeFilter GROUP BY time($__interval) fill(null)' + ); + }); + }); + + describe('render series with math and alias', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [ + [ + { type: 'field', params: ['value'] }, + { type: 'mean', params: [] }, + { type: 'math', params: ['/100'] }, + { type: 'alias', params: ['text'] }, + ], + ], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") /100 AS "text" FROM "cpu" WHERE $timeFilter GROUP BY time($__interval) fill(null)' + ); + }); + }); + + describe('series with single tag only', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time', params: ['auto'] }], + tags: [{ key: 'hostname', value: 'server\\1' }], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + + expect(queryText).toBe( + 'SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server\\\\1\') AND $timeFilter' + + ' GROUP BY time($__interval)' + ); + }); + + it('should switch regex operator with tag value is regex', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time', params: ['auto'] }], + tags: [{ key: 'app', value: '/e.*/' }], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") FROM "cpu" WHERE ("app" =~ /e.*/) AND $timeFilter GROUP BY time($__interval)' + ); + }); + }); + + describe('series with multiple tags only', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time', params: ['auto'] }], + tags: [ + { key: 'hostname', value: 'server1' }, + { key: 'app', value: 'email', condition: 'AND' }, + ], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server1\' AND "app" = \'email\') AND ' + + '$timeFilter GROUP BY time($__interval)' + ); + }); + }); + + describe('series with tags OR condition', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time', params: ['auto'] }], + tags: [ + { key: 'hostname', value: 'server1' }, + { key: 'hostname', value: 'server2', condition: 'OR' }, + ], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") FROM "cpu" WHERE ("hostname" = \'server1\' OR "hostname" = \'server2\') AND ' + + '$timeFilter GROUP BY time($__interval)' + ); + }); + }); + + describe('field name with single quote should be escaped and', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time', params: ['auto'] }], + tags: [ + { key: 'name', value: "Let's encrypt." }, + { key: 'hostname', value: 'server2', condition: 'OR' }, + ], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe( + 'SELECT mean("value") FROM "cpu" WHERE ("name" = \'Let\\\'s encrypt.\' OR "hostname" = \'server2\') AND ' + + '$timeFilter GROUP BY time($__interval)' + ); + }); + }); + + describe('query with value condition', () => { + it('should not quote value', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [], + tags: [{ key: 'value', value: '5', operator: '>' }], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe('SELECT mean("value") FROM "cpu" WHERE ("value" > 5) AND $timeFilter'); + }); + }); + + describe('series with groupByTag', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + tags: [], + groupBy: [ + { type: 'time', interval: 'auto' }, + { type: 'tag', params: ['host'] }, + ], + }, + templateSrv, + {} + ); + + const queryText = query.render(); + expect(queryText).toBe('SELECT mean("value") FROM "cpu" WHERE $timeFilter GROUP BY time($__interval), "host"'); + }); + }); + + describe('render series without group by', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }]], + groupBy: [], + }, + templateSrv, + {} + ); + const queryText = query.render(); + expect(queryText).toBe('SELECT "value" FROM "cpu" WHERE $timeFilter'); + }); + }); + + describe('render series without group by and fill', () => { + it('should generate correct query', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }]], + groupBy: [{ type: 'time' }, { type: 'fill', params: ['0'] }], + }, + templateSrv, + {} + ); + const queryText = query.render(); + expect(queryText).toBe('SELECT "value" FROM "cpu" WHERE $timeFilter GROUP BY time($__interval) fill(0)'); + }); + }); + + describe('when adding group by part', () => { + it('should add tag before fill', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [{ type: 'time' }, { type: 'fill' }], + }, + templateSrv, + {} + ); + + query.addGroupBy('tag(host)'); + expect(query.target.groupBy?.length).toBe(3); + expect(query.target.groupBy![1].type).toBe('tag'); + expect(query.target.groupBy![1].params![0]).toBe('host'); + expect(query.target.groupBy![2].type).toBe('fill'); + }); + + it('should add tag last if no fill', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + groupBy: [], + }, + templateSrv, + {} + ); + + query.addGroupBy('tag(host)'); + expect(query.target.groupBy?.length).toBe(1); + expect(query.target.groupBy![0].type).toBe('tag'); + }); + }); + + describe('when adding select part', () => { + it('should add mean after after field', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'mean'); + expect(query.target.select![0].length).toBe(2); + expect(query.target.select![0][1].type).toBe('mean'); + }); + + it('should replace sum by mean', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'sum'); + expect(query.target.select![0].length).toBe(2); + expect(query.target.select![0][1].type).toBe('sum'); + }); + + it('should add math before alias', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'alias' }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'math'); + expect(query.target.select![0].length).toBe(4); + expect(query.target.select![0][2].type).toBe('math'); + }); + + it('should add math last', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'math'); + expect(query.target.select![0].length).toBe(3); + expect(query.target.select![0][2].type).toBe('math'); + }); + + it('should replace math', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'math' }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'math'); + expect(query.target.select![0].length).toBe(3); + expect(query.target.select![0][2].type).toBe('math'); + }); + + it('should add math when one only query part', () => { + const query = new InfluxQueryModel( + { + refId: 'A', + measurement: 'cpu', + select: [[{ type: 'field', params: ['value'] }]], + }, + templateSrv, + {} + ); + + query.addSelectPart(query.selectModels[0], 'math'); + expect(query.target.select![0].length).toBe(2); + expect(query.target.select![0][1].type).toBe('math'); + }); + + describe('when render adhoc filters', () => { + it('should generate correct query segment', () => { + const query = new InfluxQueryModel({ refId: 'A', measurement: 'cpu' }, templateSrv, {}); + + const queryText = query.renderAdhocFilters([ + { key: 'key1', operator: '=', value: 'value1' }, + { key: 'key2', operator: '!=', value: 'value2' }, + ]); + + expect(queryText).toBe('"key1" = \'value1\' AND "key2" != \'value2\''); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/influx_series.test.ts b/public/app/plugins/datasource/influxdb/specs/influx_series.test.ts new file mode 100644 index 0000000..bf7b803 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/influx_series.test.ts @@ -0,0 +1,296 @@ +import InfluxSeries from '../influx_series'; + +describe('when generating timeseries from influxdb response', () => { + describe('given multiple fields for series', () => { + const options = { + alias: '', + series: [ + { + name: 'cpu', + tags: { app: 'test', server: 'server1' }, + columns: ['time', 'mean', 'max', 'min'], + values: [ + [1431946625000, 10, 11, 9], + [1431946626000, 20, 21, 19], + ], + }, + ], + }; + describe('and no alias', () => { + it('should generate multiple datapoints for each column', () => { + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result.length).toBe(3); + expect(result[0].target).toBe('cpu.mean {app: test, server: server1}'); + expect(result[0].datapoints[0][0]).toBe(10); + expect(result[0].datapoints[0][1]).toBe(1431946625000); + expect(result[0].datapoints[1][0]).toBe(20); + expect(result[0].datapoints[1][1]).toBe(1431946626000); + + expect(result[1].target).toBe('cpu.max {app: test, server: server1}'); + expect(result[1].datapoints[0][0]).toBe(11); + expect(result[1].datapoints[0][1]).toBe(1431946625000); + expect(result[1].datapoints[1][0]).toBe(21); + expect(result[1].datapoints[1][1]).toBe(1431946626000); + + expect(result[2].target).toBe('cpu.min {app: test, server: server1}'); + expect(result[2].datapoints[0][0]).toBe(9); + expect(result[2].datapoints[0][1]).toBe(1431946625000); + expect(result[2].datapoints[1][0]).toBe(19); + expect(result[2].datapoints[1][1]).toBe(1431946626000); + }); + }); + + describe('and simple alias', () => { + it('should use alias', () => { + options.alias = 'new series'; + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('new series'); + expect(result[1].target).toBe('new series'); + expect(result[2].target).toBe('new series'); + }); + }); + + describe('and alias patterns', () => { + it('should replace patterns', () => { + options.alias = 'alias: $m -> $tag_server ([[measurement]])'; + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('alias: cpu -> server1 (cpu)'); + expect(result[1].target).toBe('alias: cpu -> server1 (cpu)'); + expect(result[2].target).toBe('alias: cpu -> server1 (cpu)'); + }); + }); + }); + + describe('given measurement with default fieldname', () => { + const options = { + series: [ + { + name: 'cpu', + tags: { app: 'test', server: 'server1' }, + columns: ['time', 'value'], + values: [ + ['2015-05-18T10:57:05Z', 10], + ['2015-05-18T10:57:06Z', 12], + ], + }, + { + name: 'cpu', + tags: { app: 'test2', server: 'server2' }, + columns: ['time', 'value'], + values: [ + ['2015-05-18T10:57:05Z', 15], + ['2015-05-18T10:57:06Z', 16], + ], + }, + ], + }; + + describe('and no alias', () => { + it('should generate label with no field', () => { + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('cpu {app: test, server: server1}'); + expect(result[1].target).toBe('cpu {app: test2, server: server2}'); + }); + }); + }); + + describe('given two series', () => { + const options = { + alias: '', + series: [ + { + name: 'cpu', + tags: { app: 'test', server: 'server1' }, + columns: ['time', 'mean'], + values: [ + [1431946625000, 10], + [1431946626000, 12], + ], + }, + { + name: 'cpu', + tags: { app: 'test2', server: 'server2' }, + columns: ['time', 'mean'], + values: [ + [1431946625000, 15], + [1431946626000, 16], + ], + }, + ], + }; + + describe('and no alias', () => { + it('should generate two time series', () => { + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result.length).toBe(2); + expect(result[0].target).toBe('cpu.mean {app: test, server: server1}'); + expect(result[0].datapoints[0][0]).toBe(10); + expect(result[0].datapoints[0][1]).toBe(1431946625000); + expect(result[0].datapoints[1][0]).toBe(12); + expect(result[0].datapoints[1][1]).toBe(1431946626000); + + expect(result[1].target).toBe('cpu.mean {app: test2, server: server2}'); + expect(result[1].datapoints[0][0]).toBe(15); + expect(result[1].datapoints[0][1]).toBe(1431946625000); + expect(result[1].datapoints[1][0]).toBe(16); + expect(result[1].datapoints[1][1]).toBe(1431946626000); + }); + }); + + describe('and simple alias', () => { + it('should use alias', () => { + options.alias = 'new series'; + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('new series'); + }); + }); + + describe('and alias patterns', () => { + it('should replace patterns', () => { + options.alias = 'alias: $m -> $tag_server ([[measurement]])'; + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('alias: cpu -> server1 (cpu)'); + expect(result[1].target).toBe('alias: cpu -> server2 (cpu)'); + }); + }); + }); + + describe('given measurement with dots', () => { + const options = { + alias: '', + series: [ + { + name: 'app.prod.server1.count', + tags: {}, + columns: ['time', 'mean'], + values: [ + [1431946625000, 10], + [1431946626000, 12], + ], + }, + ], + }; + + it('should replace patterns', () => { + options.alias = 'alias: $1 -> [[3]]'; + const series = new InfluxSeries(options); + const result = series.getTimeSeries(); + + expect(result[0].target).toBe('alias: prod -> count'); + }); + }); + + describe('given table response', () => { + const options = { + alias: '', + series: [ + { + name: 'app.prod.server1.count', + tags: { datacenter: 'Africa', server: 'server2' }, + columns: ['time', 'value2', 'value'], + values: [ + [1431946625000, 23, 10], + [1431946626000, 25, 12], + ], + }, + ], + }; + + it('should return table', () => { + const series = new InfluxSeries(options); + const table = series.getTable(); + + expect(table.type).toBe('table'); + expect(table.columns.length).toBe(5); + expect(table.columns[0].text).toEqual('Time'); + expect(table.rows[0]).toEqual([1431946625000, 'Africa', 'server2', 23, 10]); + }); + }); + + describe('given table response from SHOW CARDINALITY', () => { + const options = { + alias: '', + series: [ + { + name: 'cpu', + columns: ['count'], + values: [[37]], + }, + ], + }; + + it('should return table', () => { + const series = new InfluxSeries(options); + const table = series.getTable(); + + expect(table.type).toBe('table'); + expect(table.columns.length).toBe(1); + expect(table.columns[0].text).toEqual('count'); + expect(table.rows[0]).toEqual([37]); + }); + }); + + describe('given annotation response', () => { + describe('with empty tagsColumn', () => { + const options = { + alias: '', + annotation: {}, + series: [ + { + name: 'logins.count', + tags: { datacenter: 'Africa', server: 'server2' }, + columns: ['time', 'datacenter', 'hostname', 'source', 'value'], + values: [[1481549440372, 'America', '10.1.100.10', 'backend', 215.7432653659507]], + }, + ], + }; + + it('should multiple tags', () => { + const series = new InfluxSeries(options); + const annotations = series.getAnnotations(); + + expect(annotations[0].tags.length).toBe(0); + }); + }); + + describe('given annotation response', () => { + const options = { + alias: '', + annotation: { + tagsColumn: 'datacenter, source', + }, + series: [ + { + name: 'logins.count', + tags: { datacenter: 'Africa', server: 'server2' }, + columns: ['time', 'datacenter', 'hostname', 'source', 'value'], + values: [[1481549440372, 'America', '10.1.100.10', 'backend', 215.7432653659507]], + }, + ], + }; + + it('should multiple tags', () => { + const series = new InfluxSeries(options); + const annotations = series.getAnnotations(); + + expect(annotations[0].tags.length).toBe(2); + expect(annotations[0].tags[0]).toBe('America'); + expect(annotations[0].tags[1]).toBe('backend'); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts b/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts new file mode 100644 index 0000000..6543d5a --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts @@ -0,0 +1,163 @@ +import { InfluxQueryBuilder } from '../query_builder'; + +describe('InfluxQueryBuilder', () => { + describe('when building explore queries', () => { + it('should only have measurement condition in tag keys query given query with measurement', () => { + const builder = new InfluxQueryBuilder({ measurement: 'cpu', tags: [] }); + const query = builder.buildExploreQuery('TAG_KEYS'); + expect(query).toBe('SHOW TAG KEYS FROM "cpu"'); + }); + + it('should handle regex measurement in tag keys query', () => { + const builder = new InfluxQueryBuilder({ + measurement: '/.*/', + tags: [], + }); + const query = builder.buildExploreQuery('TAG_KEYS'); + expect(query).toBe('SHOW TAG KEYS FROM /.*/'); + }); + + it('should have no conditions in tags keys query given query with no measurement or tag', () => { + const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); + const query = builder.buildExploreQuery('TAG_KEYS'); + expect(query).toBe('SHOW TAG KEYS'); + }); + + it('should have where condition in tag keys query with tags', () => { + const builder = new InfluxQueryBuilder({ + measurement: '', + tags: [{ key: 'host', value: 'se1' }], + }); + const query = builder.buildExploreQuery('TAG_KEYS'); + expect(query).toBe('SHOW TAG KEYS WHERE "host" = \'se1\''); + }); + + it('should ignore condition if operator is a value operator', () => { + const builder = new InfluxQueryBuilder({ + measurement: '', + tags: [{ key: 'value', value: '10', operator: '>' }], + }); + const query = builder.buildExploreQuery('TAG_KEYS'); + expect(query).toBe('SHOW TAG KEYS'); + }); + + it('should have no conditions in measurement query for query with no tags', () => { + const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); + const query = builder.buildExploreQuery('MEASUREMENTS'); + expect(query).toBe('SHOW MEASUREMENTS LIMIT 100'); + }); + + it('should have no conditions in measurement query for query with no tags and empty query', () => { + const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); + const query = builder.buildExploreQuery('MEASUREMENTS', undefined, ''); + expect(query).toBe('SHOW MEASUREMENTS LIMIT 100'); + }); + + it('should have WITH MEASUREMENT in measurement query for non-empty query with no tags', () => { + const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); + const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'something'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ LIMIT 100'); + }); + + it('should escape the regex value in measurement query', () => { + const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); + const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'abc/edf/'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /abc\\/edf\\// LIMIT 100'); + }); + + it('should have WITH MEASUREMENT WHERE in measurement query for non-empty query with tags', () => { + const builder = new InfluxQueryBuilder({ + measurement: '', + tags: [{ key: 'app', value: 'email' }], + }); + const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'something'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ WHERE "app" = \'email\' LIMIT 100'); + }); + + it('should have where condition in measurement query for query with tags', () => { + const builder = new InfluxQueryBuilder({ + measurement: '', + tags: [{ key: 'app', value: 'email' }], + }); + const query = builder.buildExploreQuery('MEASUREMENTS'); + expect(query).toBe('SHOW MEASUREMENTS WHERE "app" = \'email\' LIMIT 100'); + }); + + it('should have where tag name IN filter in tag values query for query with one tag', () => { + const builder = new InfluxQueryBuilder({ + measurement: '', + tags: [{ key: 'app', value: 'asdsadsad' }], + }); + const query = builder.buildExploreQuery('TAG_VALUES', 'app'); + expect(query).toBe('SHOW TAG VALUES WITH KEY = "app"'); + }); + + it('should have measurement tag condition and tag name IN filter in tag values query', () => { + const builder = new InfluxQueryBuilder({ + measurement: 'cpu', + tags: [ + { key: 'app', value: 'email' }, + { key: 'host', value: 'server1' }, + ], + }); + const query = builder.buildExploreQuery('TAG_VALUES', 'app'); + expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app" WHERE "host" = \'server1\''); + }); + + it('should select from policy correctly if policy is specified', () => { + const builder = new InfluxQueryBuilder({ + measurement: 'cpu', + policy: 'one_week', + tags: [ + { key: 'app', value: 'email' }, + { key: 'host', value: 'server1' }, + ], + }); + const query = builder.buildExploreQuery('TAG_VALUES', 'app'); + expect(query).toBe('SHOW TAG VALUES FROM "one_week"."cpu" WITH KEY = "app" WHERE "host" = \'server1\''); + }); + + it('should not include policy when policy is default', () => { + const builder = new InfluxQueryBuilder({ + measurement: 'cpu', + policy: 'default', + tags: [], + }); + const query = builder.buildExploreQuery('TAG_VALUES', 'app'); + expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app"'); + }); + + it('should switch to regex operator in tag condition', () => { + const builder = new InfluxQueryBuilder({ + measurement: 'cpu', + tags: [{ key: 'host', value: '/server.*/' }], + }); + const query = builder.buildExploreQuery('TAG_VALUES', 'app'); + expect(query).toBe('SHOW TAG VALUES FROM "cpu" WITH KEY = "app" WHERE "host" =~ /server.*/'); + }); + + it('should build show field query', () => { + const builder = new InfluxQueryBuilder({ + measurement: 'cpu', + tags: [{ key: 'app', value: 'email' }], + }); + const query = builder.buildExploreQuery('FIELDS'); + expect(query).toBe('SHOW FIELD KEYS FROM "cpu"'); + }); + + it('should build show field query with regexp', () => { + const builder = new InfluxQueryBuilder({ + measurement: '/$var/', + tags: [{ key: 'app', value: 'email' }], + }); + const query = builder.buildExploreQuery('FIELDS'); + expect(query).toBe('SHOW FIELD KEYS FROM /$var/'); + }); + + it('should build show retention policies query', () => { + const builder = new InfluxQueryBuilder({ measurement: 'cpu', tags: [] }, 'site'); + const query = builder.buildExploreQuery('RETENTION POLICIES'); + expect(query).toBe('SHOW RETENTION POLICIES on "site"'); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts b/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts new file mode 100644 index 0000000..b127396 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts @@ -0,0 +1,178 @@ +import { uiSegmentSrv } from 'app/core/services/segment_srv'; +import { InfluxQueryCtrl } from '../query_ctrl'; +import InfluxDatasource from '../datasource'; + +describe('InfluxDBQueryCtrl', () => { + const ctx = {} as any; + + beforeEach(() => { + InfluxQueryCtrl.prototype.datasource = ({ + metricFindQuery: () => Promise.resolve([]), + } as unknown) as InfluxDatasource; + InfluxQueryCtrl.prototype.target = { target: {} }; + InfluxQueryCtrl.prototype.panelCtrl = { + panel: { + targets: [InfluxQueryCtrl.prototype.target], + }, + refresh: () => {}, + }; + + ctx.ctrl = new InfluxQueryCtrl( + {}, + {} as any, + {} as any, + //@ts-ignore + new uiSegmentSrv({ trustAsHtml: (html: any) => html }, { highlightVariablesAsHtml: () => {} }) + ); + }); + + describe('init', () => { + it('should init tagSegments', () => { + expect(ctx.ctrl.tagSegments.length).toBe(1); + }); + + it('should init measurementSegment', () => { + expect(ctx.ctrl.measurementSegment.value).toBe('select measurement'); + }); + }); + + describe('when first tag segment is updated', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + }); + + it('should update tag key', () => { + expect(ctx.ctrl.target.tags[0].key).toBe('asd'); + expect(ctx.ctrl.tagSegments[0].type).toBe('key'); + }); + + it('should add tagSegments', () => { + expect(ctx.ctrl.tagSegments.length).toBe(3); + }); + }); + + describe('when last tag value segment is updated', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + }); + + it('should update tag value', () => { + expect(ctx.ctrl.target.tags[0].value).toBe('server1'); + }); + + it('should set tag operator', () => { + expect(ctx.ctrl.target.tags[0].operator).toBe('='); + }); + + it('should add plus button for another filter', () => { + expect(ctx.ctrl.tagSegments[3].fake).toBe(true); + }); + }); + + describe('when last tag value segment is updated to regex', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: '/server.*/', type: 'value' }, 2); + }); + + it('should update operator', () => { + expect(ctx.ctrl.tagSegments[1].value).toBe('=~'); + expect(ctx.ctrl.target.tags[0].operator).toBe('=~'); + }); + }); + + describe('when second tag key is added', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); + }); + + it('should update tag key', () => { + expect(ctx.ctrl.target.tags[1].key).toBe('key2'); + }); + + it('should add AND segment', () => { + expect(ctx.ctrl.tagSegments[3].value).toBe('AND'); + }); + }); + + describe('when condition is changed', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); + ctx.ctrl.tagSegmentUpdated({ value: 'OR', type: 'condition' }, 3); + }); + + it('should update tag condition', () => { + expect(ctx.ctrl.target.tags[1].condition).toBe('OR'); + }); + + it('should update AND segment', () => { + expect(ctx.ctrl.tagSegments[3].value).toBe('OR'); + expect(ctx.ctrl.tagSegments.length).toBe(7); + }); + }); + + describe('when deleting first tag filter after value is selected', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 0); + }); + + it('should remove tags', () => { + expect(ctx.ctrl.target.tags.length).toBe(0); + }); + + it('should remove all segment after 2 and replace with plus button', () => { + expect(ctx.ctrl.tagSegments.length).toBe(1); + expect(ctx.ctrl.tagSegments[0].type).toBe('plus-button'); + }); + }); + + describe('when deleting second tag value before second tag value is complete', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); + ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); + }); + + it('should remove all segment after 2 and replace with plus button', () => { + expect(ctx.ctrl.tagSegments.length).toBe(4); + expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); + }); + }); + + describe('when deleting second tag value before second tag value is complete', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); + ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); + }); + + it('should remove all segment after 2 and replace with plus button', () => { + expect(ctx.ctrl.tagSegments.length).toBe(4); + expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); + }); + }); + + describe('when deleting second tag value after second tag filter is complete', () => { + beforeEach(() => { + ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); + ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); + ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); + ctx.ctrl.tagSegmentUpdated({ value: 'value', type: 'value' }, 6); + ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); + }); + + it('should remove all segment after 2 and replace with plus button', () => { + expect(ctx.ctrl.tagSegments.length).toBe(4); + expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/query_part.test.ts b/public/app/plugins/datasource/influxdb/specs/query_part.test.ts new file mode 100644 index 0000000..264c695 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/query_part.test.ts @@ -0,0 +1,188 @@ +import queryPart from '../query_part'; + +describe('InfluxQueryPart', () => { + describe('series with measurement only', () => { + it('should handle nested function parts', () => { + const part = queryPart.create({ + type: 'derivative', + params: ['10s'], + }); + + expect(part.text).toBe('derivative(10s)'); + expect(part.render('mean(value)')).toBe('derivative(mean(value), 10s)'); + }); + + it('should nest spread function', () => { + const part = queryPart.create({ + type: 'spread', + }); + + expect(part.text).toBe('spread()'); + expect(part.render('value')).toBe('spread(value)'); + }); + + it('should handle suffix parts', () => { + const part = queryPart.create({ + type: 'math', + params: ['/ 100'], + }); + + expect(part.text).toBe('math(/ 100)'); + expect(part.render('mean(value)')).toBe('mean(value) / 100'); + }); + + it('should handle alias parts', () => { + const part = queryPart.create({ + type: 'alias', + params: ['test'], + }); + + expect(part.text).toBe('alias(test)'); + expect(part.render('mean(value)')).toBe('mean(value) AS "test"'); + }); + + it('should nest distinct when count is selected', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }); + + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('distinct()'); + expect(selectParts[2].text).toBe('count()'); + }); + + it('should convert to count distinct when distinct is selected and count added', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }); + + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('distinct()'); + expect(selectParts[2].text).toBe('count()'); + }); + + it('should replace count distinct if an aggregation is selected', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }), + queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'mean', + category: queryPart.getCategories().Selectors, + }); + + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('mean()'); + expect(selectParts).toHaveLength(2); + }); + + it('should not allowed nested counts when count distinct is selected', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }), + queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }); + + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('distinct()'); + expect(selectParts[2].text).toBe('count()'); + expect(selectParts).toHaveLength(3); + }); + + it('should not remove count distinct when distinct is added', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }), + queryPart.create({ + type: 'count', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }); + + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('distinct()'); + expect(selectParts[2].text).toBe('count()'); + expect(selectParts).toHaveLength(3); + }); + + it('should remove distinct when sum aggregation is selected', () => { + const selectParts = [ + queryPart.create({ + type: 'field', + category: queryPart.getCategories().Fields, + }), + queryPart.create({ + type: 'distinct', + category: queryPart.getCategories().Aggregations, + }), + ]; + const partModel = queryPart.create({ + type: 'sum', + category: queryPart.getCategories().Aggregations, + }); + queryPart.replaceAggregationAdd(selectParts, partModel); + + expect(selectParts[1].text).toBe('sum()'); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/specs/response_parser.test.ts b/public/app/plugins/datasource/influxdb/specs/response_parser.test.ts new file mode 100644 index 0000000..e2c5595 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/specs/response_parser.test.ts @@ -0,0 +1,172 @@ +import _ from 'lodash'; +import ResponseParser from '../response_parser'; + +describe('influxdb response parser', () => { + const parser = new ResponseParser(); + + describe('SHOW TAG response', () => { + const query = 'SHOW TAG KEYS FROM "cpu"'; + const response = { + results: [ + { + series: [ + { + name: 'cpu', + columns: ['tagKey'], + values: [['datacenter'], ['hostname'], ['source']], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('expects three results', () => { + expect(_.size(result)).toBe(3); + }); + }); + + describe('SHOW TAG VALUES response', () => { + const query = 'SHOW TAG VALUES FROM "cpu" WITH KEY = "hostname"'; + + describe('response from 0.10.0', () => { + const response = { + results: [ + { + series: [ + { + name: 'hostnameTagValues', + columns: ['hostname'], + values: [['server1'], ['server2'], ['server2']], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('should get two responses', () => { + expect(_.size(result)).toBe(2); + expect(result[0].text).toBe('server1'); + expect(result[1].text).toBe('server2'); + }); + }); + + describe('response from 0.12.0', () => { + const response = { + results: [ + { + series: [ + { + name: 'cpu', + columns: ['key', 'value'], + values: [ + ['source', 'site'], + ['source', 'api'], + ], + }, + { + name: 'logins', + columns: ['key', 'value'], + values: [ + ['source', 'site'], + ['source', 'webapi'], + ], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('should get two responses', () => { + expect(_.size(result)).toBe(3); + expect(result[0].text).toBe('site'); + expect(result[1].text).toBe('api'); + expect(result[2].text).toBe('webapi'); + }); + }); + }); + + describe('SELECT response', () => { + const query = 'SELECT "usage_iowait" FROM "cpu" LIMIT 10'; + const response = { + results: [ + { + series: [ + { + name: 'cpu', + columns: ['time', 'usage_iowait'], + values: [ + [1488465190006040638, 0.0], + [1488465190006040638, 15.0], + [1488465190006040638, 20.2], + ], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('should return second column', () => { + expect(_.size(result)).toBe(3); + expect(result[0].text).toBe('0'); + expect(result[1].text).toBe('15'); + expect(result[2].text).toBe('20.2'); + }); + }); + + describe('SHOW FIELD response', () => { + const query = 'SHOW FIELD KEYS FROM "cpu"'; + + describe('response from pre-1.0', () => { + const response = { + results: [ + { + series: [ + { + name: 'cpu', + columns: ['fieldKey'], + values: [['value']], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('should get two responses', () => { + expect(_.size(result)).toBe(1); + }); + }); + + describe('response from 1.0', () => { + const response = { + results: [ + { + series: [ + { + name: 'cpu', + columns: ['fieldKey', 'fieldType'], + values: [['time', 'float']], + }, + ], + }, + ], + }; + + const result = parser.parse(query, response); + + it('should return first column', () => { + expect(_.size(result)).toBe(1); + expect(result[0].text).toBe('time'); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/types.ts b/public/app/plugins/datasource/influxdb/types.ts new file mode 100644 index 0000000..929f732 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/types.ts @@ -0,0 +1,55 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export enum InfluxVersion { + InfluxQL = 'InfluxQL', + Flux = 'Flux', +} + +export interface InfluxOptions extends DataSourceJsonData { + version?: InfluxVersion; + + timeInterval: string; + httpMode: string; + + // With Flux + organization?: string; + defaultBucket?: string; + maxSeries?: number; +} + +export interface InfluxSecureJsonData { + // For Flux + token?: string; + + // In 1x a different password can be sent than then HTTP auth + password?: string; +} + +export interface InfluxQueryPart { + type: string; + params?: string[]; + interval?: string; +} + +export interface InfluxQueryTag { + key: string; + operator?: string; + condition?: string; + value: string; +} + +export interface InfluxQuery extends DataQuery { + policy?: string; + measurement?: string; + resultFormat?: 'time_series' | 'table'; + orderByTime?: string; + tags?: InfluxQueryTag[]; + groupBy?: InfluxQueryPart[]; + select?: InfluxQueryPart[][]; + limit?: string; + slimit?: string; + tz?: string; + fill?: string; + rawQuery?: boolean; + query?: string; +} diff --git a/public/app/plugins/datasource/jaeger/ConfigEditor.tsx b/public/app/plugins/datasource/jaeger/ConfigEditor.tsx new file mode 100644 index 0000000..29d02ee --- /dev/null +++ b/public/app/plugins/datasource/jaeger/ConfigEditor.tsx @@ -0,0 +1,21 @@ +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { TraceToLogsSettings } from 'app/core/components/TraceToLogsSettings'; +import React from 'react'; + +export type Props = DataSourcePluginOptionsEditorProps; + +export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => { + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/datasource/jaeger/QueryField.test.tsx b/public/app/plugins/datasource/jaeger/QueryField.test.tsx new file mode 100644 index 0000000..585ad6f --- /dev/null +++ b/public/app/plugins/datasource/jaeger/QueryField.test.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { JaegerQueryField } from './QueryField'; +import { shallow, mount } from 'enzyme'; +import { JaegerDatasource, JaegerQuery } from './datasource'; +import { ButtonCascader } from '@grafana/ui'; + +describe('JaegerQueryField', function () { + it('shows empty value if no services returned', function () { + const wrapper = shallow( + {}} + onChange={() => {}} + /> + ); + expect(wrapper.find(ButtonCascader).props().options[0].label).toBe('No traces found'); + }); + + it('uses URL encoded service name in metadataRequest request', async function () { + const wrapper = mount( + {}} + onChange={() => {}} + /> + ); + + // Simulating selection options. We need this as the function depends on the intermediate state of the component + await wrapper.find(ButtonCascader)!.props().loadData!([{ value: 'service/test', label: 'service/test' }]); + + wrapper.update(); + expect(wrapper.find(ButtonCascader).props().options[0].label).toEqual('service/test'); + expect(wrapper.find(ButtonCascader).props().options[0].value).toEqual('service/test'); + expect(wrapper.find(ButtonCascader).props().options![0].children![1]).toEqual({ + isLeaf: false, + label: 'op1', + value: 'op1', + }); + }); + + it('shows root span as 3rd level in cascader', async function () { + const wrapper = mount( + {}} + onChange={() => {}} + /> + ); + + // Simulating selection options. We need this as the function depends on the intermediate state of the component + await wrapper.find(ButtonCascader)!.props().loadData!([{ value: 'service1', label: 'service1' }]); + + await wrapper.find(ButtonCascader)!.props().loadData!([ + { value: 'service1', label: 'service1' }, + { value: 'op1', label: 'op1' }, + ]); + + wrapper.update(); + expect(wrapper.find(ButtonCascader)!.props().options![0].children![1].children![0]).toEqual({ + label: 'rootOp [0.099 ms]', + value: '12345', + }); + }); +}); + +function makeDatasourceMock(data: { [service: string]: { [operation: string]: any } }): JaegerDatasource { + return { + metadataRequest(url: string, params: Record) { + if (url.match(/\/services$/)) { + return Promise.resolve(Object.keys(data)); + } + let match = url.match(/\/services\/(.*)\/operations/); + if (match) { + const decodedService = decodeURIComponent(match[1]); + expect(decodedService).toBe(Object.keys(data)[0]); + return Promise.resolve(Object.keys(data[decodedService])); + } + + if (url.match(/\/traces?/)) { + return Promise.resolve(data[params.service][params.operation]); + } + throw new Error(`Unexpected url: ${url}`); + }, + + getTimeRange(): { start: number; end: number } { + return { start: 1, end: 100 }; + }, + } as any; +} diff --git a/public/app/plugins/datasource/jaeger/QueryField.tsx b/public/app/plugins/datasource/jaeger/QueryField.tsx new file mode 100644 index 0000000..cb15be5 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/QueryField.tsx @@ -0,0 +1,239 @@ +import { AppEvents, ExploreQueryFieldProps } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { ButtonCascader, CascaderOption } from '@grafana/ui'; +import React from 'react'; +import { appEvents } from '../../../core/core'; +import { JaegerDatasource, JaegerQuery } from './datasource'; +import { Span, TraceResponse } from './types'; + +const ALL_OPERATIONS_KEY = '__ALL__'; +const NO_TRACES_KEY = '__NO_TRACES__'; + +type Props = ExploreQueryFieldProps; +interface State { + serviceOptions: CascaderOption[]; +} + +function findRootSpan(spans: Span[]): Span | undefined { + return spans.find((s) => !s.references?.length); +} + +function getLabelFromTrace(trace: TraceResponse): string { + const rootSpan = findRootSpan(trace.spans); + if (rootSpan) { + return `${rootSpan.operationName} [${rootSpan.duration / 1000} ms]`; + } + return trace.traceID; +} + +export class JaegerQueryField extends React.PureComponent { + private _isMounted: boolean; + + constructor(props: Props, context: React.Context) { + super(props, context); + this.state = { + serviceOptions: [], + }; + } + + componentDidMount() { + this._isMounted = true; + // We should probably call this periodically to get new services after mount. + this.getServices(); + } + + componentWillUnmount(): void { + this._isMounted = false; + } + + async getServices() { + const url = '/api/services'; + const { datasource } = this.props; + try { + const services: string[] | null = await datasource.metadataRequest(url); + if (!this._isMounted) { + return; + } + + if (services) { + const serviceOptions: CascaderOption[] = services.sort().map((service) => ({ + label: service, + value: service, + isLeaf: false, + })); + this.setState({ serviceOptions }); + } + } catch (error) { + appEvents.emit(AppEvents.alertError, ['Failed to load services from Jaeger', error]); + } + } + + onLoadOptions = async (selectedOptions: CascaderOption[]) => { + const service = selectedOptions[0].value; + if (selectedOptions.length === 1) { + // Load operations + const operations: string[] = await this.findOperations(service); + if (!this._isMounted) { + return; + } + + const allOperationsOption: CascaderOption = { + label: '[ALL]', + value: ALL_OPERATIONS_KEY, + }; + const operationOptions: CascaderOption[] = [ + allOperationsOption, + ...operations.sort().map((operation) => ({ + label: operation, + value: operation, + isLeaf: false, + })), + ]; + this.setState((state) => { + const serviceOptions = state.serviceOptions.map((serviceOption) => { + if (serviceOption.value === service) { + return { + ...serviceOption, + children: operationOptions, + }; + } + return serviceOption; + }); + return { serviceOptions }; + }); + } else if (selectedOptions.length === 2) { + // Load traces + const operationValue = selectedOptions[1].value; + const operation = operationValue === ALL_OPERATIONS_KEY ? '' : operationValue; + const traces: any[] = await this.findTraces(service, operation); + if (!this._isMounted) { + return; + } + + let traceOptions: CascaderOption[] = traces.map((trace) => ({ + label: getLabelFromTrace(trace), + value: trace.traceID, + })); + if (traceOptions.length === 0) { + traceOptions = [ + { + label: '[No traces in time range]', + value: NO_TRACES_KEY, + }, + ]; + } + this.setState((state) => { + // Place new traces into the correct service/operation sub-tree + const serviceOptions = state.serviceOptions.map((serviceOption) => { + if (serviceOption.value === service && serviceOption.children) { + const operationOptions = serviceOption.children.map((operationOption) => { + if (operationOption.value === operationValue) { + return { + ...operationOption, + children: traceOptions, + }; + } + return operationOption; + }); + return { + ...serviceOption, + children: operationOptions, + }; + } + return serviceOption; + }); + return { serviceOptions }; + }); + } + }; + + findOperations = async (service: string) => { + const { datasource } = this.props; + const url = `/api/services/${encodeURIComponent(service)}/operations`; + try { + return await datasource.metadataRequest(url); + } catch (error) { + appEvents.emit(AppEvents.alertError, ['Failed to load operations from Jaeger', error]); + } + return []; + }; + + findTraces = async (service: string, operation?: string) => { + const { datasource } = this.props; + const { start, end } = datasource.getTimeRange(); + + const traceSearch = { + start, + end, + service, + operation, + limit: 10, + lookback: '1h', + maxDuration: '', + minDuration: '', + }; + const url = '/api/traces'; + try { + return await datasource.metadataRequest(url, traceSearch); + } catch (error) { + appEvents.emit(AppEvents.alertError, ['Failed to load traces from Jaeger', error]); + } + return []; + }; + + onSelectTrace = (values: string[], selectedOptions: CascaderOption[]) => { + const { query, onChange, onRunQuery } = this.props; + if (selectedOptions.length === 3) { + const traceID = selectedOptions[2].value; + onChange({ ...query, query: traceID }); + onRunQuery(); + } + }; + + render() { + const { query, onChange } = this.props; + const { serviceOptions } = this.state; + const cascaderOptions = serviceOptions && serviceOptions.length ? serviceOptions : noTracesFoundOptions; + + return ( + <> +
      +
      + + Traces + +
      +
      +
      +
      + + onChange({ + ...query, + query: e.currentTarget.value, + }) + } + /> +
      +
      +
      +
      + + ); + } +} + +const noTracesFoundOptions = [ + { + label: 'No traces found', + value: 'no_traces', + isLeaf: true, + + // Cannot be disabled because then cascader shows 'loading' for some reason. + // disabled: true, + }, +]; + +export default JaegerQueryField; diff --git a/public/app/plugins/datasource/jaeger/datasource.test.ts b/public/app/plugins/datasource/jaeger/datasource.test.ts new file mode 100644 index 0000000..1d63001 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/datasource.test.ts @@ -0,0 +1,249 @@ +import { JaegerDatasource, JaegerQuery } from './datasource'; +import { + DataQueryRequest, + DataSourceInstanceSettings, + FieldType, + PluginType, + dateTime, + ArrayVector, +} from '@grafana/data'; +import { BackendSrv, BackendSrvRequest, getBackendSrv, setBackendSrv } from '@grafana/runtime'; +import { testResponse } from './testResponse'; + +describe('JaegerDatasource', () => { + it('returns trace when queried', async () => { + await withMockedBackendSrv(makeBackendSrvMock('12345'), async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds.query(defaultQuery).toPromise(); + expect(response.data[0].fields).toMatchObject( + [ + { name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] }, + { name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] }, + { name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] }, + { name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] }, + { name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] }, + { + name: 'serviceTags', + values: [ + [ + { key: 'cluster', type: 'string', value: 'ops-tools1' }, + { key: 'container', type: 'string', value: 'tempo-query' }, + ], + [ + { key: 'cluster', type: 'string', value: 'ops-tools1' }, + { key: 'container', type: 'string', value: 'tempo-query' }, + ], + ], + }, + { name: 'startTime', values: [1605873894680.409, 1605873894680.587] }, + { name: 'duration', values: [1049.141, 1.847] }, + { name: 'logs', values: [[], []] }, + { + name: 'tags', + values: [ + [ + { key: 'sampler.type', type: 'string', value: 'probabilistic' }, + { key: 'sampler.param', type: 'float64', value: 1 }, + ], + [ + { key: 'component', type: 'string', value: 'gRPC' }, + { key: 'span.kind', type: 'string', value: 'client' }, + ], + ], + }, + { name: 'warnings', values: [undefined, undefined] }, + { name: 'stackTraces', values: [undefined, undefined] }, + ].map((f) => ({ ...f, values: new ArrayVector(f.values) })) + ); + }); + }); + + it('returns trace when traceId with special characters is queried', async () => { + await withMockedBackendSrv(makeBackendSrvMock('a/b'), async () => { + const ds = new JaegerDatasource(defaultSettings); + const query = { + ...defaultQuery, + targets: [ + { + query: 'a/b', + refId: '1', + }, + ], + }; + await ds.query(query).toPromise(); + // there is expect makeBackendSrvMock checking correct encoding + }); + }); + + it('returns empty response if trace id is not specified', async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds + .query({ + ...defaultQuery, + targets: [], + }) + .toPromise(); + const field = response.data[0].fields[0]; + expect(field.name).toBe('trace'); + expect(field.type).toBe(FieldType.trace); + expect(field.values.length).toBe(0); + }); +}); + +describe('when performing testDataSource', () => { + describe('and call succeeds', () => { + it('should return successfully', async () => { + const backendSrvMock = makeTestDatasourceMock( + Promise.resolve({ + statusText: 'OK', + status: 200, + data: { + data: ['service1'], + }, + }) + ); + + await withMockedBackendSrv(backendSrvMock, async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds.testDatasource(); + expect(response.status).toEqual('success'); + expect(response.message).toBe('Data source connected and services found.'); + }); + }); + }); + + describe('and call succeeds, but returns no services', () => { + it('should display an error', async () => { + const backendSrvMock = makeTestDatasourceMock( + Promise.resolve({ + statusText: 'OK', + status: 200, + }) + ); + + await withMockedBackendSrv(backendSrvMock, async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds.testDatasource(); + expect(response.status).toEqual('error'); + expect(response.message).toBe( + 'Data source connected, but no services received. Verify that Jaeger is configured properly.' + ); + }); + }); + }); + + describe('and call returns error with message', () => { + it('should return the formatted error', async () => { + const backendSrvMock = { + datasourceRequest(options: BackendSrvRequest): Promise { + return Promise.reject({ + statusText: 'Not found', + status: 404, + data: { + message: '404 page not found', + }, + }); + }, + } as BackendSrv; + + await withMockedBackendSrv(backendSrvMock, async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds.testDatasource(); + expect(response.status).toEqual('error'); + expect(response.message).toBe('Jaeger: Not found. 404. 404 page not found'); + }); + }); + }); + + describe('and call returns error without message', () => { + it('should return JSON error', async () => { + const backendSrvMock = { + datasourceRequest(options: BackendSrvRequest): Promise { + return Promise.reject({ + statusText: 'Bad gateway', + status: 502, + data: { + errors: ['Could not connect to Jaeger backend'], + }, + }); + }, + } as BackendSrv; + + await withMockedBackendSrv(backendSrvMock, async () => { + const ds = new JaegerDatasource(defaultSettings); + const response = await ds.testDatasource(); + expect(response.status).toEqual('error'); + expect(response.message).toBe('Jaeger: Bad gateway. 502. {"errors":["Could not connect to Jaeger backend"]}'); + }); + }); + }); +}); + +function makeTestDatasourceMock(result: Promise) { + return { + datasourceRequest(options: BackendSrvRequest): Promise { + return result; + }, + } as BackendSrv; +} + +function makeBackendSrvMock(traceId: string) { + return { + datasourceRequest(options: BackendSrvRequest): Promise { + expect(options.url.substr(options.url.length - 17, options.url.length)).toBe( + `/api/traces/${encodeURIComponent(traceId)}` + ); + return Promise.resolve({ + data: { + data: [testResponse], + }, + }); + }, + } as any; +} + +async function withMockedBackendSrv(srv: BackendSrv, fn: () => Promise) { + const oldSrv = getBackendSrv(); + setBackendSrv(srv); + await fn(); + setBackendSrv(oldSrv); +} + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: '0', + type: 'tracing', + name: 'jaeger', + meta: { + id: 'jaeger', + name: 'jaeger', + type: PluginType.datasource, + info: {} as any, + module: '', + baseUrl: '', + }, + jsonData: {}, +}; + +const defaultQuery: DataQueryRequest = { + requestId: '1', + dashboardId: 0, + interval: '0', + intervalMs: 10, + panelId: 0, + scopedVars: {}, + range: { + from: dateTime().subtract(1, 'h'), + to: dateTime(), + raw: { from: '1h', to: 'now' }, + }, + timezone: 'browser', + app: 'explore', + startTime: 0, + targets: [ + { + query: '12345', + refId: '1', + }, + ], +}; diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts new file mode 100644 index 0000000..a7716a6 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -0,0 +1,134 @@ +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + dateMath, + DateTime, + FieldType, + MutableDataFrame, +} from '@grafana/data'; +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; +import { from, Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { serializeParams } from 'app/core/utils/fetch'; +import { createTraceFrame } from './responseTransform'; + +export type JaegerQuery = { + query: string; +} & DataQuery; + +export class JaegerDatasource extends DataSourceApi { + constructor(private instanceSettings: DataSourceInstanceSettings, private readonly timeSrv: TimeSrv = getTimeSrv()) { + super(instanceSettings); + } + + async metadataRequest(url: string, params?: Record): Promise { + const res = await this._request(url, params, { hideFromInspector: true }).toPromise(); + return res.data.data; + } + + query(options: DataQueryRequest): Observable { + // At this moment we expect only one target. In case we somehow change the UI to be able to show multiple + // traces at one we need to change this. + const id = options.targets[0]?.query; + if (!id) { + return of({ data: [emptyTraceDataFrame] }); + } + + // TODO: this api is internal, used in jaeger ui. Officially they have gRPC api that should be used. + return this._request(`/api/traces/${encodeURIComponent(id)}`).pipe( + map((response) => { + return { + data: [createTraceFrame(response?.data?.data?.[0] || [])], + }; + }) + ); + } + + async testDatasource(): Promise { + return this._request('/api/services') + .pipe( + map((res) => { + const values: any[] = res?.data?.data || []; + const testResult = + values.length > 0 + ? { status: 'success', message: 'Data source connected and services found.' } + : { + status: 'error', + message: + 'Data source connected, but no services received. Verify that Jaeger is configured properly.', + }; + return testResult; + }), + catchError((err: any) => { + let message = 'Jaeger: '; + if (err.statusText) { + message += err.statusText; + } else { + message += 'Cannot connect to Jaeger'; + } + + if (err.status) { + message += `. ${err.status}`; + } + + if (err.data && err.data.message) { + message += `. ${err.data.message}`; + } else if (err.data) { + message += `. ${JSON.stringify(err.data)}`; + } + return of({ status: 'error', message: message }); + }) + ) + .toPromise(); + } + + getTimeRange(): { start: number; end: number } { + const range = this.timeSrv.timeRange(); + return { + start: getTime(range.from, false), + end: getTime(range.to, true), + }; + } + + getQueryDisplayText(query: JaegerQuery) { + return query.query; + } + + private _request(apiUrl: string, data?: any, options?: Partial): Observable> { + // Hack for proxying metadata requests + const baseUrl = `/api/datasources/proxy/${this.instanceSettings.id}`; + const params = data ? serializeParams(data) : ''; + const url = `${baseUrl}${apiUrl}${params.length ? `?${params}` : ''}`; + const req = { + ...options, + url, + }; + + return from(getBackendSrv().datasourceRequest(req)); + } +} + +function getTime(date: string | DateTime, roundUp: boolean) { + if (typeof date === 'string') { + date = dateMath.parse(date, roundUp)!; + } + return date.valueOf() * 1000; +} + +const emptyTraceDataFrame = new MutableDataFrame({ + fields: [ + { + name: 'trace', + type: FieldType.trace, + values: [], + }, + ], + meta: { + preferredVisualisationType: 'trace', + }, +}); diff --git a/public/app/plugins/datasource/jaeger/img/jaeger_logo.svg b/public/app/plugins/datasource/jaeger/img/jaeger_logo.svg new file mode 100644 index 0000000..e0793af --- /dev/null +++ b/public/app/plugins/datasource/jaeger/img/jaeger_logo.svg @@ -0,0 +1,236 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/public/app/plugins/datasource/jaeger/module.ts b/public/app/plugins/datasource/jaeger/module.ts new file mode 100644 index 0000000..9ed98d2 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/module.ts @@ -0,0 +1,8 @@ +import { DataSourcePlugin } from '@grafana/data'; +import { JaegerDatasource } from './datasource'; +import { JaegerQueryField } from './QueryField'; +import { ConfigEditor } from './ConfigEditor'; + +export const plugin = new DataSourcePlugin(JaegerDatasource) + .setConfigEditor(ConfigEditor) + .setExploreQueryField(JaegerQueryField); diff --git a/public/app/plugins/datasource/jaeger/plugin.json b/public/app/plugins/datasource/jaeger/plugin.json new file mode 100644 index 0000000..884c310 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/plugin.json @@ -0,0 +1,35 @@ +{ + "type": "datasource", + "name": "Jaeger", + "id": "jaeger", + "category": "tracing", + + "metrics": false, + "alerting": false, + "annotations": false, + "logs": false, + "streaming": false, + "tracing": true, + + "info": { + "description": "Open source, end-to-end distributed tracing", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/jaeger_logo.svg", + "large": "img/jaeger_logo.svg" + }, + "links": [ + { + "name": "Learn more", + "url": "https://www.jaegertracing.io" + }, + { + "name": "GitHub Project", + "url": "https://github.com/jaegertracing/jaeger" + } + ] + } +} diff --git a/public/app/plugins/datasource/jaeger/responseTransform.test.ts b/public/app/plugins/datasource/jaeger/responseTransform.test.ts new file mode 100644 index 0000000..83b0540 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/responseTransform.test.ts @@ -0,0 +1,49 @@ +import { createTraceFrame } from './responseTransform'; +import { ArrayVector } from '@grafana/data'; +import { testResponse } from './testResponse'; + +describe('createTraceFrame', () => { + it('creates data frame from jaeger response', () => { + const dataFrame = createTraceFrame(testResponse); + expect(dataFrame.fields).toMatchObject( + [ + { name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] }, + { name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] }, + { name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] }, + { name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] }, + { name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] }, + { + name: 'serviceTags', + values: [ + [ + { key: 'cluster', type: 'string', value: 'ops-tools1' }, + { key: 'container', type: 'string', value: 'tempo-query' }, + ], + [ + { key: 'cluster', type: 'string', value: 'ops-tools1' }, + { key: 'container', type: 'string', value: 'tempo-query' }, + ], + ], + }, + { name: 'startTime', values: [1605873894680.409, 1605873894680.587] }, + { name: 'duration', values: [1049.141, 1.847] }, + { name: 'logs', values: [[], []] }, + { + name: 'tags', + values: [ + [ + { key: 'sampler.type', type: 'string', value: 'probabilistic' }, + { key: 'sampler.param', type: 'float64', value: 1 }, + ], + [ + { key: 'component', type: 'string', value: 'gRPC' }, + { key: 'span.kind', type: 'string', value: 'client' }, + ], + ], + }, + { name: 'warnings', values: [undefined, undefined] }, + { name: 'stackTraces', values: [undefined, undefined] }, + ].map((f) => ({ ...f, values: new ArrayVector(f.values) })) + ); + }); +}); diff --git a/public/app/plugins/datasource/jaeger/responseTransform.ts b/public/app/plugins/datasource/jaeger/responseTransform.ts new file mode 100644 index 0000000..17bddd9 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/responseTransform.ts @@ -0,0 +1,54 @@ +import { DataFrame, FieldType, MutableDataFrame, TraceSpanRow } from '@grafana/data'; + +import { Span, TraceProcess, TraceResponse } from './types'; + +export function createTraceFrame(data: TraceResponse): DataFrame { + const spans = data.spans.map((s) => toSpanRow(s, data.processes)); + + const frame = new MutableDataFrame({ + fields: [ + { name: 'traceID', type: FieldType.string }, + { name: 'spanID', type: FieldType.string }, + { name: 'parentSpanID', type: FieldType.string }, + { name: 'operationName', type: FieldType.string }, + { name: 'serviceName', type: FieldType.string }, + { name: 'serviceTags', type: FieldType.other }, + { name: 'startTime', type: FieldType.number }, + { name: 'duration', type: FieldType.number }, + { name: 'logs', type: FieldType.other }, + { name: 'tags', type: FieldType.other }, + { name: 'warnings', type: FieldType.other }, + { name: 'stackTraces', type: FieldType.other }, + ], + meta: { + preferredVisualisationType: 'trace', + }, + }); + + for (const span of spans) { + frame.add(span); + } + + return frame; +} + +function toSpanRow(span: Span, processes: Record): TraceSpanRow { + return { + spanID: span.spanID, + traceID: span.traceID, + parentSpanID: span.references?.find((r) => r.refType === 'CHILD_OF')?.spanID, + operationName: span.operationName, + // from micro to millis + startTime: span.startTime / 1000, + duration: span.duration / 1000, + logs: span.logs.map((l) => ({ + ...l, + timestamp: l.timestamp / 1000, + })), + tags: span.tags, + warnings: span.warnings ?? undefined, + stackTraces: span.stackTraces, + serviceName: processes[span.processID].serviceName, + serviceTags: processes[span.processID].tags, + }; +} diff --git a/public/app/plugins/datasource/jaeger/testResponse.ts b/public/app/plugins/datasource/jaeger/testResponse.ts new file mode 100644 index 0000000..3a85a29 --- /dev/null +++ b/public/app/plugins/datasource/jaeger/testResponse.ts @@ -0,0 +1,49 @@ +import { TraceResponse } from './types'; + +export const testResponse: TraceResponse = { + traceID: '3fa414edcef6ad90', + spans: [ + { + traceID: '3fa414edcef6ad90', + spanID: '3fa414edcef6ad90', + operationName: 'HTTP GET - api_traces_traceid', + references: [], + startTime: 1605873894680409, + duration: 1049141, + tags: [ + { key: 'sampler.type', type: 'string', value: 'probabilistic' }, + { key: 'sampler.param', type: 'float64', value: 1 }, + ], + logs: [], + processID: 'p1', + warnings: null, + flags: 0, + }, + { + traceID: '3fa414edcef6ad90', + spanID: '0f5c1808567e4403', + operationName: '/tempopb.Querier/FindTraceByID', + references: [{ refType: 'CHILD_OF', traceID: '3fa414edcef6ad90', spanID: '3fa414edcef6ad90' }], + startTime: 1605873894680587, + duration: 1847, + tags: [ + { key: 'component', type: 'string', value: 'gRPC' }, + { key: 'span.kind', type: 'string', value: 'client' }, + ], + logs: [], + processID: 'p1', + warnings: null, + flags: 0, + }, + ], + processes: { + p1: { + serviceName: 'tempo-querier', + tags: [ + { key: 'cluster', type: 'string', value: 'ops-tools1' }, + { key: 'container', type: 'string', value: 'tempo-query' }, + ], + }, + }, + warnings: null, +}; diff --git a/public/app/plugins/datasource/jaeger/types.ts b/public/app/plugins/datasource/jaeger/types.ts new file mode 100644 index 0000000..caf358a --- /dev/null +++ b/public/app/plugins/datasource/jaeger/types.ts @@ -0,0 +1,49 @@ +export type TraceKeyValuePair = { + key: string; + type?: string; + value: any; +}; + +export type TraceLink = { + url: string; + text: string; +}; + +export type TraceLog = { + timestamp: number; + fields: TraceKeyValuePair[]; +}; + +export type TraceProcess = { + serviceName: string; + tags: TraceKeyValuePair[]; +}; + +export type TraceSpanReference = { + refType: 'CHILD_OF' | 'FOLLOWS_FROM'; + spanID: string; + traceID: string; +}; + +export type Span = { + spanID: string; + traceID: string; + processID: string; + operationName: string; + // Times are in microseconds + startTime: number; + duration: number; + logs: TraceLog[]; + tags?: TraceKeyValuePair[]; + references?: TraceSpanReference[]; + warnings?: string[] | null; + stackTraces?: string[]; + flags: number; +}; + +export type TraceResponse = { + processes: Record; + traceID: string; + warnings?: string[] | null; + spans: Span[]; +}; diff --git a/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx new file mode 100644 index 0000000..6bd42b2 --- /dev/null +++ b/public/app/plugins/datasource/loki/LokiAnnotationsQueryCtrl.tsx @@ -0,0 +1,20 @@ +import { LokiQuery } from './types'; +/** + * Just a simple wrapper for a react component that is actually implementing the query editor. + */ +export class LokiAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.target = this.annotation.target || {}; + this.onQueryChange = this.onQueryChange.bind(this); + } + + onQueryChange(query: LokiQuery) { + this.annotation.expr = query.expr; + this.annotation.maxLines = query.maxLines; + this.annotation.instant = query.instant; + } +} diff --git a/public/app/plugins/datasource/loki/README.md b/public/app/plugins/datasource/loki/README.md new file mode 100644 index 0000000..448e44b --- /dev/null +++ b/public/app/plugins/datasource/loki/README.md @@ -0,0 +1,3 @@ +# Loki Data Source - Native Plugin + +This is a **built in** data source that allows you to connect to the Loki logging service. diff --git a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx new file mode 100644 index 0000000..ba84fa6 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx @@ -0,0 +1,44 @@ +// Libraries +import React, { memo } from 'react'; + +// Types +import { LokiQuery } from '../types'; +import { LokiQueryFieldForm } from './LokiQueryFieldForm'; +import LokiDatasource from '../datasource'; + +interface Props { + expr: string; + maxLines?: number; + instant?: boolean; + datasource: LokiDatasource; + onChange: (query: LokiQuery) => void; +} + +export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEditor(props: Props) { + const { expr, maxLines, instant, datasource, onChange } = props; + + // Timerange to get existing labels from. Hard coding like this seems to be good enough right now. + const absolute = { + from: Date.now() - 10000, + to: Date.now(), + }; + + const queryWithRefId: LokiQuery = { + refId: '', + expr, + maxLines, + instant, + }; + return ( +
      + {}} + history={[]} + absoluteRange={absolute} + /> +
      + ); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx new file mode 100644 index 0000000..2a7669e --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -0,0 +1,133 @@ +import React, { PureComponent } from 'react'; +import { shuffle } from 'lodash'; +import { QueryEditorHelpProps, DataQuery } from '@grafana/data'; +import LokiLanguageProvider from '../language_provider'; + +const DEFAULT_EXAMPLES = ['{job="default/prometheus"}']; +const PREFERRED_LABELS = ['job', 'app', 'k8s_app']; +const EXAMPLES_LIMIT = 5; + +const LOGQL_EXAMPLES = [ + { + title: 'Log pipeline', + expression: '{job="mysql"} |= "metrics" | logfmt | duration > 10s', + label: + 'This query targets the MySQL job, filters out logs that don’t contain the word "metrics" and parses each log line to extract more labels and filters with them.', + }, + { + title: 'Count over time', + expression: 'count_over_time({job="mysql"}[5m])', + label: 'This query counts all the log lines within the last five minutes for the MySQL job.', + }, + { + title: 'Rate', + expression: 'rate(({job="mysql"} |= "error" != "timeout")[10s])', + label: + 'This query gets the per-second rate of all non-timeout errors within the last ten seconds for the MySQL job.', + }, + { + title: 'Aggregate, count, and group', + expression: 'sum(count_over_time({job="mysql"}[5m])) by (level)', + label: 'Get the count of logs during the last five minutes, grouping by level.', + }, +]; + +export default class LokiCheatSheet extends PureComponent { + userLabelTimer: NodeJS.Timeout; + state = { + userExamples: DEFAULT_EXAMPLES, + }; + + componentDidMount() { + this.scheduleUserLabelChecking(); + } + + componentWillUnmount() { + clearTimeout(this.userLabelTimer); + } + + scheduleUserLabelChecking() { + this.userLabelTimer = setTimeout(this.checkUserLabels, 1000); + } + + checkUserLabels = async () => { + // Set example from user labels + const provider: LokiLanguageProvider = this.props.datasource?.languageProvider; + if (provider.started) { + const labels = provider.getLabelKeys() || []; + const preferredLabel = PREFERRED_LABELS.find((l) => labels.includes(l)); + if (preferredLabel) { + const values = await provider.getLabelValues(preferredLabel); + const userExamples = shuffle(values) + .slice(0, EXAMPLES_LIMIT) + .map((value) => `{${preferredLabel}="${value}"}`); + this.setState({ userExamples }); + } + } else { + this.scheduleUserLabelChecking(); + } + }; + + renderExpression(expr: string) { + const { onClickExample } = this.props; + + return ( +
      onClickExample({ refId: 'A', expr } as DataQuery)} + > + {expr} +
      + ); + } + + render() { + const { userExamples } = this.state; + + return ( +
      +

      Loki Cheat Sheet

      +
      +
      See your logs
      +
      Start by selecting a log stream from the Log labels selector.
      +
      + Alternatively, you can write a stream selector into the query field: +
      + {this.renderExpression('{job="default/prometheus"}')} + {userExamples !== DEFAULT_EXAMPLES && userExamples.length > 0 ? ( +
      +
      Here are some example streams from your logs:
      + {userExamples.map((example) => this.renderExpression(example))} +
      + ) : null} +
      +
      +
      Combine stream selectors
      + {this.renderExpression('{app="cassandra",namespace="prod"}')} +
      Returns all log lines from streams that have both labels.
      +
      + +
      +
      Filtering for search terms.
      + {this.renderExpression('{app="cassandra"} |~ "(duration|latency)s*(=|is|of)s*[d.]+"')} + {this.renderExpression('{app="cassandra"} |= "exact match"')} + {this.renderExpression('{app="cassandra"} != "do not match"')} +
      + + LogQL + {' '} + supports exact and regular expression filters. +
      +
      + {LOGQL_EXAMPLES.map((item) => ( +
      +
      {item.title}
      + {this.renderExpression(item.expression)} +
      {item.label}
      +
      + ))} +
      + ); + } +} diff --git a/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.test.tsx new file mode 100644 index 0000000..f9d4e74 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import LokiExploreQueryEditor from './LokiExploreQueryEditor'; +import { LokiOptionFields } from './LokiOptionFields'; +import { LokiDatasource } from '../datasource'; +import { LokiQuery } from '../types'; +import { ExploreMode, LoadingState, PanelData, toUtc, TimeRange } from '@grafana/data'; +import { makeMockLokiDatasource } from '../mocks'; +import LokiLanguageProvider from '../language_provider'; + +const setup = (renderMethod: any, propOverrides?: object) => { + const datasource: LokiDatasource = makeMockLokiDatasource({}); + datasource.languageProvider = new LokiLanguageProvider(datasource); + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: LokiQuery = { expr: '', refId: 'A', maxLines: 0 }; + const range: TimeRange = { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }; + const data: PanelData = { + state: LoadingState.NotStarted, + series: [], + request: { + requestId: '1', + dashboardId: 1, + interval: '1s', + intervalMs: 1000, + panelId: 1, + range: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + scopedVars: {}, + targets: [], + timezone: 'GMT', + app: 'Grafana', + startTime: 0, + }, + timeRange: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + }; + const history: any[] = []; + const exploreMode: ExploreMode = ExploreMode.Logs; + + const props: any = { + query, + data, + range, + datasource, + exploreMode, + history, + onChange, + onRunQuery, + }; + + Object.assign(props, { ...props, ...propOverrides }); + return renderMethod(); +}; + +describe('LokiExploreQueryEditor', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('should render component', () => { + const wrapper = setup(shallow); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render LokiQueryField with ExtraFieldElement when ExploreMode is set to Logs', async () => { + // @ts-ignore strict null error TS2345: Argument of type '() => Promise' is not assignable to parameter of type '() => void | undefined'. + await act(async () => { + const wrapper = setup(mount); + expect(wrapper.find(LokiOptionFields).length).toBe(1); + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx new file mode 100644 index 0000000..0949fa8 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx @@ -0,0 +1,30 @@ +// Libraries +import React, { memo } from 'react'; +import _ from 'lodash'; + +// Types +import { ExploreQueryFieldProps } from '@grafana/data'; +import { LokiDatasource } from '../datasource'; +import { LokiQuery, LokiOptions } from '../types'; +import { LokiQueryField } from './LokiQueryField'; + +type Props = ExploreQueryFieldProps; + +export function LokiExploreQueryEditor(props: Props) { + const { range, query, data, datasource, history, onChange, onRunQuery } = props; + + return ( + {}} + onRunQuery={onRunQuery} + history={history} + data={data} + range={range} + /> + ); +} + +export default memo(LokiExploreQueryEditor); diff --git a/public/app/plugins/datasource/loki/components/LokiLabel.tsx b/public/app/plugins/datasource/loki/components/LokiLabel.tsx new file mode 100644 index 0000000..fc63b3b --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiLabel.tsx @@ -0,0 +1,120 @@ +import React, { forwardRef, HTMLAttributes } from 'react'; +import { cx, css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useTheme } from '@grafana/ui'; +// @ts-ignore +import Highlighter from 'react-highlight-words'; + +/** + * @public + */ +export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent) => void; + +export interface Props extends Omit, 'onClick'> { + name: string; + active?: boolean; + loading?: boolean; + searchTerm?: string; + value?: string; + facets?: number; + onClick?: OnLabelClick; +} + +export const LokiLabel = forwardRef( + ({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => { + const theme = useTheme(); + const styles = getLabelStyles(theme); + const searchWords = searchTerm ? [searchTerm] : []; + + const onLabelClick = (event: React.MouseEvent) => { + if (onClick && !hidden) { + onClick(name, value, event); + } + }; + // Using this component for labels and label values. If value is given use value for display text. + let text = value || name; + if (facets) { + text = `${text} (${facets})`; + } + + return ( + + ); + } +); + +LokiLabel.displayName = 'LokiLabel'; + +const getLabelStyles = (theme: GrafanaTheme) => ({ + base: css` + cursor: pointer; + font-size: ${theme.typography.size.sm}; + line-height: ${theme.typography.lineHeight.xs}; + background-color: ${theme.colors.bg3}; + vertical-align: baseline; + color: ${theme.colors.text}; + white-space: nowrap; + text-shadow: none; + padding: ${theme.spacing.xs}; + border-radius: ${theme.border.radius.md}; + margin-right: ${theme.spacing.sm}; + margin-bottom: ${theme.spacing.xs}; + `, + loading: css` + font-weight: ${theme.typography.weight.semibold}; + background-color: ${theme.colors.formSwitchBgHover}; + color: ${theme.palette.gray98}; + animation: pulse 3s ease-out 0s infinite normal forwards; + @keyframes pulse { + 0% { + color: ${theme.colors.textSemiWeak}; + } + 50% { + color: ${theme.colors.textFaint}; + } + 100% { + color: ${theme.colors.textSemiWeak}; + } + } + `, + active: css` + font-weight: ${theme.typography.weight.semibold}; + background-color: ${theme.colors.formSwitchBgActive}; + color: ${theme.colors.formSwitchDot}; + `, + matchHighLight: css` + background: inherit; + color: ${theme.palette.yellow}; + background-color: rgba(${theme.palette.yellow}, 0.1); + `, + hidden: css` + opacity: 0.6; + cursor: default; + border: 1px solid transparent; + `, + hover: css` + &:hover { + opacity: 0.85; + cursor: pointer; + } + `, +}); diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx new file mode 100644 index 0000000..f368b6a --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getTheme } from '@grafana/ui'; +import { + buildSelector, + facetLabels, + SelectableLabel, + UnthemedLokiLabelBrowser, + BrowserProps, +} from './LokiLabelBrowser'; +import LokiLanguageProvider from '../language_provider'; + +describe('buildSelector()', () => { + it('returns an empty selector for no labels', () => { + expect(buildSelector([])).toEqual('{}'); + }); + it('returns an empty selector for selected labels with no values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns an empty selector for one selected label with no selected values', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }]; + expect(buildSelector(labels)).toEqual('{}'); + }); + it('returns a simple selector from a selected label with a selected value', () => { + const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }]; + expect(buildSelector(labels)).toEqual('{foo="bar"}'); + }); +}); + +describe('facetLabels()', () => { + const possibleLabels = { + cluster: ['dev'], + namespace: ['alertmanager'], + }; + const labels: SelectableLabel[] = [ + { name: 'foo', selected: true, values: [{ name: 'bar' }] }, + { name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] }, + { name: 'namespace', values: [{ name: 'alertmanager' }] }, + ]; + + it('returns no labels given an empty label set', () => { + expect(facetLabels([], {})).toEqual([]); + }); + + it('marks all labels as hidden when no labels are possible', () => { + const result = facetLabels(labels, {}); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + }); + + it('keeps values as facetted when they are possible', () => { + const result = facetLabels(labels, possibleLabels); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[0].values).toBeUndefined(); + expect(result[1].hidden).toBeFalsy(); + expect(result[1].values!.length).toBe(1); + expect(result[1].values![0].name).toBe('dev'); + }); + + it('does not facet out label values that are currently being facetted', () => { + const result = facetLabels(labels, possibleLabels, 'cluster'); + expect(result.length).toEqual(labels.length); + expect(result[0].hidden).toBeTruthy(); + expect(result[1].hidden).toBeFalsy(); + // 'cluster' is being facetted, should show all 3 options even though only 1 is possible + expect(result[1].values!.length).toBe(3); + expect(result[2].values!.length).toBe(1); + }); +}); + +describe('LokiLabelBrowser', () => { + const setupProps = (): BrowserProps => { + const mockLanguageProvider = { + start: () => Promise.resolve(), + getLabelValues: (name: string) => { + switch (name) { + case 'label1': + return ['value1-1', 'value1-2']; + case 'label2': + return ['value2-1', 'value2-2']; + case 'label3': + return ['value3-1', 'value3-2']; + } + return []; + }, + fetchSeriesLabels: (selector: string) => { + switch (selector) { + case '{label1="value1-1"}': + return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; + case '{label1=~"value1-1|value1-2"}': + return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; + } + // Allow full set by default + return { + label1: ['value1-1', 'value1-2'], + label2: ['value2-1', 'value2-2'], + }; + }, + getLabelKeys: () => ['label1', 'label2', 'label3'], + }; + + const defaults: BrowserProps = { + theme: getTheme(), + onChange: () => {}, + autoSelect: 0, + languageProvider: (mockLanguageProvider as unknown) as LokiLanguageProvider, + }; + + return defaults; + }; + + // Clear label selection manually because it's saved in localStorage + afterEach(() => { + const clearBtn = screen.getByLabelText('Selector clear button'); + userEvent.click(clearBtn); + }); + + it('renders and loader shows when empty, and then first set of labels', async () => { + const props = setupProps(); + render(); + // Loading appears and dissappears + screen.getByText(/Loading labels/); + await waitFor(() => { + expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument(); + }); + // Initial set of labels is available and not selected + expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('allows label and value selection/deselection', async () => { + const props = setupProps(); + render(); + // Selecting label2 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + userEvent.click(label2); + expect(screen.queryByRole('option', { name: /label2/, selected: true })).toBeInTheDocument(); + // List of values for label2 appears + expect(await screen.findAllByRole('list')).toHaveLength(1); + expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); + expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + // Selecting label1, list for its values appears + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + expect(screen.queryByRole('option', { name: /label1/, selected: true })).toBeInTheDocument(); + await screen.findByLabelText('Values for label1'); + expect(await screen.findAllByRole('list')).toHaveLength(2); + // Selecting value2-2 of label2 + const value = await screen.findByRole('option', { name: 'value2-2', selected: false }); + userEvent.click(value); + await screen.findByRole('option', { name: 'value2-2', selected: true }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}'); + // Selecting value2-1 of label2, both values now selected + const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); + userEvent.click(value2); + // await screen.findByRole('option', {name: 'value2-1', selected: true}); + await screen.findByText('{label2=~"value2-1|value2-2"}'); + // Deselecting value2-2, one value should remain + const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true }); + userEvent.click(selectedValue); + await screen.findByRole('option', { name: 'value2-1', selected: true }); + await screen.findByRole('option', { name: 'value2-2', selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + // Selecting value from label1 for combined selector + const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + userEvent.click(value1); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + await screen.findByText('{label1="value1-2",label2="value2-1"}'); + // Deselect label1 should remove label and value + const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0]; + userEvent.click(selectedLabel); + await screen.findByRole('option', { name: /label1/, selected: false }); + expect(await screen.findAllByRole('list')).toHaveLength(1); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); + // Clear selector + const clearBtn = screen.getByLabelText('Selector clear button'); + userEvent.click(clearBtn); + await screen.findByRole('option', { name: /label2/, selected: false }); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); + }); + + it('filters values by input text', async () => { + const props = setupProps(); + render(); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + // Typing '1' to filter for values + userEvent.type(screen.getByLabelText('Filter expression for values'), '1'); + expect(screen.getByLabelText('Filter expression for values')).toHaveValue('1'); + expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); + }); + + it('facets labels', async () => { + const props = setupProps(); + render(); + // Selecting label2 and label1 + const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); + userEvent.click(label2); + const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); + userEvent.click(label1); + await screen.findByLabelText('Values for label1'); + await screen.findByLabelText('Values for label2'); + expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3'); + // Click value1-1 which triggers facetting for value3-x, and still show all value1-x + const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false }); + userEvent.click(value1); + await waitForElementToBeRemoved(screen.queryByRole('option', { name: 'value2-2' })); + expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}'); + expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)'); + // Click value1-2 for which facetting will allow more values for value3-x + const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false }); + userEvent.click(value12); + await screen.findByRole('option', { name: 'value1-2', selected: true }); + userEvent.click(screen.getByRole('option', { name: /label3/ })); + await screen.findByLabelText('Values for label3'); + expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument(); + expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}'); + expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)'); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx new file mode 100644 index 0000000..ea1eeb7 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx @@ -0,0 +1,487 @@ +import React, { ChangeEvent } from 'react'; +import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui'; +import LokiLanguageProvider from '../language_provider'; +import { css, cx } from 'emotion'; +import store from 'app/core/store'; +import { FixedSizeList } from 'react-window'; + +import { GrafanaTheme } from '@grafana/data'; +import { LokiLabel } from './LokiLabel'; + +// Hard limit on labels to render +const MAX_LABEL_COUNT = 100; +const MAX_VALUE_COUNT = 10000; +const MAX_AUTO_SELECT = 4; +const EMPTY_SELECTOR = '{}'; +export const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; + +export interface BrowserProps { + languageProvider: LokiLanguageProvider; + onChange: (selector: string) => void; + theme: GrafanaTheme; + autoSelect?: number; + hide?: () => void; +} + +interface BrowserState { + labels: SelectableLabel[]; + searchTerm: string; + status: string; + error: string; + validationStatus: string; +} + +interface FacettableValue { + name: string; + selected?: boolean; +} + +export interface SelectableLabel { + name: string; + selected?: boolean; + loading?: boolean; + values?: FacettableValue[]; + hidden?: boolean; + facets?: number; +} + +export function buildSelector(labels: SelectableLabel[]): string { + const selectedLabels = []; + for (const label of labels) { + if (label.selected && label.values && label.values.length > 0) { + const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name); + if (selectedValues.length > 1) { + selectedLabels.push(`${label.name}=~"${selectedValues.join('|')}"`); + } else if (selectedValues.length === 1) { + selectedLabels.push(`${label.name}="${selectedValues[0]}"`); + } + } + } + return ['{', selectedLabels.join(','), '}'].join(''); +} + +export function facetLabels( + labels: SelectableLabel[], + possibleLabels: Record, + lastFacetted?: string +): SelectableLabel[] { + return labels.map((label) => { + const possibleValues = possibleLabels[label.name]; + if (possibleValues) { + let existingValues: FacettableValue[]; + if (label.name === lastFacetted && label.values) { + // Facetting this label, show all values + existingValues = label.values; + } else { + // Keep selection in other facets + const selectedValues: Set = new Set( + label.values?.filter((value) => value.selected).map((value) => value.name) || [] + ); + // Values for this label have not been requested yet, let's use the facetted ones as the initial values + existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) })); + } + return { ...label, loading: false, values: existingValues, facets: existingValues.length }; + } + + // Label is facetted out, hide all values + return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 }; + }); +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrapper: css` + background-color: ${theme.colors.bg2}; + padding: ${theme.spacing.md}; + width: 100%; + `, + list: css` + margin-top: ${theme.spacing.sm}; + display: flex; + flex-wrap: wrap; + max-height: 200px; + overflow: auto; + `, + section: css` + & + & { + margin: ${theme.spacing.md} 0; + } + position: relative; + `, + selector: css` + font-family: ${theme.typography.fontFamily.monospace}; + margin-bottom: ${theme.spacing.sm}; + `, + status: css` + padding: ${theme.spacing.xs}; + color: ${theme.colors.textSemiWeak}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* using absolute positioning because flex interferes with ellipsis */ + position: absolute; + width: 50%; + right: 0; + text-align: right; + transition: opacity 100ms linear; + opacity: 0; + `, + statusShowing: css` + opacity: 1; + `, + error: css` + color: ${theme.palette.brandDanger}; + `, + valueList: css` + margin-right: ${theme.spacing.sm}; + `, + valueListWrapper: css` + border-left: 1px solid ${theme.colors.border2}; + margin: ${theme.spacing.sm} 0; + padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm}; + `, + valueListArea: css` + display: flex; + flex-wrap: wrap; + margin-top: ${theme.spacing.sm}; + `, + valueTitle: css` + margin-left: -${theme.spacing.xs}; + margin-bottom: ${theme.spacing.sm}; + `, + validationStatus: css` + padding: ${theme.spacing.xs}; + margin-bottom: ${theme.spacing.sm}; + color: ${theme.colors.textStrong}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `, +})); + +export class UnthemedLokiLabelBrowser extends React.Component { + state = { + labels: [] as SelectableLabel[], + searchTerm: '', + status: 'Ready', + error: '', + validationStatus: '', + }; + + onChangeSearch = (event: ChangeEvent) => { + this.setState({ searchTerm: event.target.value }); + }; + + onClickRunLogsQuery = () => { + const selector = buildSelector(this.state.labels); + this.props.onChange(selector); + }; + + onClickRunMetricsQuery = () => { + const selector = buildSelector(this.state.labels); + const query = `rate(${selector}[$__interval])`; + this.props.onChange(query); + }; + + onClickClear = () => { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => ({ + ...label, + values: undefined, + selected: false, + loading: false, + hidden: false, + facets: undefined, + })); + return { labels, searchTerm: '', status: '', error: '', validationStatus: '' }; + }); + store.delete(LAST_USED_LABELS_KEY); + }; + + onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + // Toggle selected state + const selected = !label.selected; + let nextValue: Partial = { selected }; + if (label.values && !selected) { + // Deselect all values if label was deselected + const values = label.values.map((value) => ({ ...value, selected: false })); + nextValue = { ...nextValue, facets: 0, values }; + } + // Resetting search to prevent empty results + this.setState({ searchTerm: '' }); + this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name)); + }; + + onClickValue = (name: string, value: string | undefined, event: React.MouseEvent) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label || !label.values) { + return; + } + // Resetting search to prevent empty results + this.setState({ searchTerm: '' }); + // Toggling value for selected label, leaving other values intact + const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected })); + this.updateLabelState(name, { values }, '', () => this.doFacetting(name)); + }; + + onClickValidate = () => { + const selector = buildSelector(this.state.labels); + this.validateSelector(selector); + }; + + updateLabelState(name: string, updatedFields: Partial, status = '', cb?: () => void) { + this.setState((state) => { + const labels: SelectableLabel[] = state.labels.map((label) => { + if (label.name === name) { + return { ...label, ...updatedFields }; + } + return label; + }); + // New status overrides errors + const error = status ? '' : state.error; + return { labels, status, error, validationStatus: '' }; + }, cb); + } + + componentDidMount() { + const { languageProvider, autoSelect = MAX_AUTO_SELECT } = this.props; + if (languageProvider) { + const selectedLabels: string[] = store.getObject(LAST_USED_LABELS_KEY, []); + languageProvider.start().then(() => { + let rawLabels: string[] = languageProvider.getLabelKeys(); + if (rawLabels.length > MAX_LABEL_COUNT) { + const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`; + rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT); + this.setState({ error }); + } + // Auto-select all labels if label list is small enough + const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({ + name: label, + selected: (arr.length <= autoSelect && selectedLabels.length === 0) || selectedLabels.includes(label), + loading: false, + })); + // Pre-fetch values for selected labels + this.setState({ labels }, () => { + this.state.labels.forEach((label) => { + if (label.selected) { + this.fetchValues(label.name); + } + }); + }); + }); + } + } + + doFacettingForLabel(name: string) { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); + store.setObject(LAST_USED_LABELS_KEY, selectedLabels); + if (label.selected) { + // Refetch values for newly selected label... + if (!label.values) { + this.fetchValues(name); + } + } else { + // Only need to facet when deselecting labels + this.doFacetting(); + } + } + + doFacetting = (lastFacetted?: string) => { + const selector = buildSelector(this.state.labels); + if (selector === EMPTY_SELECTOR) { + // Clear up facetting + const labels: SelectableLabel[] = this.state.labels.map((label) => { + return { ...label, facets: 0, values: undefined, hidden: false }; + }); + this.setState({ labels }, () => { + // Get fresh set of values + this.state.labels.forEach((label) => label.selected && this.fetchValues(label.name)); + }); + } else { + // Do facetting + this.fetchSeries(selector, lastFacetted); + } + }; + + async fetchValues(name: string) { + const { languageProvider } = this.props; + this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); + try { + let rawValues = await languageProvider.getLabelValues(name); + if (rawValues.length > MAX_VALUE_COUNT) { + const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`; + rawValues = rawValues.slice(0, MAX_VALUE_COUNT); + this.setState({ error }); + } + const values: FacettableValue[] = rawValues.map((value) => ({ name: value })); + this.updateLabelState(name, { values, loading: false }, ''); + } catch (error) { + console.error(error); + } + } + + async fetchSeries(selector: string, lastFacetted?: string) { + const { languageProvider } = this.props; + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); + } + try { + const possibleLabels = await languageProvider.fetchSeriesLabels(selector); + if (Object.keys(possibleLabels).length === 0) { + // Sometimes the backend does not return a valid set + console.error('No results for label combination, but should not occur.'); + this.setState({ error: `Facetting failed for ${selector}` }); + return; + } + const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted); + this.setState({ labels, error: '' }); + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: false }); + } + } catch (error) { + console.error(error); + } + } + + async validateSelector(selector: string) { + const { languageProvider } = this.props; + this.setState({ validationStatus: `Validating selector ${selector}`, error: '' }); + const streams = await languageProvider.fetchSeries(selector); + this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` }); + } + + render() { + const { theme } = this.props; + const { labels, searchTerm, status, error, validationStatus } = this.state; + if (labels.length === 0) { + return ; + } + const styles = getStyles(theme); + let selectedLabels = labels.filter((label) => label.selected && label.values); + if (searchTerm) { + // TODO extract from render() and debounce + selectedLabels = selectedLabels.map((label) => ({ + ...label, + values: label.values?.filter((value) => value.selected || value.name.includes(searchTerm)), + })); + } + const selector = buildSelector(this.state.labels); + const empty = selector === EMPTY_SELECTOR; + return ( +
      +
      + +
      + {labels.map((label) => ( +
      +
      +
      + +
      + +
      +
      + {selectedLabels.map((label) => ( +
      +
      +
      + (label.values as FacettableValue[])[i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( +
      + +
      + ); + }} +
      +
      + ))} +
      +
      +
      + +
      + {selector} +
      + {validationStatus &&
      {validationStatus}
      } + + + + + +
      + {error || status} +
      +
      +
      +
      + ); + } +} + +export const LokiLabelBrowser = withTheme(UnthemedLokiLabelBrowser); diff --git a/public/app/plugins/datasource/loki/components/LokiOptionFields.test.tsx b/public/app/plugins/datasource/loki/components/LokiOptionFields.test.tsx new file mode 100644 index 0000000..c91d054 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiOptionFields.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { LokiOptionFieldsProps, LokiOptionFields } from './LokiOptionFields'; + +const setup = (propOverrides?: LokiOptionFieldsProps) => { + const queryType = 'range'; + const lineLimitValue = '1'; + const onLineLimitChange = jest.fn(); + const onQueryTypeChange = jest.fn(); + const onKeyDownFunc = jest.fn(); + + const props: any = { + queryType, + lineLimitValue, + onLineLimitChange, + onQueryTypeChange, + onKeyDownFunc, + }; + + Object.assign(props, propOverrides); + + return render(); +}; + +describe('LokiOptionFields', () => { + it('should render step field', () => { + setup(); + expect(screen.getByTestId('lineLimitField')).toBeInTheDocument(); + }); + + it('should render query type field', () => { + setup(); + expect(screen.getByTestId('queryTypeField')).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx new file mode 100644 index 0000000..20f73c1 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiOptionFields.tsx @@ -0,0 +1,131 @@ +// Libraries +import React, { memo } from 'react'; +import { css, cx } from 'emotion'; +import { LokiQuery } from '../types'; + +// Types +import { InlineFormLabel, RadioButtonGroup, InlineField, Input } from '@grafana/ui'; + +export interface LokiOptionFieldsProps { + lineLimitValue: string; + queryType: LokiQueryType; + query: LokiQuery; + onChange: (value: LokiQuery) => void; + onRunQuery: () => void; + runOnBlur?: boolean; +} + +type LokiQueryType = 'instant' | 'range'; + +const queryTypeOptions = [ + { value: 'range', label: 'Range', description: 'Run query over a range of time.' }, + { + value: 'instant', + label: 'Instant', + description: 'Run query against a single point in time. For this query, the "To" time is used.', + }, +]; + +export function LokiOptionFields(props: LokiOptionFieldsProps) { + const { lineLimitValue, queryType, query, onRunQuery, runOnBlur, onChange } = props; + + function onChangeQueryLimit(value: string) { + const nextQuery = { ...query, maxLines: preprocessMaxLines(value) }; + onChange(nextQuery); + } + + function onQueryTypeChange(value: LokiQueryType) { + let nextQuery; + if (value === 'instant') { + nextQuery = { ...query, instant: true, range: false }; + } else { + nextQuery = { ...query, instant: false, range: true }; + } + onChange(nextQuery); + } + + function preprocessMaxLines(value: string): number { + if (value.length === 0) { + // empty input - falls back to dataSource.maxLines limit + return NaN; + } else if (value.length > 0 && (isNaN(+value) || +value < 0)) { + // input with at least 1 character and that is either incorrect (value in the input field is not a number) or negative + // falls back to the limit of 0 lines + return 0; + } else { + // default case - correct input + return +value; + } + } + + function onMaxLinesChange(e: React.SyntheticEvent) { + if (query.maxLines !== preprocessMaxLines(e.currentTarget.value)) { + onChangeQueryLimit(e.currentTarget.value); + } + } + + function onReturnKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + onRunQuery(); + } + } + + return ( +
      + {/*Query type field*/} +
      + Query type + + { + onQueryTypeChange(type); + if (runOnBlur) { + onRunQuery(); + } + }} + /> +
      + {/*Line limit field*/} +
      + + { + if (runOnBlur) { + onRunQuery(); + } + }} + /> + +
      +
      + ); +} + +export default memo(LokiOptionFields); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx new file mode 100644 index 0000000..53485f9 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { toUtc } from '@grafana/data'; + +import { LokiQueryEditor } from './LokiQueryEditor'; +import { LokiDatasource } from '../datasource'; +import { LokiQuery } from '../types'; + +const createMockRequestRange = (from: string, to: string) => { + return { + from: toUtc(from, 'YYYY-MM-DD'), + to: toUtc(to, 'YYYY-MM-DD'), + }; +}; + +const setup = (propOverrides?: object) => { + const datasourceMock: unknown = {}; + const datasource: LokiDatasource = datasourceMock as LokiDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + + const query: LokiQuery = { + expr: '', + refId: 'A', + legendFormat: 'My Legend', + }; + + const range = createMockRequestRange('2020-01-01', '2020-01-02'); + + const props: any = { + datasource, + onChange, + onRunQuery, + query, + range, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance(); + + return { + instance, + wrapper, + }; +}; + +describe('Render LokiQueryEditor with legend', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should update timerange', () => { + const { wrapper } = setup(); + wrapper.setProps({ + range: createMockRequestRange('2019-01-01', '2020-01-02'), + }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx new file mode 100644 index 0000000..67ded1b --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx @@ -0,0 +1,59 @@ +// Libraries +import React, { memo } from 'react'; + +// Types +import { QueryEditorProps } from '@grafana/data'; +import { InlineFormLabel } from '@grafana/ui'; +import { LokiDatasource } from '../datasource'; +import { LokiQuery, LokiOptions } from '../types'; +import { LokiQueryField } from './LokiQueryField'; + +type Props = QueryEditorProps; + +export function LokiQueryEditor(props: Props) { + const { range, query, data, datasource, onChange, onRunQuery } = props; + + const onLegendChange = (e: React.SyntheticEvent) => { + const nextQuery = { ...query, legendFormat: e.currentTarget.value }; + onChange(nextQuery); + }; + + const legendField = ( +
      +
      + + Legend + + +
      +
      + ); + + return ( + + ); +} + +export default memo(LokiQueryEditor); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx new file mode 100644 index 0000000..37946b4 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -0,0 +1,16 @@ +import React, { FunctionComponent } from 'react'; +import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm'; + +type LokiQueryFieldProps = Omit< + LokiQueryFieldFormProps, + 'labelsLoaded' | 'onLoadOptions' | 'onLabelsRefresh' | 'absoluteRange' +>; + +export const LokiQueryField: FunctionComponent = (props) => { + const { datasource, range, ...otherProps } = props; + const absoluteTimeRange = { from: range!.from!.valueOf(), to: range!.to!.valueOf() }; // Range here is never optional + + return ; +}; + +export default LokiQueryField; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx new file mode 100644 index 0000000..1da18e0 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx @@ -0,0 +1,194 @@ +// Libraries +import React, { ReactNode } from 'react'; + +import { + SlatePrism, + TypeaheadOutput, + SuggestionsState, + QueryField, + TypeaheadInput, + BracesPlugin, + DOMUtil, + Icon, +} from '@grafana/ui'; + +// Utils & Services +// dom also includes Element polyfills +import { Plugin, Node } from 'slate'; +import { LokiLabelBrowser } from './LokiLabelBrowser'; + +// Types +import { ExploreQueryFieldProps, AbsoluteTimeRange } from '@grafana/data'; +import { LokiQuery, LokiOptions } from '../types'; +import { LanguageMap, languages as prismLanguages } from 'prismjs'; +import LokiLanguageProvider, { LokiHistoryItem } from '../language_provider'; +import LokiDatasource from '../datasource'; +import LokiOptionFields from './LokiOptionFields'; + +function getChooserText(hasSyntax: boolean, hasLogLabels: boolean) { + if (!hasSyntax) { + return 'Loading labels...'; + } + if (!hasLogLabels) { + return '(No logs found)'; + } + return 'Log browser'; +} + +function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { + // Modify suggestion based on context + switch (typeaheadContext) { + case 'context-labels': { + const nextChar = DOMUtil.getNextCharacter(); + if (!nextChar || nextChar === '}' || nextChar === ',') { + suggestion += '='; + } + break; + } + + case 'context-label-values': { + // Always add quotes and remove existing ones instead + if (!typeaheadText.match(/^(!?=~?"|")/)) { + suggestion = `"${suggestion}`; + } + if (DOMUtil.getNextCharacter() !== '"') { + suggestion = `${suggestion}"`; + } + break; + } + + default: + } + return suggestion; +} + +export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps { + history: LokiHistoryItem[]; + absoluteRange: AbsoluteTimeRange; + ExtraFieldElement?: ReactNode; + runOnBlur?: boolean; +} + +interface LokiQueryFieldFormState { + labelsLoaded: boolean; + labelBrowserVisible: boolean; +} + +export class LokiQueryFieldForm extends React.PureComponent { + plugins: Plugin[]; + + constructor(props: LokiQueryFieldFormProps) { + super(props); + + this.state = { labelsLoaded: false, labelBrowserVisible: false }; + + this.plugins = [ + BracesPlugin(), + SlatePrism( + { + onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', + getSyntax: (node: Node) => 'logql', + }, + { ...(prismLanguages as LanguageMap), logql: this.props.datasource.languageProvider.getSyntax() } + ), + ]; + } + + async componentDidUpdate() { + await this.props.datasource.languageProvider.start(); + this.setState({ labelsLoaded: true }); + } + + onChangeLogLabels = (selector: string) => { + this.onChangeQuery(selector, true); + this.setState({ labelBrowserVisible: false }); + }; + + onChangeQuery = (value: string, override?: boolean) => { + // Send text change to parent + const { query, onChange, onRunQuery } = this.props; + if (onChange) { + const nextQuery = { ...query, expr: value }; + onChange(nextQuery); + + if (override && onRunQuery) { + onRunQuery(); + } + } + }; + + onClickChooserButton = () => { + this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible })); + }; + + onTypeahead = async (typeahead: TypeaheadInput): Promise => { + const { datasource } = this.props; + + if (!datasource.languageProvider) { + return { suggestions: [] }; + } + + const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider; + const { history } = this.props; + const { prefix, text, value, wrapperClasses, labelKey } = typeahead; + + const result = await lokiLanguageProvider.provideCompletionItems( + { text, value, prefix, wrapperClasses, labelKey }, + { history } + ); + return result; + }; + + render() { + const { ExtraFieldElement, query, datasource, runOnBlur } = this.props; + const { labelsLoaded, labelBrowserVisible } = this.state; + const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider; + const cleanText = datasource.languageProvider ? lokiLanguageProvider.cleanText : undefined; + const hasLogLabels = lokiLanguageProvider.getLabelKeys().length > 0; + const chooserText = getChooserText(labelsLoaded, hasLogLabels); + const buttonDisabled = !(labelsLoaded && hasLogLabels); + + return ( + <> +
      + +
      + +
      +
      + {labelBrowserVisible && ( +
      + +
      + )} + + {ExtraFieldElement} + + ); + } +} diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap new file mode 100644 index 0000000..5b33453 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LokiExploreQueryEditor should render component 1`] = ` + +`; diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiQueryEditor.test.tsx.snap new file mode 100644 index 0000000..c22188a --- /dev/null +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiQueryEditor.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render LokiQueryEditor with legend should render 1`] = ` + +
      + + Legend + + +
      +
      + } + datasource={Object {}} + history={Array []} + onBlur={[MockFunction]} + onChange={[MockFunction]} + onRunQuery={[MockFunction]} + query={ + Object { + "expr": "", + "legendFormat": "My Legend", + "refId": "A", + } + } + range={ + Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + } + } + runOnBlur={true} +/> +`; + +exports[`Render LokiQueryEditor with legend should update timerange 1`] = ` + +
      + + Legend + + +
      +
      + } + datasource={Object {}} + history={Array []} + onBlur={[MockFunction]} + onChange={[MockFunction]} + onRunQuery={[MockFunction]} + query={ + Object { + "expr": "", + "legendFormat": "My Legend", + "refId": "A", + } + } + range={ + Object { + "from": "2019-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + } + } + runOnBlur={true} +/> +`; diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx new file mode 100644 index 0000000..39f2a3b --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { ConfigEditor } from './ConfigEditor'; +import { createDefaultConfigOptions } from '../mocks'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { DerivedFields } from './DerivedFields'; + +describe('ConfigEditor', () => { + it('should render without error', () => { + mount( {}} options={createDefaultConfigOptions()} />); + }); + + it('should render the right sections', () => { + const wrapper = mount( {}} options={createDefaultConfigOptions()} />); + expect(wrapper.find(DataSourceHttpSettings).length).toBe(1); + expect(wrapper.find({ label: 'Maximum lines' }).length).toBe(1); + expect(wrapper.find(DerivedFields).length).toBe(1); + }); + + it('should pass correct data to onChange', () => { + const onChangeMock = jest.fn(); + const wrapper = mount(); + const inputWrapper = wrapper.find({ label: 'Maximum lines' }).find('input'); + (inputWrapper.getDOMNode() as any).value = 42; + inputWrapper.simulate('change'); + expect(onChangeMock.mock.calls[0][0].jsonData.maxLines).toBe('42'); + }); +}); diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx new file mode 100644 index 0000000..a0323e4 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { LokiOptions } from '../types'; +import { MaxLinesField } from './MaxLinesField'; +import { DerivedFields } from './DerivedFields'; + +export type Props = DataSourcePluginOptionsEditorProps; + +const makeJsonUpdater = (field: keyof LokiOptions) => ( + options: DataSourceSettings, + value: T +): DataSourceSettings => { + return { + ...options, + jsonData: { + ...options.jsonData, + [field]: value, + }, + }; +}; + +const setMaxLines = makeJsonUpdater('maxLines'); +const setDerivedFields = makeJsonUpdater('derivedFields'); + +export const ConfigEditor = (props: Props) => { + const { options, onOptionsChange } = props; + + return ( + <> + + +
      +
      +
      + onOptionsChange(setMaxLines(options, value))} + /> +
      +
      +
      + + onOptionsChange(setDerivedFields(options, value))} + /> + + ); +}; diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx new file mode 100644 index 0000000..e6c54a5 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { DebugSection } from './DebugSection'; +import { mount } from 'enzyme'; +import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from '../../../../features/panel/panellinks/link_srv'; +import { TimeSrv } from '../../../../features/dashboard/services/TimeSrv'; +import { dateTime } from '@grafana/data'; +import { TemplateSrv } from '../../../../features/templating/template_srv'; + +describe('DebugSection', () => { + let originalLinkSrv: LinkService; + + // This needs to be setup so we can test interpolation in the debugger + beforeAll(() => { + // We do not need more here and TimeSrv is hard to setup fully. + const timeSrvMock: TimeSrv = { + timeRangeForUrl() { + const from = dateTime().subtract(1, 'h'); + const to = dateTime(); + return { from, to, raw: { from, to } }; + }, + } as any; + const linkService = new LinkSrv(new TemplateSrv(), timeSrvMock); + originalLinkSrv = getLinkSrv(); + setLinkSrv(linkService); + }); + + afterAll(() => { + setLinkSrv(originalLinkSrv); + }); + + it('does not render any field if no debug text', () => { + const wrapper = mount(); + expect(wrapper.find('DebugFieldItem').length).toBe(0); + }); + + it('does not render any field if no derived fields', () => { + const wrapper = mount(); + const textarea = wrapper.find('textarea'); + (textarea.getDOMNode() as HTMLTextAreaElement).value = 'traceId=1234'; + textarea.simulate('change'); + expect(wrapper.find('DebugFieldItem').length).toBe(0); + }); + + it('renders derived fields', () => { + const derivedFields = [ + { + matcherRegex: 'traceId=(\\w+)', + name: 'traceIdLink', + url: 'http://localhost/trace/${__value.raw}', + }, + { + matcherRegex: 'traceId=(\\w+)', + name: 'traceId', + }, + { + matcherRegex: 'traceId=(', + name: 'error', + }, + ]; + + const wrapper = mount(); + const textarea = wrapper.find('textarea'); + (textarea.getDOMNode() as HTMLTextAreaElement).value = 'traceId=1234'; + textarea.simulate('change'); + + expect(wrapper.find('table').length).toBe(1); + // 3 rows + one header + expect(wrapper.find('tr').length).toBe(4); + expect(wrapper.find('tr').at(1).contains('http://localhost/trace/1234')).toBeTruthy(); + }); +}); diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx new file mode 100644 index 0000000..b4ee9ff --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import cx from 'classnames'; +import { LegacyForms } from '@grafana/ui'; +const { FormField } = LegacyForms; +import { DerivedFieldConfig } from '../types'; +import { ArrayVector, Field, FieldType, LinkModel } from '@grafana/data'; +import { getFieldLinksForExplore } from '../../../../features/explore/utils/links'; + +type Props = { + derivedFields?: DerivedFieldConfig[]; + className?: string; +}; +export const DebugSection = (props: Props) => { + const { derivedFields, className } = props; + const [debugText, setDebugText] = useState(''); + + let debugFields: DebugField[] = []; + if (debugText && derivedFields) { + debugFields = makeDebugFields(derivedFields, debugText); + } + + return ( +
      + setDebugText(event.currentTarget.value)} + /> + } + /> + {!!debugFields.length && } +
      + ); +}; + +type DebugFieldItemProps = { + fields: DebugField[]; +}; +const DebugFields = ({ fields }: DebugFieldItemProps) => { + return ( +
      + + + + + + + + + {fields.map((field) => { + let value: any = field.value; + if (field.error) { + value = field.error.message; + } else if (field.href) { + value = {value}; + } + return ( + + + + + + ); + })} + +
      NameValueUrl
      {field.name}{value}{field.href ? {field.href} : ''}
      + ); +}; + +type DebugField = { + name: string; + error?: any; + value?: string; + href?: string; +}; + +function makeDebugFields(derivedFields: DerivedFieldConfig[], debugText: string): DebugField[] { + return derivedFields + .filter((field) => field.name && field.matcherRegex) + .map((field) => { + try { + const testMatch = debugText.match(field.matcherRegex); + const value = testMatch && testMatch[1]; + let link: LinkModel | null = null; + + if (field.url && value) { + link = getFieldLinksForExplore({ + field: { + name: '', + type: FieldType.string, + values: new ArrayVector([value]), + config: { + links: [{ title: '', url: field.url }], + }, + }, + rowIndex: 0, + range: {} as any, + })[0]; + } + + return { + name: field.name, + value: value || '', + href: link && link.href, + } as DebugField; + } catch (error) { + return { + name: field.name, + error, + } as DebugField; + } + }); +} diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx new file mode 100644 index 0000000..64bf6d5 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DerivedField } from './DerivedField'; +import { DataSourcePicker } from '../../../../core/components/Select/DataSourcePicker'; +import { DataSourceInstanceSettings } from '@grafana/data'; + +jest.mock('app/features/plugins/datasource_srv', () => ({ + getDatasourceSrv() { + return { + getExternal(): DataSourceInstanceSettings[] { + return [ + { + id: 1, + uid: 'metrics', + name: 'metrics_ds', + meta: { + tracing: false, + } as any, + } as any, + + { + id: 2, + uid: 'tracing', + name: 'tracing_ds', + meta: { + tracing: true, + } as any, + } as any, + ]; + }, + }; + }, +})); + +describe('DerivedField', () => { + it('shows internal link if uid is set', () => { + const value = { + matcherRegex: '', + name: '', + datasourceUid: 'test', + }; + const wrapper = shallow( {}} onDelete={() => {}} suggestions={[]} />); + + expect(wrapper.find(DataSourcePicker).length).toBe(1); + }); + + it('shows url link if uid is not set', () => { + const value = { + matcherRegex: '', + name: '', + url: 'test', + }; + const wrapper = shallow( {}} onDelete={() => {}} suggestions={[]} />); + expect(wrapper.find(DataSourcePicker).length).toBe(0); + }); + + it('shows only tracing datasources for internal link', () => { + const value = { + matcherRegex: '', + name: '', + datasourceUid: 'test', + }; + const wrapper = shallow( {}} onDelete={() => {}} suggestions={[]} />); + expect(wrapper.find(DataSourcePicker).props().tracing).toEqual(true); + }); +}); diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx new file mode 100644 index 0000000..a93b194 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useState } from 'react'; +import { css } from 'emotion'; +import { Button, DataLinkInput, stylesFactory, LegacyForms } from '@grafana/ui'; +import { VariableSuggestion } from '@grafana/data'; +import { DerivedFieldConfig } from '../types'; +import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; +import { usePrevious } from 'react-use'; + +const { Switch, FormField } = LegacyForms; + +const getStyles = stylesFactory(() => ({ + row: css` + display: flex; + align-items: baseline; + `, + nameField: css` + flex: 2; + `, + regexField: css` + flex: 3; + `, +})); + +type Props = { + value: DerivedFieldConfig; + onChange: (value: DerivedFieldConfig) => void; + onDelete: () => void; + suggestions: VariableSuggestion[]; + className?: string; +}; +export const DerivedField = (props: Props) => { + const { value, onChange, onDelete, suggestions, className } = props; + const styles = getStyles(); + const [showInternalLink, setShowInternalLink] = useState(!!value.datasourceUid); + const previousUid = usePrevious(value.datasourceUid); + + // Force internal link visibility change if uid changed outside of this component. + useEffect(() => { + if (!previousUid && value.datasourceUid && !showInternalLink) { + setShowInternalLink(true); + } + if (previousUid && !value.datasourceUid && showInternalLink) { + setShowInternalLink(false); + } + }, [previousUid, value.datasourceUid, showInternalLink]); + + const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent) => { + onChange({ + ...value, + [field]: event.currentTarget.value, + }); + }; + + return ( +
      +
      + + +
      + + + onChange({ + ...value, + url: newValue, + }) + } + suggestions={suggestions} + /> + } + className={css` + width: 100%; + `} + /> + +
      + { + if (showInternalLink) { + onChange({ + ...value, + datasourceUid: undefined, + }); + } + setShowInternalLink(!showInternalLink); + }} + /> + + {showInternalLink && ( + + onChange({ + ...value, + datasourceUid: ds.uid, + }) + } + current={value.datasourceUid} + /> + )} +
      +
      + ); +}; diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx new file mode 100644 index 0000000..932f58b --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DerivedFields } from './DerivedFields'; +import { Button } from '@grafana/ui'; +import { DerivedField } from './DerivedField'; +import { act } from 'react-dom/test-utils'; + +describe('DerivedFields', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('renders correctly when no fields', async () => { + let wrapper: any; + //@ts-ignore + await act(async () => { + wrapper = await mount( {}} />); + }); + expect(wrapper.find(Button).length).toBe(1); + expect(wrapper.find(Button).contains('Add')).toBeTruthy(); + expect(wrapper.find(DerivedField).length).toBe(0); + }); + + it('renders correctly when there are fields', async () => { + let wrapper: any; + //@ts-ignore + await act(async () => { + wrapper = await mount( {}} />); + }); + + expect(wrapper.find(Button).filterWhere((button: any) => button.contains('Add')).length).toBe(1); + expect(wrapper.find(Button).filterWhere((button: any) => button.contains('Show example log message')).length).toBe( + 1 + ); + expect(wrapper.find(DerivedField).length).toBe(2); + }); + + it('adds new field', async () => { + const onChangeMock = jest.fn(); + let wrapper: any; + //@ts-ignore + await act(async () => { + wrapper = await mount(); + }); + const addButton = wrapper.find(Button).filterWhere((button: any) => button.contains('Add')); + addButton.simulate('click'); + expect(onChangeMock.mock.calls[0][0].length).toBe(1); + }); + + it('removes field', async () => { + const onChangeMock = jest.fn(); + let wrapper: any; + //@ts-ignore + await act(async () => { + wrapper = await mount(); + }); + const removeButton = wrapper.find(DerivedField).at(0).find(Button); + removeButton.simulate('click'); + const newValue = onChangeMock.mock.calls[0][0]; + expect(newValue.length).toBe(1); + expect(newValue[0]).toMatchObject({ + matcherRegex: 'regex2', + name: 'test2', + url: 'localhost2', + }); + }); +}); + +const testValue = [ + { + matcherRegex: 'regex1', + name: 'test1', + url: 'localhost1', + }, + { + matcherRegex: 'regex2', + name: 'test2', + url: 'localhost2', + }, +]; diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx new file mode 100644 index 0000000..bcabb04 --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { Button, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data'; +import { DerivedFieldConfig } from '../types'; +import { DerivedField } from './DerivedField'; +import { DebugSection } from './DebugSection'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + infoText: css` + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textWeak}; + `, + derivedField: css` + margin-bottom: ${theme.spacing.sm}; + `, +})); + +type Props = { + value?: DerivedFieldConfig[]; + onChange: (value: DerivedFieldConfig[]) => void; +}; + +export const DerivedFields = (props: Props) => { + const { value, onChange } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + const [showDebug, setShowDebug] = useState(false); + + return ( + <> +

      Derived fields

      + +
      + Derived fields can be used to extract new fields from a log message and create a link from its value. +
      + +
      + {value && + value.map((field, index) => { + return ( + { + const newDerivedFields = [...value]; + newDerivedFields.splice(index, 1, newField); + onChange(newDerivedFields); + }} + onDelete={() => { + const newDerivedFields = [...value]; + newDerivedFields.splice(index, 1); + onChange(newDerivedFields); + }} + suggestions={[ + { + value: DataLinkBuiltInVars.valueRaw, + label: 'Raw value', + documentation: 'Exact string captured by the regular expression', + origin: VariableOrigin.Value, + }, + ]} + /> + ); + })} +
      + + + {value && value.length > 0 && ( + + )} +
      +
      + + {showDebug && ( +
      + +
      + )} + + ); +}; diff --git a/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx b/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx new file mode 100644 index 0000000..5d3dddd --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/MaxLinesField.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { LegacyForms } from '@grafana/ui'; +const { FormField } = LegacyForms; + +type Props = { + value: string; + onChange: (value: string) => void; +}; + +export const MaxLinesField = (props: Props) => { + const { value, onChange } = props; + return ( + onChange(event.currentTarget.value)} + spellCheck={false} + placeholder="1000" + /> + } + tooltip={ + <> + Loki queries must contain a limit of the maximum number of lines returned (default: 1000). Increase this limit + to have a bigger result set for ad-hoc analysis. Decrease this limit if your browser becomes sluggish when + displaying the log results. + + } + /> + ); +}; diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts new file mode 100644 index 0000000..64fc678 --- /dev/null +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -0,0 +1,587 @@ +import { of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AnnotationQueryRequest, CoreApp, DataFrame, dateTime, FieldCache, TimeSeries } from '@grafana/data'; +import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; + +import LokiDatasource from './datasource'; +import { LokiQuery, LokiResponse, LokiResultType } from './types'; +import { getQueryOptions } from 'test/helpers/getQueryOptions'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { CustomVariableModel } from '../../../features/variables/types'; +import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer'; +import { makeMockLokiDatasource } from './mocks'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; + +jest.mock('@grafana/runtime', () => ({ + // @ts-ignore + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => backendSrv, +})); + +const timeSrvStub = { + timeRange: () => ({ + from: new Date(0), + to: new Date(1), + }), +}; + +const testLogsResponse: FetchResponse = { + data: { + data: { + resultType: LokiResultType.Stream, + result: [ + { + stream: {}, + values: [['1573646419522934000', 'hello']], + }, + ], + }, + status: 'success', + }, + ok: true, + headers: ({} as unknown) as Headers, + redirected: false, + status: 200, + statusText: 'Success', + type: 'default', + url: '', + config: ({} as unknown) as BackendSrvRequest, +}; + +const testMetricsResponse: FetchResponse = { + data: { + data: { + resultType: LokiResultType.Matrix, + result: [ + { + metric: {}, + values: [[1605715380, '1.1']], + }, + ], + }, + status: 'success', + }, + ok: true, + headers: ({} as unknown) as Headers, + redirected: false, + status: 200, + statusText: 'OK', + type: 'basic', + url: '', + config: ({} as unknown) as BackendSrvRequest, +}; + +describe('LokiDatasource', () => { + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockImplementation(() => of(createFetchResponse({}))); + }); + + describe('when creating range query', () => { + let ds: LokiDatasource; + let adjustIntervalSpy: jest.SpyInstance; + + beforeEach(() => { + ds = createLokiDSForTests(); + adjustIntervalSpy = jest.spyOn(ds, 'adjustInterval'); + }); + + it('should use default intervalMs if one is not provided', () => { + const target = { expr: '{job="grafana"}', refId: 'B' }; + const raw = { from: 'now', to: 'now-1h' }; + const range = { from: dateTime(), to: dateTime(), raw: raw }; + const options = { + range, + }; + + const req = ds.createRangeQuery(target, options as any, 1000); + expect(req.start).toBeDefined(); + expect(req.end).toBeDefined(); + expect(adjustIntervalSpy).toHaveBeenCalledWith(1000, expect.anything()); + }); + + it('should use provided intervalMs', () => { + const target = { expr: '{job="grafana"}', refId: 'B' }; + const raw = { from: 'now', to: 'now-1h' }; + const range = { from: dateTime(), to: dateTime(), raw: raw }; + const options = { + range, + intervalMs: 2000, + }; + + const req = ds.createRangeQuery(target, options as any, 1000); + expect(req.start).toBeDefined(); + expect(req.end).toBeDefined(); + expect(adjustIntervalSpy).toHaveBeenCalledWith(2000, expect.anything()); + }); + + it('should set the minimal step to 1ms', () => { + const target = { expr: '{job="grafana"}', refId: 'B' }; + const raw = { from: 'now', to: 'now-1h' }; + const range = { from: dateTime('2020-10-14T00:00:00'), to: dateTime('2020-10-14T00:00:01'), raw: raw }; + const options = { + range, + intervalMs: 0.0005, + }; + + const req = ds.createRangeQuery(target, options as any, 1000); + expect(req.start).toBeDefined(); + expect(req.end).toBeDefined(); + expect(adjustIntervalSpy).toHaveBeenCalledWith(0.0005, expect.anything()); + // Step is in seconds (1 ms === 0.001 s) + expect(req.step).toEqual(0.001); + }); + }); + + describe('when doing logs queries with limits', () => { + const runLimitTest = async ({ + maxDataPoints = 123, + queryMaxLines, + dsMaxLines = 456, + expectedLimit, + expr = '{label="val"}', + }: any) => { + let settings: any = { + url: 'myloggingurl', + jsonData: { + maxLines: dsMaxLines, + }, + }; + + const templateSrvMock = ({ + getAdhocFilters: (): any[] => [], + replace: (a: string) => a, + } as unknown) as TemplateSrv; + + const ds = new LokiDatasource(settings, templateSrvMock, timeSrvStub as any); + + const options = getQueryOptions({ targets: [{ expr, refId: 'B', maxLines: queryMaxLines }] }); + options.maxDataPoints = maxDataPoints; + + fetchMock.mockImplementation(() => of(testLogsResponse)); + + await expect(ds.query(options).pipe(take(1))).toEmitValuesWith(() => { + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][0].url).toContain(`limit=${expectedLimit}`); + }); + }; + + it('should use datasource max lines when no limit given and it is log query', async () => { + await runLimitTest({ expectedLimit: 456 }); + }); + + it('should use custom max lines from query if set and it is logs query', async () => { + await runLimitTest({ queryMaxLines: 20, expectedLimit: 20 }); + }); + + it('should use custom max lines from query if set and it is logs query even if it is higher than data source limit', async () => { + await runLimitTest({ queryMaxLines: 500, expectedLimit: 500 }); + }); + + it('should use maxDataPoints if it is metrics query', async () => { + await runLimitTest({ expr: 'rate({label="val"}[10m])', expectedLimit: 123 }); + }); + + it('should use maxDataPoints if it is metrics query and using search', async () => { + await runLimitTest({ expr: 'rate({label="val"}[10m])', expectedLimit: 123 }); + }); + }); + + describe('when querying', () => { + function setup(expr: string, app: CoreApp, instant?: boolean, range?: boolean) { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr, refId: 'B', instant, range }], + app, + }); + ds.runInstantQuery = jest.fn(() => of({ data: [] })); + ds.runRangeQuery = jest.fn(() => of({ data: [] })); + return { ds, options }; + } + + const metricsQuery = 'rate({job="grafana"}[10m])'; + const logsQuery = '{job="grafana"} |= "foo"'; + + it('should run logs instant if only instant is selected', async () => { + const { ds, options } = setup(logsQuery, CoreApp.Explore, true, false); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).toBeCalled(); + expect(ds.runRangeQuery).not.toBeCalled(); + }); + + it('should run metrics instant if only instant is selected', async () => { + const { ds, options } = setup(metricsQuery, CoreApp.Explore, true, false); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).toBeCalled(); + expect(ds.runRangeQuery).not.toBeCalled(); + }); + + it('should run only logs range query if only range is selected', async () => { + const { ds, options } = setup(logsQuery, CoreApp.Explore, false, true); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should run only metrics range query if only range is selected', async () => { + const { ds, options } = setup(metricsQuery, CoreApp.Explore, false, true); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should run only logs range query if no query type is selected in Explore', async () => { + const { ds, options } = setup(logsQuery, CoreApp.Explore); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should run only metrics range query if no query type is selected in Explore', async () => { + const { ds, options } = setup(metricsQuery, CoreApp.Explore); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should run only logs range query in Dashboard', async () => { + const { ds, options } = setup(logsQuery, CoreApp.Dashboard); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should run only metrics range query in Dashboard', async () => { + const { ds, options } = setup(metricsQuery, CoreApp.Dashboard); + await ds.query(options).toPromise(); + expect(ds.runInstantQuery).not.toBeCalled(); + expect(ds.runRangeQuery).toBeCalled(); + }); + + it('should return series data for metrics range queries', async () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr: metricsQuery, refId: 'B', range: true }], + app: CoreApp.Explore, + }); + + fetchMock.mockImplementation(() => of(testMetricsResponse)); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + const result = received[0]; + const timeSeries = result.data[0] as TimeSeries; + + expect(timeSeries.meta?.preferredVisualisationType).toBe('graph'); + expect(timeSeries.refId).toBe('B'); + expect(timeSeries.datapoints[0]).toEqual([1.1, 1605715380000]); + }); + }); + + it('should return series data for logs range query', async () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr: logsQuery, refId: 'B' }], + }); + + fetchMock.mockImplementation(() => of(testLogsResponse)); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + const result = received[0]; + const dataFrame = result.data[0] as DataFrame; + const fieldCache = new FieldCache(dataFrame); + + expect(fieldCache.getFieldByName('line')?.values.get(0)).toBe('hello'); + expect(dataFrame.meta?.limit).toBe(20); + expect(dataFrame.meta?.searchWords).toEqual(['foo']); + }); + }); + + it('should return custom error message when Loki returns escaping error', async () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr: '{job="gra\\fana"}', refId: 'B' }], + }); + + fetchMock.mockImplementation(() => + throwError({ + data: { + message: 'parse error at line 1, col 6: invalid char escape', + }, + status: 400, + statusText: 'Bad Request', + }) + ); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + const err: any = received[0]; + expect(err.data.message).toBe( + 'Error: parse error at line 1, col 6: invalid char escape. Make sure that all special characters are escaped with \\. For more information on escaping of special characters visit LogQL documentation at https://grafana.com/docs/loki/latest/logql/.' + ); + }); + }); + }); + + describe('when interpolating variables', () => { + let ds: LokiDatasource; + let variable: CustomVariableModel; + + beforeEach(() => { + ds = createLokiDSForTests(); + variable = { ...initialCustomVariableModelState }; + }); + + it('should only escape single quotes', () => { + expect(ds.interpolateQueryExpr("abc'$^*{}[]+?.()|", variable)).toEqual("abc\\\\'$^*{}[]+?.()|"); + }); + + it('should return a number', () => { + expect(ds.interpolateQueryExpr(1000, variable)).toEqual(1000); + }); + + describe('and variable allows multi-value', () => { + beforeEach(() => { + variable.multi = true; + }); + + it('should regex escape values if the value is a string', () => { + expect(ds.interpolateQueryExpr('looking*glass', variable)).toEqual('looking\\\\*glass'); + }); + + it('should return pipe separated values if the value is an array of strings', () => { + expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], variable)).toEqual('a\\\\|bc|de\\\\|f'); + }); + }); + + describe('and variable allows all', () => { + beforeEach(() => { + variable.includeAll = true; + }); + + it('should regex escape values if the array is a string', () => { + expect(ds.interpolateQueryExpr('looking*glass', variable)).toEqual('looking\\\\*glass'); + }); + + it('should return pipe separated values if the value is an array of strings', () => { + expect(ds.interpolateQueryExpr(['a|bc', 'de|f'], variable)).toEqual('a\\\\|bc|de\\\\|f'); + }); + }); + }); + + describe('when performing testDataSource', () => { + describe('and call succeeds', () => { + it('should return successfully', async () => { + fetchMock.mockImplementation(() => of(createFetchResponse({ values: ['avalue'] }))); + const ds = createLokiDSForTests({} as TemplateSrv); + + const result = await ds.testDatasource(); + + expect(result.status).toBe('success'); + }); + }); + + describe('and call fails with 401 error', () => { + it('should return error status and a detailed error message', async () => { + fetchMock.mockImplementation(() => + throwError({ + statusText: 'Unauthorized', + status: 401, + data: { + message: 'Unauthorized', + }, + }) + ); + const ds = createLokiDSForTests({} as TemplateSrv); + + const result = await ds.testDatasource(); + + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Unauthorized. 401. Unauthorized'); + }); + }); + + describe('and call fails with 404 error', () => { + it('should return error status and a detailed error message', async () => { + fetchMock.mockImplementation(() => + throwError({ + statusText: 'Not found', + status: 404, + data: { + message: '404 page not found', + }, + }) + ); + + const ds = createLokiDSForTests({} as TemplateSrv); + + const result = await ds.testDatasource(); + + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Not found. 404. 404 page not found'); + }); + }); + + describe('and call fails with 502 error', () => { + it('should return error status and a detailed error message', async () => { + fetchMock.mockImplementation(() => + throwError({ + statusText: 'Bad Gateway', + status: 502, + data: '', + }) + ); + + const ds = createLokiDSForTests({} as TemplateSrv); + + const result = await ds.testDatasource(); + + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Bad Gateway. 502'); + }); + }); + }); + + describe('when calling annotationQuery', () => { + const getTestContext = (response: any) => { + const query = makeAnnotationQueryRequest(); + fetchMock.mockImplementation(() => of(response)); + + const ds = createLokiDSForTests(); + const promise = ds.annotationQuery(query); + + return { promise }; + }; + + it('should transform the loki data to annotation response', async () => { + const response: FetchResponse = ({ + data: { + data: { + resultType: LokiResultType.Stream, + result: [ + { + stream: { + label: 'value', + label2: 'value ', + }, + values: [['1549016857498000000', 'hello']], + }, + { + stream: { + label: '', // empty value gets filtered + label2: 'value2', + label3: ' ', // whitespace value gets trimmed then filtered + }, + values: [['1549024057498000000', 'hello 2']], + }, + ], + }, + status: 'success', + }, + } as unknown) as FetchResponse; + const { promise } = getTestContext(response); + + const res = await promise; + + expect(res.length).toBe(2); + expect(res[0].text).toBe('hello'); + expect(res[0].tags).toEqual(['value']); + + expect(res[1].text).toBe('hello 2'); + expect(res[1].tags).toEqual(['value2']); + }); + }); + + describe('metricFindQuery', () => { + const getTestContext = (mock: LokiDatasource) => { + const ds = createLokiDSForTests(); + ds.getVersion = mock.getVersion; + ds.metadataRequest = mock.metadataRequest; + + return { ds }; + }; + + const mocks = makeMetadataAndVersionsMocks(); + + mocks.forEach((mock, index) => { + it(`should return label names for Loki v${index}`, async () => { + const { ds } = getTestContext(mock); + + const res = await ds.metricFindQuery('label_names()'); + + expect(res).toEqual([{ text: 'label1' }, { text: 'label2' }]); + }); + }); + + mocks.forEach((mock, index) => { + it(`should return label values for Loki v${index}`, async () => { + const { ds } = getTestContext(mock); + + const res = await ds.metricFindQuery('label_values(label1)'); + + expect(res).toEqual([{ text: 'value1' }, { text: 'value2' }]); + }); + }); + + mocks.forEach((mock, index) => { + it(`should return empty array when incorrect query for Loki v${index}`, async () => { + const { ds } = getTestContext(mock); + + const res = await ds.metricFindQuery('incorrect_query'); + + expect(res).toEqual([]); + }); + }); + }); +}); + +function createLokiDSForTests( + templateSrvMock = ({ + getAdhocFilters: (): any[] => [], + replace: (a: string) => a, + } as unknown) as TemplateSrv +): LokiDatasource { + const instanceSettings: any = { + url: 'myloggingurl', + }; + + const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 }; + const customSettings = { ...instanceSettings, jsonData: customData }; + + return new LokiDatasource(customSettings, templateSrvMock, timeSrvStub as any); +} + +function makeAnnotationQueryRequest(): AnnotationQueryRequest { + const timeRange = { + from: dateTime(), + to: dateTime(), + }; + return { + annotation: { + expr: '{test=test}', + refId: '', + datasource: 'loki', + enable: true, + name: 'test-annotation', + }, + dashboard: { + id: 1, + } as any, + range: { + ...timeRange, + raw: timeRange, + }, + rangeRaw: timeRange, + }; +} + +function makeMetadataAndVersionsMocks() { + const mocks = []; + for (let i = 0; i <= 1; i++) { + const mock: LokiDatasource = makeMockLokiDatasource({ label1: ['value1', 'value2'], label2: ['value3', 'value4'] }); + mock.getVersion = jest.fn().mockReturnValue(`v${i}`); + mocks.push(mock); + } + return mocks; +} diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts new file mode 100644 index 0000000..89d21ab --- /dev/null +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -0,0 +1,595 @@ +// Libraries +import { cloneDeep, isEmpty, map as lodashMap } from 'lodash'; +import { merge, Observable, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import Prism from 'prismjs'; + +// Types +import { + AnnotationEvent, + AnnotationQueryRequest, + DataFrame, + DataFrameView, + DataQueryError, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + dateMath, + DateTime, + FieldCache, + LoadingState, + LogRowModel, + PluginMeta, + QueryResultMeta, + ScopedVars, +} from '@grafana/data'; +import { getTemplateSrv, TemplateSrv, BackendSrvRequest, FetchError, getBackendSrv } from '@grafana/runtime'; +import { addLabelToQuery } from 'app/plugins/datasource/prometheus/add_label_to_query'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { convertToWebSocketUrl } from 'app/core/utils/explore'; +import { + lokiResultsToTableModel, + lokiStreamResultToDataFrame, + lokiStreamsToDataFrames, + processRangeQueryResponse, +} from './result_transformer'; +import { getHighlighterExpressionsFromQuery } from './query_utils'; + +import { + LokiOptions, + LokiQuery, + LokiRangeQueryRequest, + LokiResponse, + LokiResultType, + LokiStreamResponse, +} from './types'; +import { LiveStreams, LokiLiveTarget } from './live_streams'; +import LanguageProvider from './language_provider'; +import { serializeParams } from '../../../core/utils/fetch'; +import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; +import syntax from './syntax'; + +export type RangeQueryOptions = DataQueryRequest | AnnotationQueryRequest; +export const DEFAULT_MAX_LINES = 1000; +export const LOKI_ENDPOINT = '/loki/api/v1'; +const NS_IN_MS = 1000000; + +const RANGE_QUERY_ENDPOINT = `${LOKI_ENDPOINT}/query_range`; +const INSTANT_QUERY_ENDPOINT = `${LOKI_ENDPOINT}/query`; + +const DEFAULT_QUERY_PARAMS: Partial = { + direction: 'BACKWARD', + limit: DEFAULT_MAX_LINES, + query: '', +}; + +export class LokiDatasource extends DataSourceApi { + private streams = new LiveStreams(); + languageProvider: LanguageProvider; + maxLines: number; + + constructor( + private instanceSettings: DataSourceInstanceSettings, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(instanceSettings); + + this.languageProvider = new LanguageProvider(this); + const settingsData = instanceSettings.jsonData || {}; + this.maxLines = parseInt(settingsData.maxLines ?? '0', 10) || DEFAULT_MAX_LINES; + } + + _request(apiUrl: string, data?: any, options?: Partial): Observable> { + const baseUrl = this.instanceSettings.url; + const params = data ? serializeParams(data) : ''; + const url = `${baseUrl}${apiUrl}${params.length ? `?${params}` : ''}`; + const req = { + ...options, + url, + }; + + return getBackendSrv().fetch>(req); + } + + query(options: DataQueryRequest): Observable { + const subQueries: Array> = []; + const filteredTargets = options.targets + .filter((target) => target.expr && !target.hide) + .map((target) => ({ + ...target, + expr: this.templateSrv.replace(target.expr, options.scopedVars, this.interpolateQueryExpr), + })); + + for (const target of filteredTargets) { + if (target.instant) { + subQueries.push(this.runInstantQuery(target, options, filteredTargets.length)); + } else { + subQueries.push(this.runRangeQuery(target, options, filteredTargets.length)); + } + } + + // No valid targets, return the empty result to save a round trip. + if (isEmpty(subQueries)) { + return of({ + data: [], + state: LoadingState.Done, + }); + } + + return merge(...subQueries); + } + + runInstantQuery = ( + target: LokiQuery, + options: DataQueryRequest, + responseListLength = 1 + ): Observable => { + const timeNs = this.getTime(options.range.to, true); + const queryLimit = isMetricsQuery(target.expr) ? options.maxDataPoints : target.maxLines; + const query = { + query: target.expr, + time: `${timeNs + (1e9 - (timeNs % 1e9))}`, + limit: Math.min(queryLimit || Infinity, this.maxLines), + }; + + /** Used only for results of metrics instant queries */ + const meta: QueryResultMeta = { + preferredVisualisationType: 'table', + }; + + return this._request(INSTANT_QUERY_ENDPOINT, query).pipe( + map((response: { data: LokiResponse }) => { + if (response.data.data.resultType === LokiResultType.Stream) { + return { + data: response.data + ? lokiStreamsToDataFrames( + response.data as LokiStreamResponse, + target, + query.limit, + this.instanceSettings.jsonData + ) + : [], + key: `${target.refId}_instant`, + }; + } + + return { + data: [lokiResultsToTableModel(response.data.data.result, responseListLength, target.refId, meta, true)], + key: `${target.refId}_instant`, + }; + }), + catchError((err: any) => this.throwUnless(err, err.status === 404, target)) + ); + }; + + createRangeQuery(target: LokiQuery, options: RangeQueryOptions, limit: number): LokiRangeQueryRequest { + const query = target.expr; + let range: { start?: number; end?: number; step?: number } = {}; + if (options.range) { + const startNs = this.getTime(options.range.from, false); + const endNs = this.getTime(options.range.to, true); + const rangeMs = Math.ceil((endNs - startNs) / 1e6); + const adjustedInterval = + this.adjustInterval((options as DataQueryRequest).intervalMs || 1000, rangeMs) / 1000; + // We want to ceil to 3 decimal places + const step = Math.ceil(adjustedInterval * 1000) / 1000; + const alignedTimes = { + start: startNs - (startNs % 1e9), + end: endNs + (1e9 - (endNs % 1e9)), + }; + + range = { + start: alignedTimes.start, + end: alignedTimes.end, + step, + }; + } + + return { + ...DEFAULT_QUERY_PARAMS, + ...range, + query, + limit, + }; + } + + /** + * Attempts to send a query to /loki/api/v1/query_range + */ + runRangeQuery = ( + target: LokiQuery, + options: RangeQueryOptions, + responseListLength = 1 + ): Observable => { + // For metric query we use maxDataPoints from the request options which should be something like width of the + // visualisation in pixels. In case of logs request we either use lines limit defined in the query target or + // global limit defined for the data source which ever is lower. + let maxDataPoints = isMetricsQuery(target.expr) + ? // We fallback to maxLines here because maxDataPoints is defined as possibly undefined. Not sure that can + // actually happen both Dashboards and Explore should send some value here. If not maxLines does not make that + // much sense but nor any other arbitrary value. + (options as DataQueryRequest).maxDataPoints || this.maxLines + : // If user wants maxLines 0 we still fallback to data source limit. I think that makes sense as why would anyone + // want to do a query and not see any results? + target.maxLines || this.maxLines; + + if ((options as DataQueryRequest).liveStreaming) { + return this.runLiveQuery(target, maxDataPoints); + } + const query = this.createRangeQuery(target, options, maxDataPoints); + return this._request(RANGE_QUERY_ENDPOINT, query).pipe( + catchError((err: any) => this.throwUnless(err, err.status === 404, target)), + switchMap((response: { data: LokiResponse; status: number }) => + processRangeQueryResponse( + response.data, + target, + query, + responseListLength, + maxDataPoints, + this.instanceSettings.jsonData, + (options as DataQueryRequest).scopedVars, + (options as DataQueryRequest).reverse + ) + ) + ); + }; + + createLiveTarget(target: LokiQuery, maxDataPoints: number): LokiLiveTarget { + const query = target.expr; + const baseUrl = this.instanceSettings.url; + const params = serializeParams({ query }); + + return { + query, + url: convertToWebSocketUrl(`${baseUrl}/loki/api/v1/tail?${params}`), + refId: target.refId, + size: maxDataPoints, + }; + } + + /** + * Runs live queries which in this case means creating a websocket and listening on it for new logs. + * This returns a bit different dataFrame than runQueries as it returns single dataframe even if there are multiple + * Loki streams, sets only common labels on dataframe.labels and has additional dataframe.fields.labels for unique + * labels per row. + */ + runLiveQuery = (target: LokiQuery, maxDataPoints: number): Observable => { + const liveTarget = this.createLiveTarget(target, maxDataPoints); + + return this.streams.getStream(liveTarget).pipe( + map((data) => ({ + data: data || [], + key: `loki-${liveTarget.refId}`, + state: LoadingState.Streaming, + })), + catchError((err: any) => { + return throwError(`Live tailing was stopped due to following error: ${err.reason}`); + }) + ); + }; + + interpolateVariablesInQueries(queries: LokiQuery[], scopedVars: ScopedVars): LokiQuery[] { + let expandedQueries = queries; + if (queries && queries.length) { + expandedQueries = queries.map((query) => ({ + ...query, + datasource: this.name, + expr: this.templateSrv.replace(query.expr, scopedVars, this.interpolateQueryExpr), + })); + } + + return expandedQueries; + } + + getQueryDisplayText(query: LokiQuery) { + return query.expr; + } + + getTimeRangeParams() { + const timeRange = this.timeSrv.timeRange(); + return { from: timeRange.from.valueOf() * NS_IN_MS, to: timeRange.to.valueOf() * NS_IN_MS }; + } + + async importQueries(queries: LokiQuery[], originMeta: PluginMeta): Promise { + return this.languageProvider.importQueries(queries, originMeta.id); + } + + async metadataRequest(url: string, params?: Record) { + const res = await this._request(url, params, { hideFromInspector: true }).toPromise(); + return res.data.data || res.data.values || []; + } + + async metricFindQuery(query: string) { + if (!query) { + return Promise.resolve([]); + } + const interpolated = this.templateSrv.replace(query, {}, this.interpolateQueryExpr); + return await this.processMetricFindQuery(interpolated); + } + + async processMetricFindQuery(query: string) { + const labelNamesRegex = /^label_names\(\)\s*$/; + const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; + + const params = this.getTimeRangeParams(); + const labelNames = query.match(labelNamesRegex); + if (labelNames) { + return await this.labelNamesQuery(params); + } + + const labelValues = query.match(labelValuesRegex); + if (labelValues) { + return await this.labelValuesQuery(labelValues[2], params); + } + + return Promise.resolve([]); + } + + async labelNamesQuery(params?: Record) { + const url = `${LOKI_ENDPOINT}/label`; + const result = await this.metadataRequest(url, params); + return result.map((value: string) => ({ text: value })); + } + + async labelValuesQuery(label: string, params?: Record) { + const url = `${LOKI_ENDPOINT}/label/${label}/values`; + const result = await this.metadataRequest(url, params); + return result.map((value: string) => ({ text: value })); + } + + interpolateQueryExpr(value: any, variable: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return lokiRegularEscape(value); + } + + if (typeof value === 'string') { + return lokiSpecialRegexEscape(value); + } + + const escapedValues = lodashMap(value, lokiSpecialRegexEscape); + return escapedValues.join('|'); + } + + modifyQuery(query: LokiQuery, action: any): LokiQuery { + let expression = query.expr ?? ''; + switch (action.type) { + case 'ADD_FILTER': { + expression = addLabelToQuery(expression, action.key, action.value, undefined, true); + break; + } + case 'ADD_FILTER_OUT': { + expression = addLabelToQuery(expression, action.key, action.value, '!=', true); + break; + } + default: + break; + } + return { ...query, expr: expression }; + } + + getHighlighterExpression(query: LokiQuery): string[] { + return getHighlighterExpressionsFromQuery(query.expr); + } + + getTime(date: string | DateTime, roundUp: boolean) { + if (typeof date === 'string') { + date = dateMath.parse(date, roundUp)!; + } + + return Math.ceil(date.valueOf() * 1e6); + } + + getLogRowContext = (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => { + const target = this.prepareLogRowContextQueryTarget( + row, + (options && options.limit) || 10, + (options && options.direction) || 'BACKWARD' + ); + + const reverse = options && options.direction === 'FORWARD'; + return this._request(RANGE_QUERY_ENDPOINT, target) + .pipe( + catchError((err: any) => { + if (err.status === 404) { + return of(err); + } + + const error: DataQueryError = { + message: 'Error during context query. Please check JS console logs.', + status: err.status, + statusText: err.statusText, + }; + throw error; + }), + switchMap((res: { data: LokiStreamResponse; status: number }) => + of({ + data: res.data ? res.data.data.result.map((stream) => lokiStreamResultToDataFrame(stream, reverse)) : [], + }) + ) + ) + .toPromise(); + }; + + prepareLogRowContextQueryTarget = (row: LogRowModel, limit: number, direction: 'BACKWARD' | 'FORWARD') => { + const query = Object.keys(row.labels) + .map((label) => `${label}="${row.labels[label].replace(/\\/g, '\\\\')}"`) // escape backslashes in label as users can't escape them by themselves + .join(','); + + const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer + const commonTargetOptions = { + limit, + query: `{${query}}`, + expr: `{${query}}`, + direction, + }; + + const fieldCache = new FieldCache(row.dataFrame); + const nsField = fieldCache.getFieldByName('tsNs')!; + const nsTimestamp = nsField.values.get(row.rowIndex); + + if (direction === 'BACKWARD') { + return { + ...commonTargetOptions, + // convert to ns, we loose some precision here but it is not that important at the far points of the context + start: row.timeEpochMs - contextTimeBuffer + '000000', + end: nsTimestamp, + direction, + }; + } else { + return { + ...commonTargetOptions, + // start param in Loki API is inclusive so we'll have to filter out the row that this request is based from + // and any other that were logged in the same ns but before the row. Right now these rows will be lost + // because the are before but came it he response that should return only rows after. + start: nsTimestamp, + // convert to ns, we loose some precision here but it is not that important at the far points of the context + end: row.timeEpochMs + contextTimeBuffer + '000000', + }; + } + }; + + testDatasource() { + // Consider only last 10 minutes otherwise request takes too long + const startMs = Date.now() - 10 * 60 * 1000; + const start = `${startMs}000000`; // API expects nanoseconds + return this._request(`${LOKI_ENDPOINT}/label`, { start }) + .pipe( + map((res) => { + const values: any[] = res?.data?.data || res?.data?.values || []; + const testResult = + values.length > 0 + ? { status: 'success', message: 'Data source connected and labels found.' } + : { + status: 'error', + message: + 'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.', + }; + return testResult; + }), + catchError((err: any) => { + let message = 'Loki: '; + if (err.statusText) { + message += err.statusText; + } else { + message += 'Cannot connect to Loki'; + } + + if (err.status) { + message += `. ${err.status}`; + } + + if (err.data && err.data.message) { + message += `. ${err.data.message}`; + } else if (err.data) { + message += `. ${err.data}`; + } + return of({ status: 'error', message: message }); + }) + ) + .toPromise(); + } + + async annotationQuery(options: AnnotationQueryRequest): Promise { + const { expr, maxLines, instant } = options.annotation; + + if (!expr) { + return []; + } + + const interpolatedExpr = this.templateSrv.replace(expr, {}, this.interpolateQueryExpr); + const query = { refId: `annotation-${options.annotation.name}`, expr: interpolatedExpr, maxLines, instant }; + const { data } = instant + ? await this.runInstantQuery(query, options as any).toPromise() + : await this.runRangeQuery(query, options as any).toPromise(); + + const annotations: AnnotationEvent[] = []; + + for (const frame of data) { + const tags: string[] = []; + for (const field of frame.fields) { + if (field.labels) { + tags.push.apply(tags, [ + ...new Set( + Object.values(field.labels) + .map((label: string) => label.trim()) + .filter((label: string) => label !== '') + ), + ]); + } + } + const view = new DataFrameView<{ ts: string; line: string }>(frame); + + view.forEach((row) => { + annotations.push({ + time: new Date(row.ts).valueOf(), + text: row.line, + tags, + }); + }); + } + + return annotations; + } + + showContextToggle(row?: LogRowModel): boolean { + return (row && row.searchWords && row.searchWords.length > 0) === true; + } + + throwUnless(err: FetchError, condition: boolean, target: LokiQuery) { + if (condition) { + return of(err); + } + + const error = this.processError(err, target); + throw error; + } + + processError(err: FetchError, target: LokiQuery) { + let error = cloneDeep(err); + if (err.data.message.includes('escape') && target.expr.includes('\\')) { + error.data.message = `Error: ${err.data.message}. Make sure that all special characters are escaped with \\. For more information on escaping of special characters visit LogQL documentation at https://grafana.com/docs/loki/latest/logql/.`; + } + return error; + } + + adjustInterval(interval: number, range: number) { + // Loki will drop queries that might return more than 11000 data points. + // Calibrate interval if it is too small. + if (interval !== 0 && range / interval > 11000) { + interval = Math.ceil(range / 11000); + } + // The min interval is set to 1ms + return Math.max(interval, 1); + } +} + +export function lokiRegularEscape(value: any) { + if (typeof value === 'string') { + return value.replace(/'/g, "\\\\'"); + } + return value; +} + +export function lokiSpecialRegexEscape(value: any) { + if (typeof value === 'string') { + return lokiRegularEscape(value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&')); + } + return value; +} + +/** + * Checks if the query expression uses function and so should return a time series instead of logs. + * Sometimes important to know that before we actually do the query. + */ +function isMetricsQuery(query: string): boolean { + const tokens = Prism.tokenize(query, syntax); + return tokens.some((t) => { + // Not sure in which cases it can be string maybe if nothing matched which means it should not be a function + return typeof t !== 'string' && t.type === 'function'; + }); +} + +export default LokiDatasource; diff --git a/public/app/plugins/datasource/loki/img/grafana_icon.svg b/public/app/plugins/datasource/loki/img/grafana_icon.svg new file mode 100644 index 0000000..7270222 --- /dev/null +++ b/public/app/plugins/datasource/loki/img/grafana_icon.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/loki/img/loki_icon.svg b/public/app/plugins/datasource/loki/img/loki_icon.svg new file mode 100644 index 0000000..de7a0d5 --- /dev/null +++ b/public/app/plugins/datasource/loki/img/loki_icon.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts new file mode 100644 index 0000000..9eb3c23 --- /dev/null +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -0,0 +1,297 @@ +import Plain from 'slate-plain-serializer'; + +import LanguageProvider, { LokiHistoryItem } from './language_provider'; +import { TypeaheadInput } from '@grafana/ui'; + +import { makeMockLokiDatasource } from './mocks'; +import LokiDatasource from './datasource'; + +jest.mock('app/store/store', () => ({ + store: { + getState: jest.fn().mockReturnValue({ + explore: { + left: { + mode: 'Logs', + }, + }, + }), + }, +})); + +describe('Language completion provider', () => { + const datasource = makeMockLokiDatasource({}); + + describe('query suggestions', () => { + it('returns no suggestions on empty context', async () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize(''); + const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + + expect(result.suggestions.length).toEqual(0); + }); + + it('returns history on empty context when history was provided', async () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize(''); + const history: LokiHistoryItem[] = [ + { + query: { refId: '1', expr: '{app="foo"}' }, + ts: 1, + }, + ]; + const result = await instance.provideCompletionItems( + { text: '', prefix: '', value, wrapperClasses: [] }, + { history } + ); + expect(result.context).toBeUndefined(); + + expect(result.suggestions).toMatchObject([ + { + label: 'History', + items: [ + { + label: '{app="foo"}', + }, + ], + }, + ]); + }); + + it('returns function and history suggestions', async () => { + const instance = new LanguageProvider(datasource); + const input = createTypeaheadInput('m', 'm', undefined, 1, [], instance); + // Historic expressions don't have to match input, filtering is done in field + const history: LokiHistoryItem[] = [ + { + query: { refId: '1', expr: '{app="foo"}' }, + ts: 1, + }, + ]; + const result = await instance.provideCompletionItems(input, { history }); + expect(result.context).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + expect(result.suggestions[0].label).toEqual('History'); + expect(result.suggestions[1].label).toEqual('Functions'); + }); + + it('returns pipe operations on pipe context', async () => { + const instance = new LanguageProvider(datasource); + const input = createTypeaheadInput('{app="test"} | ', ' ', '', 15, ['context-pipe']); + const result = await instance.provideCompletionItems(input); + expect(result.context).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + expect(result.suggestions[0].label).toEqual('Operators'); + expect(result.suggestions[1].label).toEqual('Parsers'); + }); + }); + + describe('label key suggestions', () => { + it('returns all label suggestions on empty selector', async () => { + const datasource = makeMockLokiDatasource({ label1: [], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{}', '', '', 1); + const result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([ + { + items: [ + { label: 'label1', filterText: '"label1"' }, + { label: 'label2', filterText: '"label2"' }, + ], + label: 'Labels', + }, + ]); + }); + + it('returns all label suggestions on selector when starting to type', async () => { + const datasource = makeMockLokiDatasource({ label1: [], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{l}', '', '', 2); + const result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([ + { + items: [ + { label: 'label1', filterText: '"label1"' }, + { label: 'label2', filterText: '"label2"' }, + ], + label: 'Labels', + }, + ]); + }); + }); + + describe('label suggestions facetted', () => { + it('returns facetted label suggestions based on selector', async () => { + const datasource = makeMockLokiDatasource( + { label1: [], label2: [] }, + { '{foo="bar"}': [{ label1: 'label_val1' }] } + ); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{foo="bar",}', '', '', 11); + const result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }], label: 'Labels' }]); + }); + + it('returns facetted label suggestions for multipule selectors', async () => { + const datasource = makeMockLokiDatasource( + { label1: [], label2: [] }, + { '{baz="42",foo="bar"}': [{ label2: 'label_val2' }] } + ); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{baz="42",foo="bar",}', '', '', 20); + const result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'label2' }], label: 'Labels' }]); + }); + }); + + describe('label suggestions', () => { + it('returns label values suggestions from Loki', async () => { + const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{label1=}', '=', 'label1'); + let result = await provider.provideCompletionItems(input); + + result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([ + { + items: [ + { label: 'label1_val1', filterText: '"label1_val1"' }, + { label: 'label1_val2', filterText: '"label1_val2"' }, + ], + label: 'Label values for "label1"', + }, + ]); + }); + it('returns label values suggestions from Loki when re-editing', async () => { + const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] }); + const provider = await getLanguageProvider(datasource); + const input = createTypeaheadInput('{label1="label1_v"}', 'label1_v', 'label1', 17, [ + 'attr-value', + 'context-labels', + ]); + let result = await provider.provideCompletionItems(input); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([ + { + items: [ + { label: 'label1_val1', filterText: '"label1_val1"' }, + { label: 'label1_val2', filterText: '"label1_val2"' }, + ], + label: 'Label values for "label1"', + }, + ]); + }); + }); + + describe('label values', () => { + it('should fetch label values if not cached', async () => { + const datasource = makeMockLokiDatasource({ testkey: ['label1_val1', 'label1_val2'], label2: [] }); + const provider = await getLanguageProvider(datasource); + const requestSpy = jest.spyOn(provider, 'request'); + const labelValues = await provider.fetchLabelValues('testkey'); + expect(requestSpy).toHaveBeenCalled(); + expect(labelValues).toEqual(['label1_val1', 'label1_val2']); + }); + + it('should return cached values', async () => { + const datasource = makeMockLokiDatasource({ testkey: ['label1_val1', 'label1_val2'], label2: [] }); + const provider = await getLanguageProvider(datasource); + const requestSpy = jest.spyOn(provider, 'request'); + const labelValues = await provider.fetchLabelValues('testkey'); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(labelValues).toEqual(['label1_val1', 'label1_val2']); + + const nextLabelValues = await provider.fetchLabelValues('testkey'); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(nextLabelValues).toEqual(['label1_val1', 'label1_val2']); + }); + }); +}); + +describe('Request URL', () => { + it('should contain range params', async () => { + const datasourceWithLabels = makeMockLokiDatasource({ other: [] }); + const rangeParams = datasourceWithLabels.getTimeRangeParams(); + const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest'); + + const instance = new LanguageProvider(datasourceWithLabels); + instance.fetchLogLabels(); + const expectedUrl = '/loki/api/v1/label'; + expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams); + }); +}); + +describe('Query imports', () => { + const datasource = makeMockLokiDatasource({}); + + it('returns empty queries for unknown origin datasource', async () => { + const instance = new LanguageProvider(datasource); + const result = await instance.importQueries([{ refId: 'bar', expr: 'foo' }], 'unknown'); + expect(result).toEqual([{ refId: 'bar', expr: '' }]); + }); + + describe('prometheus query imports', () => { + it('returns empty query from metric-only query', async () => { + const instance = new LanguageProvider(datasource); + const result = await instance.importPrometheusQuery('foo'); + expect(result).toEqual(''); + }); + + it('returns empty query from selector query if label is not available', async () => { + const datasourceWithLabels = makeMockLokiDatasource({ other: [] }); + const instance = new LanguageProvider(datasourceWithLabels); + const result = await instance.importPrometheusQuery('{foo="bar"}'); + expect(result).toEqual('{}'); + }); + + it('returns selector query from selector query with common labels', async () => { + const datasourceWithLabels = makeMockLokiDatasource({ foo: [] }); + const instance = new LanguageProvider(datasourceWithLabels); + const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}'); + expect(result).toEqual('{foo="bar"}'); + }); + + it('returns selector query from selector query with all labels if logging label list is empty', async () => { + const datasourceWithLabels = makeMockLokiDatasource({}); + const instance = new LanguageProvider(datasourceWithLabels); + const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}'); + expect(result).toEqual('{baz="42",foo="bar"}'); + }); + }); +}); + +async function getLanguageProvider(datasource: LokiDatasource) { + const instance = new LanguageProvider(datasource); + await instance.start(); + return instance; +} + +/** + * @param value Value of the full input + * @param text Last piece of text (not sure but in case of {label=} this would be just '=') + * @param labelKey Label by which to search for values. Cutting corners a bit here as this should be inferred from value + */ +function createTypeaheadInput( + value: string, + text: string, + labelKey?: string, + anchorOffset?: number, + wrapperClasses?: string[], + instance?: LanguageProvider +): TypeaheadInput { + const deserialized = Plain.deserialize(value); + const range = deserialized.selection.setAnchor(deserialized.selection.anchor.setOffset(anchorOffset || 1)); + const valueWithSelection = deserialized.setSelection(range); + return { + text, + prefix: instance ? instance.cleanText(text) : '', + wrapperClasses: wrapperClasses || ['context-labels'], + value: valueWithSelection, + labelKey, + }; +} diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts new file mode 100644 index 0000000..60f99df --- /dev/null +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -0,0 +1,493 @@ +// Libraries +import _ from 'lodash'; +import LRU from 'lru-cache'; + +// Services & Utils +import { + parseSelector, + labelRegexp, + selectorRegexp, + processLabels, +} from 'app/plugins/datasource/prometheus/language_utils'; +import syntax, { FUNCTIONS, PIPE_PARSERS, PIPE_OPERATORS } from './syntax'; + +// Types +import { LokiQuery } from './types'; +import { dateTime, AbsoluteTimeRange, LanguageProvider, HistoryItem } from '@grafana/data'; +import { PromQuery } from '../prometheus/types'; +import { RATE_RANGES } from '../prometheus/promql'; + +import LokiDatasource from './datasource'; +import { CompletionItem, TypeaheadInput, TypeaheadOutput, CompletionItemGroup } from '@grafana/ui'; +import { Grammar } from 'prismjs'; + +const DEFAULT_KEYS = ['job', 'namespace']; +const EMPTY_SELECTOR = '{}'; +const HISTORY_ITEM_COUNT = 10; +const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h +const NS_IN_MS = 1000000; +export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec + +const wrapLabel = (label: string) => ({ label, filterText: `\"${label}\"` }); + +export type LokiHistoryItem = HistoryItem; + +type TypeaheadContext = { + history?: LokiHistoryItem[]; + absoluteRange?: AbsoluteTimeRange; +}; + +export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryItem[]): CompletionItem { + const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; + const historyForItem = history.filter((h) => h.ts > cutoffTs && h.query.expr === item.label); + let hint = `Queried ${historyForItem.length} times in the last 24h.`; + const recent = historyForItem[0]; + + if (recent) { + const lastQueried = dateTime(recent.ts).fromNow(); + hint = `${hint} Last queried ${lastQueried}.`; + } + + return { + ...item, + documentation: hint, + }; +} + +export default class LokiLanguageProvider extends LanguageProvider { + labelKeys: string[]; + logLabelFetchTs: number; + started: boolean; + datasource: LokiDatasource; + lookupsDisabled: boolean; // Dynamically set to true for big/slow instances + + /** + * Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does + * not account for different size of a response. If that is needed a `length` function can be added in the options. + * 10 as a max size is totally arbitrary right now. + */ + private seriesCache = new LRU>(10); + private labelsCache = new LRU(10); + + constructor(datasource: LokiDatasource, initialValues?: any) { + super(); + + this.datasource = datasource; + this.labelKeys = []; + this.logLabelFetchTs = 0; + + Object.assign(this, initialValues); + } + + // Strip syntax chars + cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%\|]/g, '').trim(); + + getSyntax(): Grammar { + return syntax; + } + + request = async (url: string, params?: any): Promise => { + try { + return await this.datasource.metadataRequest(url, params); + } catch (error) { + console.error(error); + } + + return undefined; + }; + + /** + * Initialise the language provider by fetching set of labels. Without this initialisation the provider would return + * just a set of hardcoded default labels on provideCompletionItems or a recent queries from history. + */ + start = () => { + if (!this.startTask) { + this.startTask = this.fetchLogLabels().then(() => { + this.started = true; + return []; + }); + } + + return this.startTask; + }; + + getLabelKeys(): string[] { + return this.labelKeys; + } + + /** + * Return suggestions based on input that can be then plugged into a typeahead dropdown. + * Keep this DOM-free for testing + * @param input + * @param context Is optional in types but is required in case we are doing getLabelCompletionItems + * @param context.absoluteRange Required in case we are doing getLabelCompletionItems + * @param context.history Optional used only in getEmptyCompletionItems + */ + async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise { + const { wrapperClasses, value, prefix, text } = input; + const emptyResult: TypeaheadOutput = { suggestions: [] }; + + if (!value) { + return emptyResult; + } + + // Local text properties + const empty = value?.document.text.length === 0; + const selectedLines = value.document.getTextsAtRange(value.selection); + const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null; + + const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null; + + // Syntax spans have 3 classes by default. More indicate a recognized token + const tokenRecognized = wrapperClasses.length > 3; + + // Non-empty prefix, but not inside known token + const prefixUnrecognized = prefix && !tokenRecognized; + + // Prevent suggestions in `function(|suffix)` + const noSuffix = !nextCharacter || nextCharacter === ')'; + + // Prefix is safe if it does not immediately follow a complete expression and has no text after it + const safePrefix = prefix && !text.match(/^['"~=\]})\s]+$/) && noSuffix; + + // About to type next operand if preceded by binary operator + const operatorsPattern = /[+\-*/^%]/; + const isNextOperand = text.match(operatorsPattern); + + // Determine candidates by CSS context + if (wrapperClasses.includes('context-range')) { + // Suggestions for metric[|] + return this.getRangeCompletionItems(); + } else if (wrapperClasses.includes('context-labels')) { + // Suggestions for {|} and {foo=|} + return await this.getLabelCompletionItems(input); + } else if (wrapperClasses.includes('context-pipe')) { + return this.getPipeCompletionItem(); + } else if (empty) { + // Suggestions for empty query field + return this.getEmptyCompletionItems(context); + } else if (prefixUnrecognized && noSuffix && !isNextOperand) { + // Show term suggestions in a couple of scenarios + return this.getBeginningCompletionItems(context); + } else if (prefixUnrecognized && safePrefix) { + // Show term suggestions in a couple of scenarios + return this.getTermCompletionItems(); + } + + return emptyResult; + } + + getBeginningCompletionItems = (context?: TypeaheadContext): TypeaheadOutput => { + return { + suggestions: [...this.getEmptyCompletionItems(context).suggestions, ...this.getTermCompletionItems().suggestions], + }; + }; + + getEmptyCompletionItems(context?: TypeaheadContext): TypeaheadOutput { + const history = context?.history; + const suggestions = []; + + if (history?.length) { + const historyItems = _.chain(history) + .map((h) => h.query.expr) + .filter() + .uniq() + .take(HISTORY_ITEM_COUNT) + .map(wrapLabel) + .map((item) => addHistoryMetadata(item, history)) + .value(); + + suggestions.push({ + prefixMatch: true, + skipSort: true, + label: 'History', + items: historyItems, + }); + } + + return { suggestions }; + } + + getTermCompletionItems = (): TypeaheadOutput => { + const suggestions = []; + + suggestions.push({ + prefixMatch: true, + label: 'Functions', + items: FUNCTIONS.map((suggestion) => ({ ...suggestion, kind: 'function' })), + }); + + return { suggestions }; + }; + + getPipeCompletionItem = (): TypeaheadOutput => { + const suggestions = []; + + suggestions.push({ + label: 'Operators', + items: PIPE_OPERATORS.map((suggestion) => ({ ...suggestion, kind: 'operators' })), + }); + + suggestions.push({ + label: 'Parsers', + items: PIPE_PARSERS.map((suggestion) => ({ ...suggestion, kind: 'parsers' })), + }); + + return { suggestions }; + }; + + getRangeCompletionItems(): TypeaheadOutput { + return { + context: 'context-range', + suggestions: [ + { + label: 'Range vector', + items: [...RATE_RANGES], + }, + ], + }; + } + + async getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): Promise { + let context = 'context-labels'; + const suggestions: CompletionItemGroup[] = []; + if (!value) { + return { context, suggestions: [] }; + } + const line = value.anchorBlock.getText(); + const cursorOffset = value.selection.anchor.offset; + const isValueStart = text.match(/^(=|=~|!=|!~)/); + + // Get normalized selector + let selector; + let parsedSelector; + try { + parsedSelector = parseSelector(line, cursorOffset); + selector = parsedSelector.selector; + } catch { + selector = EMPTY_SELECTOR; + } + + if (!labelKey && selector === EMPTY_SELECTOR) { + // start task gets all labels + await this.start(); + const allLabels = this.getLabelKeys(); + return { context, suggestions: [{ label: `Labels`, items: allLabels.map(wrapLabel) }] }; + } + + const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; + + let labelValues; + // Query labels for selector + if (selector) { + if (selector === EMPTY_SELECTOR && labelKey) { + const labelValuesForKey = await this.getLabelValues(labelKey); + labelValues = { [labelKey]: labelValuesForKey }; + } else { + labelValues = await this.getSeriesLabels(selector); + } + } + + if (!labelValues) { + console.warn(`Server did not return any values for selector = ${selector}`); + return { context, suggestions }; + } + + if ((text && isValueStart) || wrapperClasses.includes('attr-value')) { + // Label values + if (labelKey && labelValues[labelKey]) { + context = 'context-label-values'; + suggestions.push({ + label: `Label values for "${labelKey}"`, + // Filter to prevent previously selected values from being repeatedly suggested + items: labelValues[labelKey].map(wrapLabel).filter(({ filterText }) => filterText !== text), + }); + } + } else { + // Label keys + const labelKeys = labelValues ? Object.keys(labelValues) : DEFAULT_KEYS; + if (labelKeys) { + const possibleKeys = _.difference(labelKeys, existingKeys); + if (possibleKeys.length) { + const newItems = possibleKeys.map((key) => ({ label: key })); + const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems }; + suggestions.push(newSuggestion); + } + } + } + + return { context, suggestions }; + } + + async importQueries(queries: LokiQuery[], datasourceType: string): Promise { + if (datasourceType === 'prometheus') { + return Promise.all( + queries.map(async (query) => { + const expr = await this.importPrometheusQuery(query.expr); + const { ...rest } = query as PromQuery; + return { + ...rest, + expr, + }; + }) + ); + } + // Return a cleaned LokiQuery + return queries.map((query) => ({ + refId: query.refId, + expr: '', + })); + } + + async importPrometheusQuery(query: string): Promise { + if (!query) { + return ''; + } + + // Consider only first selector in query + const selectorMatch = query.match(selectorRegexp); + if (!selectorMatch) { + return ''; + } + + const selector = selectorMatch[0]; + const labels: { [key: string]: { value: any; operator: any } } = {}; + selector.replace(labelRegexp, (_, key, operator, value) => { + labels[key] = { value, operator }; + return ''; + }); + + // Keep only labels that exist on origin and target datasource + await this.start(); // fetches all existing label keys + const existingKeys = this.labelKeys; + let labelsToKeep: { [key: string]: { value: any; operator: any } } = {}; + if (existingKeys && existingKeys.length) { + // Check for common labels + for (const key in labels) { + if (existingKeys && existingKeys.includes(key)) { + // Should we check for label value equality here? + labelsToKeep[key] = labels[key]; + } + } + } else { + // Keep all labels by default + labelsToKeep = labels; + } + + const labelKeys = Object.keys(labelsToKeep).sort(); + const cleanSelector = labelKeys + .map((key) => `${key}${labelsToKeep[key].operator}${labelsToKeep[key].value}`) + .join(','); + + return ['{', cleanSelector, '}'].join(''); + } + + async getSeriesLabels(selector: string) { + if (this.lookupsDisabled) { + return undefined; + } + try { + return await this.fetchSeriesLabels(selector); + } catch (error) { + // TODO: better error handling + console.error(error); + return undefined; + } + } + + /** + * Fetches all label keys + * @param absoluteRange Fetches + */ + async fetchLogLabels(): Promise { + const url = '/loki/api/v1/label'; + const timeRange = this.datasource.getTimeRangeParams(); + this.logLabelFetchTs = Date.now().valueOf(); + + const res = await this.request(url, timeRange); + if (Array.isArray(res)) { + this.labelKeys = res.slice().sort(); + } + + return []; + } + + async refreshLogLabels(forceRefresh?: boolean) { + if ((this.labelKeys && Date.now().valueOf() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) { + await this.fetchLogLabels(); + } + } + + /** + * Fetch labels for a selector. This is cached by it's args but also by the global timeRange currently selected as + * they can change over requested time. + * @param name + */ + fetchSeriesLabels = async (match: string): Promise> => { + const url = '/loki/api/v1/series'; + const { from: start, to: end } = this.datasource.getTimeRangeParams(); + + const cacheKey = this.generateCacheKey(url, start, end, match); + let value = this.seriesCache.get(cacheKey); + if (!value) { + // Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice. + this.seriesCache.set(cacheKey, {}); + const params = { match, start, end }; + const data = await this.request(url, params); + const { values } = processLabels(data); + value = values; + this.seriesCache.set(cacheKey, value); + } + return value; + }; + + /** + * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels. + * @param match + */ + fetchSeries = async (match: string): Promise>> => { + const url = '/loki/api/v1/series'; + const { from: start, to: end } = this.datasource.getTimeRangeParams(); + const params = { match, start, end }; + return await this.request(url, params); + }; + + // Cache key is a bit different here. We round up to a minute the intervals. + // The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every + // millisecond while still actually getting all the keys for the correct interval. This still can create problems + // when user does not the newest values for a minute if already cached. + generateCacheKey(url: string, start: number, end: number, param: string): string { + return [url, this.roundTime(start), this.roundTime(end), param].join(); + } + + // Round nanos epoch to nearest 5 minute interval + roundTime(nanos: number): number { + return nanos ? Math.floor(nanos / NS_IN_MS / 1000 / 60 / 5) : 0; + } + + async getLabelValues(key: string): Promise { + return await this.fetchLabelValues(key); + } + + async fetchLabelValues(key: string): Promise { + const url = `/loki/api/v1/label/${key}/values`; + const rangeParams = this.datasource.getTimeRangeParams(); + const { from: start, to: end } = rangeParams; + + const cacheKey = this.generateCacheKey(url, start, end, key); + const params = { start, end }; + + let labelValue = this.labelsCache.get(cacheKey); + if (!labelValue) { + // Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice. + this.labelsCache.set(cacheKey, []); + const res = await this.request(url, params); + if (Array.isArray(res)) { + labelValue = res.slice().sort(); + this.labelsCache.set(cacheKey, labelValue); + } + } + + return labelValue ?? []; + } +} diff --git a/public/app/plugins/datasource/loki/live_streams.test.ts b/public/app/plugins/datasource/loki/live_streams.test.ts new file mode 100644 index 0000000..249a375 --- /dev/null +++ b/public/app/plugins/datasource/loki/live_streams.test.ts @@ -0,0 +1,211 @@ +import { Observable, Subject, of, throwError, concat } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import * as rxJsWebSocket from 'rxjs/webSocket'; +import { LiveStreams } from './live_streams'; +import { DataFrame, DataFrameView, formatLabels, Labels } from '@grafana/data'; +import { noop } from 'lodash'; +import { LokiTailResponse } from './types'; + +let fakeSocket: Subject; +jest.mock('rxjs/webSocket', () => { + return { + __esModule: true, + webSocket: () => fakeSocket, + }; +}); + +describe('Live Stream Tests', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + const msg0: LokiTailResponse = { + streams: [ + { + stream: { filename: '/var/log/sntpc.log', job: 'varlogs' }, + values: [['1567025440118944705', 'Kittens']], + }, + ], + dropped_entries: null, + }; + + it('reads the values into the buffer', (done) => { + fakeSocket = new Subject(); + const labels: Labels = { job: 'varlogs' }; + const target = makeTarget('fake', labels); + const stream = new LiveStreams().getStream(target); + expect.assertions(4); + + const tests = [ + (val: DataFrame[]) => { + expect(val[0].length).toEqual(7); + expect(val[0].fields[2].labels).toEqual(labels); + }, + (val: DataFrame[]) => { + expect(val[0].length).toEqual(8); + const view = new DataFrameView(val[0]); + const last = { ...view.get(view.length - 1) }; + expect(last).toEqual({ + ts: '2019-08-28T20:50:40.118Z', + tsNs: '1567025440118944705', + id: '8c50d09800ce8dda69a2ff25405c9f65_A', + line: 'Kittens', + labels: { filename: '/var/log/sntpc.log' }, + }); + }, + ]; + stream.subscribe({ + next: (val) => { + const test = tests.shift(); + test!(val); + }, + complete: () => done(), + }); + + // Send it the initial list of things + fakeSocket.next(initialRawResponse); + // Send it a single update + fakeSocket.next(msg0); + fakeSocket.complete(); + }); + + it('returns the same subscription if the url matches existing one', () => { + fakeSocket = new Subject(); + const liveStreams = new LiveStreams(); + const stream1 = liveStreams.getStream(makeTarget('url_to_match')); + const stream2 = liveStreams.getStream(makeTarget('url_to_match')); + expect(stream1).toBe(stream2); + }); + + it('returns new subscription when the previous unsubscribed', () => { + fakeSocket = new Subject(); + const liveStreams = new LiveStreams(); + const stream1 = liveStreams.getStream(makeTarget('url_to_match')); + const subscription = stream1.subscribe({ + next: noop, + }); + subscription.unsubscribe(); + + const stream2 = liveStreams.getStream(makeTarget('url_to_match')); + expect(stream1).not.toBe(stream2); + }); + + it('returns new subscription when the previous is unsubscribed and correctly unsubscribes from source', () => { + let unsubscribed = false; + fakeSocket = new Observable(() => { + return () => (unsubscribed = true); + }) as any; + const spy = spyOn(rxJsWebSocket, 'webSocket'); + spy.and.returnValue(fakeSocket); + + const liveStreams = new LiveStreams(); + const stream1 = liveStreams.getStream(makeTarget('url_to_match')); + const subscription = stream1.subscribe({ + next: noop, + }); + subscription.unsubscribe(); + expect(unsubscribed).toBe(true); + }); + it('should reconnect when abnormal error', async () => { + const abnormalError = new Error('weird error') as any; + abnormalError.code = 1006; + const logStreamBeforeError = of({ + streams: [ + { + stream: { filename: '/var/log/sntpc.log', job: 'varlogs' }, + values: [['1567025440118944705', 'Kittens']], + }, + ], + dropped_entries: null, + }); + const logStreamAfterError = of({ + streams: [ + { + stream: { filename: '/var/log/sntpc.log', job: 'varlogs' }, + values: [['1567025440118944705', 'Doggos']], + }, + ], + dropped_entries: null, + }); + const errorStream = throwError(abnormalError); + let retries = 0; + fakeSocket = of({}).pipe( + mergeMap(() => { + // When subscribed first time, return logStream and errorStream + if (retries++ === 0) { + return concat(logStreamBeforeError, errorStream); + } + // When re-subsribed after abnormal error, return just logStream + return logStreamAfterError; + }) + ) as any; + const liveStreams = new LiveStreams(); + await expect(liveStreams.getStream(makeTarget('url_to_match'), 100)).toEmitValuesWith((received) => { + const data = received[0]; + const view = new DataFrameView(data[0]); + const firstLog = { ...view.get(0) }; + const secondLog = { ...view.get(1) }; + + expect(firstLog.line).toBe('Kittens'); + expect(secondLog.line).toBe('Doggos'); + expect(retries).toBe(2); + }); + }); +}); + +/** + * Create target (query to run). Url is what is used as cache key. + */ +function makeTarget(url: string, labels?: Labels) { + labels = labels || { job: 'varlogs' }; + return { + url, + size: 10, + query: formatLabels(labels), + refId: 'A', + regexp: '', + }; +} + +//---------------------------------------------------------------- +// Added this at the end so the top is more readable +//---------------------------------------------------------------- + +const initialRawResponse: LokiTailResponse = { + streams: [ + { + stream: { + filename: '/var/log/docker.log', + job: 'varlogs', + }, + values: [ + [ + '1567025018215000000', + 'level=debug msg="[resolver] received AAAA record \\"::1\\" for \\"localhost.\\" from udp:192.168.65.1"', + ], + [ + '1567025018215000000', + '2019-08-28T20:43:38Z docker time="2019-08-28T20:43:38.147224630Z" ' + + 'level=debug msg="[resolver] received AAAA record \\"fe80::1\\" for \\"localhost.\\" from udp:192.168.65.1"', + ], + ['1567025020452000000', '2019-08-28T20:43:40Z sntpc sntpc[1]: offset=-0.022171, delay=0.000463'], + ['1567025050297000000', '2019-08-28T20:44:10Z sntpc sntpc[1]: offset=-0.022327, delay=0.000527'], + [ + '1567025078152000000', + '2019-08-28T20:44:38Z lifecycle-server time="2019-08-28T20:44:38.095444834Z" ' + + 'level=debug msg="Name To resolve: localhost."', + ], + [ + '1567025078152000000', + '2019-08-28T20:44:38Z lifecycle-server time="2019-08-28T20:44:38.095896074Z" ' + + 'level=debug msg="[resolver] query localhost. (A) from 172.22.0.4:53748, forwarding to udp:192.168.65.1"', + ], + [ + '1567025078152000000', + '2019-08-28T20:44:38Z docker time="2019-08-28T20:44:38.095444834Z" level=debug msg="Name To resolve: localhost."', + ], + ], + }, + ], + dropped_entries: null, +}; diff --git a/public/app/plugins/datasource/loki/live_streams.ts b/public/app/plugins/datasource/loki/live_streams.ts new file mode 100644 index 0000000..fd96048 --- /dev/null +++ b/public/app/plugins/datasource/loki/live_streams.ts @@ -0,0 +1,75 @@ +import { DataFrame, FieldType, parseLabels, KeyValue, CircularDataFrame } from '@grafana/data'; +import { Observable, throwError, timer } from 'rxjs'; +import { webSocket } from 'rxjs/webSocket'; +import { LokiTailResponse } from './types'; +import { finalize, map, retryWhen, mergeMap } from 'rxjs/operators'; +import { appendResponseToBufferedData } from './result_transformer'; + +/** + * Maps directly to a query in the UI (refId is key) + */ +export interface LokiLiveTarget { + query: string; + url: string; + refId: string; + size: number; +} + +/** + * Cache of websocket streams that can be returned as observable. In case there already is a stream for particular + * target it is returned and on subscription returns the latest dataFrame. + */ +export class LiveStreams { + private streams: KeyValue> = {}; + + getStream(target: LokiLiveTarget, retryInterval = 5000): Observable { + let stream = this.streams[target.url]; + + if (stream) { + return stream; + } + + const data = new CircularDataFrame({ capacity: target.size }); + data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } }); + data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } }); + data.addField({ name: 'line', type: FieldType.string }).labels = parseLabels(target.query); + data.addField({ name: 'labels', type: FieldType.other }); // The labels for each line + data.addField({ name: 'id', type: FieldType.string }); + data.meta = { ...data.meta, preferredVisualisationType: 'logs' }; + data.refId = target.refId; + + stream = webSocket(target.url).pipe( + map((response: LokiTailResponse) => { + appendResponseToBufferedData(response, data); + return [data]; + }), + retryWhen((attempts: Observable) => + attempts.pipe( + mergeMap((error, i) => { + const retryAttempt = i + 1; + // Code 1006 is used to indicate that a connection was closed abnormally. + // Added hard limit of 30 on number of retries. + // If connection was closed abnormally, and we wish to retry, otherwise throw error. + if (error.code === 1006 && retryAttempt < 30) { + if (retryAttempt > 10) { + // If more than 10 times retried, consol.warn, but keep reconnecting + console.warn( + `Websocket connection is being disrupted. We keep reconnecting but consider starting new live tailing again. Error: ${error.reason}` + ); + } + // Retry every 5s + return timer(retryInterval); + } + return throwError(error); + }) + ) + ), + finalize(() => { + delete this.streams[target.url]; + }) + ); + this.streams[target.url] = stream; + + return stream; + } +} diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts new file mode 100644 index 0000000..6b565f6 --- /dev/null +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -0,0 +1,53 @@ +import { LokiDatasource, LOKI_ENDPOINT } from './datasource'; +import { AbsoluteTimeRange, DataSourceSettings } from '@grafana/data'; +import { LokiOptions } from './types'; +import { createDatasourceSettings } from '../../../features/datasources/mocks'; + +interface Labels { + [label: string]: string[]; +} + +interface Series { + [label: string]: string; +} + +interface SeriesForSelector { + [selector: string]: Series[]; +} + +export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesForSelector): LokiDatasource { + const lokiLabelsAndValuesEndpointRegex = /^\/loki\/api\/v1\/label\/(\w*)\/values/; + const lokiSeriesEndpointRegex = /^\/loki\/api\/v1\/series/; + + const lokiLabelsEndpoint = `${LOKI_ENDPOINT}/label`; + const rangeMock: AbsoluteTimeRange = { + from: 1560153109000, + to: 1560163909000, + }; + + const labels = Object.keys(labelsAndValues); + return { + getTimeRangeParams: () => rangeMock, + metadataRequest: (url: string, params?: { [key: string]: string }) => { + if (url === lokiLabelsEndpoint) { + return labels; + } else { + const labelsMatch = url.match(lokiLabelsAndValuesEndpointRegex); + const seriesMatch = url.match(lokiSeriesEndpointRegex); + if (labelsMatch) { + return labelsAndValues[labelsMatch[1]] || []; + } else if (seriesMatch && series && params) { + return series[params.match] || []; + } else { + throw new Error(`Unexpected url error, ${url}`); + } + } + }, + } as any; +} + +export function createDefaultConfigOptions(): DataSourceSettings { + return createDatasourceSettings({ + maxLines: '531', + }); +} diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts new file mode 100644 index 0000000..a327604 --- /dev/null +++ b/public/app/plugins/datasource/loki/module.ts @@ -0,0 +1,15 @@ +import { DataSourcePlugin } from '@grafana/data'; +import Datasource from './datasource'; + +import LokiCheatSheet from './components/LokiCheatSheet'; +import LokiExploreQueryEditor from './components/LokiExploreQueryEditor'; +import LokiQueryEditor from './components/LokiQueryEditor'; +import { LokiAnnotationsQueryCtrl } from './LokiAnnotationsQueryCtrl'; +import { ConfigEditor } from './configuration/ConfigEditor'; + +export const plugin = new DataSourcePlugin(Datasource) + .setQueryEditor(LokiQueryEditor) + .setConfigEditor(ConfigEditor) + .setExploreQueryField(LokiExploreQueryEditor) + .setQueryEditorHelp(LokiCheatSheet) + .setAnnotationQueryCtrl(LokiAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/loki/partials/annotations.editor.html b/public/app/plugins/datasource/loki/partials/annotations.editor.html new file mode 100644 index 0000000..f482d74 --- /dev/null +++ b/public/app/plugins/datasource/loki/partials/annotations.editor.html @@ -0,0 +1,7 @@ + diff --git a/public/app/plugins/datasource/loki/plugin.json b/public/app/plugins/datasource/loki/plugin.json new file mode 100644 index 0000000..b91a3b8 --- /dev/null +++ b/public/app/plugins/datasource/loki/plugin.json @@ -0,0 +1,38 @@ +{ + "type": "datasource", + "name": "Loki", + "id": "loki", + "category": "logging", + + "logs": true, + "metrics": true, + "alerting": true, + "annotations": true, + "streaming": true, + + "queryOptions": { + "maxDataPoints": true + }, + + "info": { + "description": "Like Prometheus but for logs. OSS logging solution from Grafana Labs", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/loki_icon.svg", + "large": "img/loki_icon.svg" + }, + "links": [ + { + "name": "Learn more", + "url": "https://grafana.com/loki" + }, + { + "name": "GitHub Project", + "url": "https://github.com/grafana/loki" + } + ] + } +} diff --git a/public/app/plugins/datasource/loki/query_utils.test.ts b/public/app/plugins/datasource/loki/query_utils.test.ts new file mode 100644 index 0000000..396bf46 --- /dev/null +++ b/public/app/plugins/datasource/loki/query_utils.test.ts @@ -0,0 +1,31 @@ +import { getHighlighterExpressionsFromQuery } from './query_utils'; + +describe('getHighlighterExpressionsFromQuery', () => { + it('returns no expressions for empty query', () => { + expect(getHighlighterExpressionsFromQuery('')).toEqual([]); + }); + + it('returns an expression for query with filter', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x"')).toEqual(['x']); + }); + + it('returns expressions for query with filter chain', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" |~ "y"')).toEqual(['x', 'y']); + }); + + it('returns drops expressions for query with negative filter chain', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x" != "y"')).toEqual(['x']); + }); + + it('returns null if filter term is not wrapped in double quotes', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= x')).toEqual([]); + }); + + it('escapes filter term if regex filter operator is not used', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |= "x[yz].w"')).toEqual(['x\\[yz\\]\\.w']); + }); + + it('does not escape filter term if regex filter operator is used', () => { + expect(getHighlighterExpressionsFromQuery('{foo="bar"} |~ "x[yz].w" |~ "z.+"')).toEqual(['x[yz].w', 'z.+']); + }); +}); diff --git a/public/app/plugins/datasource/loki/query_utils.ts b/public/app/plugins/datasource/loki/query_utils.ts new file mode 100644 index 0000000..1077f9d --- /dev/null +++ b/public/app/plugins/datasource/loki/query_utils.ts @@ -0,0 +1,52 @@ +import escapeRegExp from 'lodash/escapeRegExp'; + +export function formatQuery(selector: string | undefined): string { + return `${selector || ''}`.trim(); +} + +/** + * Returns search terms from a LogQL query. + * E.g., `{} |= foo |=bar != baz` returns `['foo', 'bar']`. + */ +export function getHighlighterExpressionsFromQuery(input: string): string[] { + let expression = input; + const results = []; + + // Consume filter expression from left to right + while (expression) { + const filterStart = expression.search(/\|=|\|~|!=|!~/); + // Nothing more to search + if (filterStart === -1) { + break; + } + // Drop terms for negative filters + const filterOperator = expression.substr(filterStart, 2); + const skip = expression.substr(filterStart).search(/!=|!~/) === 0; + expression = expression.substr(filterStart + 2); + if (skip) { + continue; + } + // Check if there is more chained + const filterEnd = expression.search(/\|=|\|~|!=|!~/); + let filterTerm; + if (filterEnd === -1) { + filterTerm = expression.trim(); + } else { + filterTerm = expression.substr(0, filterEnd).trim(); + expression = expression.substr(filterEnd); + } + + // Unwrap the filter term by removing quotes + const quotedTerm = filterTerm.match(/^"((?:[^\\"]|\\")*)"$/); + + if (quotedTerm) { + const unwrappedFilterTerm = quotedTerm[1]; + const regexOperator = filterOperator === '|~'; + results.push(regexOperator ? unwrappedFilterTerm : escapeRegExp(unwrappedFilterTerm)); + } else { + return []; + } + } + + return results; +} diff --git a/public/app/plugins/datasource/loki/result_transformer.test.ts b/public/app/plugins/datasource/loki/result_transformer.test.ts new file mode 100644 index 0000000..fc9095c --- /dev/null +++ b/public/app/plugins/datasource/loki/result_transformer.test.ts @@ -0,0 +1,313 @@ +import { CircularDataFrame, FieldCache, FieldType, MutableDataFrame } from '@grafana/data'; +import { + LokiStreamResult, + LokiTailResponse, + LokiStreamResponse, + LokiResultType, + TransformerOptions, + LokiMatrixResult, +} from './types'; +import * as ResultTransformer from './result_transformer'; +import { setTemplateSrv } from '@grafana/runtime'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + +const streamResult: LokiStreamResult[] = [ + { + stream: { + foo: 'bar', + }, + values: [['1579857562021616000', "foo: 'bar'"]], + }, + { + stream: { + bar: 'foo', + }, + values: [['1579857562031616000', "bar: 'foo'"]], + }, +]; + +const lokiResponse: LokiStreamResponse = { + status: 'success', + data: { + result: streamResult, + resultType: LokiResultType.Stream, + stats: { + summary: { + bytesTotal: 900, + }, + }, + }, +}; + +jest.mock('@grafana/runtime', () => ({ + // @ts-ignore + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => { + return { + getInstanceSettings: () => { + return { name: 'Loki1' }; + }, + }; + }, +})); + +describe('loki result transformer', () => { + beforeAll(() => { + setTemplateSrv(new TemplateSrv()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('lokiStreamResultToDataFrame', () => { + it('converts streams to series', () => { + const data = streamResult.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream)); + + expect(data.length).toBe(2); + expect(data[0].fields[1].labels!['foo']).toEqual('bar'); + expect(data[0].fields[0].values.get(0)).toEqual('2020-01-24T09:19:22.021Z'); + expect(data[0].fields[1].values.get(0)).toEqual(streamResult[0].values[0][1]); + expect(data[0].fields[2].values.get(0)).toEqual('2b431b8a98b80b3b2c2f4cd2444ae6cb'); + expect(data[1].fields[0].values.get(0)).toEqual('2020-01-24T09:19:22.031Z'); + expect(data[1].fields[1].values.get(0)).toEqual(streamResult[1].values[0][1]); + expect(data[1].fields[2].values.get(0)).toEqual('75d73d66cff40f9d1a1f2d5a0bf295d0'); + }); + + it('should always generate unique ids for logs', () => { + const streamResultWithDuplicateLogs: LokiStreamResult[] = [ + { + stream: { + foo: 'bar', + }, + + values: [ + ['1579857562021616000', 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"'], + ['1579857562021616000', 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"'], + ['1579857562021616000', 't=2020-02-12T15:04:51+0000 lvl=info msg="Non-duplicated"'], + ['1579857562021616000', 't=2020-02-12T15:04:51+0000 lvl=info msg="Duplicated"'], + ], + }, + { + stream: { + bar: 'foo', + }, + values: [['1579857562021617000', 't=2020-02-12T15:04:51+0000 lvl=info msg="Non-dupliicated"']], + }, + ]; + + const data = streamResultWithDuplicateLogs.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream)); + + expect(data[0].fields[2].values.get(0)).toEqual('65cee200875f58ee1430d8bd2e8b74e7'); + expect(data[0].fields[2].values.get(1)).toEqual('65cee200875f58ee1430d8bd2e8b74e7_1'); + expect(data[0].fields[2].values.get(2)).not.toEqual('65cee200875f58ee1430d8bd2e8b74e7_2'); + expect(data[0].fields[2].values.get(3)).toEqual('65cee200875f58ee1430d8bd2e8b74e7_2'); + expect(data[1].fields[2].values.get(0)).not.toEqual('65cee200875f58ee1430d8bd2e8b74e7_3'); + }); + + it('should append refId to the unique ids if refId is provided', () => { + const data = streamResult.map((stream) => ResultTransformer.lokiStreamResultToDataFrame(stream, false, 'B')); + expect(data.length).toBe(2); + expect(data[0].fields[2].values.get(0)).toEqual('2b431b8a98b80b3b2c2f4cd2444ae6cb_B'); + expect(data[1].fields[2].values.get(0)).toEqual('75d73d66cff40f9d1a1f2d5a0bf295d0_B'); + }); + }); + + describe('lokiStreamsToDataFrames', () => { + it('should enhance data frames', () => { + jest.spyOn(ResultTransformer, 'enhanceDataFrame'); + const dataFrames = ResultTransformer.lokiStreamsToDataFrames(lokiResponse, { refId: 'B' }, 500, { + derivedFields: [ + { + matcherRegex: 'trace=(w+)', + name: 'test', + url: 'example.com', + }, + ], + }); + + expect(ResultTransformer.enhanceDataFrame).toBeCalled(); + dataFrames.forEach((frame) => { + expect( + frame.fields.filter((field) => field.name === 'test' && field.type === 'string').length + ).toBeGreaterThanOrEqual(1); + }); + }); + }); + + describe('appendResponseToBufferedData', () => { + it('should return a dataframe with ts in iso format', () => { + const tailResponse: LokiTailResponse = { + streams: [ + { + stream: { + filename: '/var/log/grafana/grafana.log', + job: 'grafana', + }, + values: [ + [ + '1581519914265798400', + 't=2020-02-12T15:04:51+0000 lvl=info msg="Starting Grafana" logger=server version=6.7.0-pre commit=6f09bc9fb4 branch=issue-21929 compiled=2020-02-11T20:43:28+0000', + ], + ], + }, + ], + }; + + const data = new CircularDataFrame({ capacity: 1 }); + data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } }); + data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } }); + data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' }; + data.addField({ name: 'labels', type: FieldType.other }); + data.addField({ name: 'id', type: FieldType.string }); + + ResultTransformer.appendResponseToBufferedData(tailResponse, data); + expect(data.get(0)).toEqual({ + ts: '2020-02-12T15:05:14.265Z', + tsNs: '1581519914265798400', + line: + 't=2020-02-12T15:04:51+0000 lvl=info msg="Starting Grafana" logger=server version=6.7.0-pre commit=6f09bc9fb4 branch=issue-21929 compiled=2020-02-11T20:43:28+0000', + labels: { filename: '/var/log/grafana/grafana.log' }, + id: '19e8e093d70122b3b53cb6e24efd6e2d', + }); + }); + + it('should always generate unique ids for logs', () => { + const tailResponse: LokiTailResponse = { + streams: [ + { + stream: { + filename: '/var/log/grafana/grafana.log', + job: 'grafana', + }, + values: [ + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Dupplicated 1"'], + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Dupplicated 1"'], + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Dupplicated 2"'], + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Not dupplicated"'], + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Dupplicated 1"'], + ['1581519914265798400', 't=2020-02-12T15:04:51+0000 lvl=info msg="Dupplicated 2"'], + ], + }, + ], + }; + + const data = new CircularDataFrame({ capacity: 6 }); + data.addField({ name: 'ts', type: FieldType.time, config: { displayName: 'Time' } }); + data.addField({ name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' } }); + data.addField({ name: 'line', type: FieldType.string }).labels = { job: 'grafana' }; + data.addField({ name: 'labels', type: FieldType.other }); + data.addField({ name: 'id', type: FieldType.string }); + data.refId = 'C'; + + ResultTransformer.appendResponseToBufferedData(tailResponse, data); + expect(data.get(0).id).toEqual('870e4d105741bdfc2c67904ee480d4f3_C'); + expect(data.get(1).id).toEqual('870e4d105741bdfc2c67904ee480d4f3_1_C'); + expect(data.get(2).id).toEqual('707e4ec2b842f389dbb993438505856d_C'); + expect(data.get(3).id).toEqual('78f044015a58fad3e257a855b167d85e_C'); + expect(data.get(4).id).toEqual('870e4d105741bdfc2c67904ee480d4f3_2_C'); + expect(data.get(5).id).toEqual('707e4ec2b842f389dbb993438505856d_1_C'); + }); + }); + + describe('createMetricLabel', () => { + it('should create correct label based on passed variables', () => { + const label = ResultTransformer.createMetricLabel({}, ({ + scopedVars: { testLabel: { selected: true, text: 'label1', value: 'label1' } }, + legendFormat: '{{$testLabel}}', + } as unknown) as TransformerOptions); + expect(label).toBe('label1'); + }); + }); + + describe('lokiResultsToTableModel', () => { + it('should correctly set the type of the label column to be a string', () => { + const lokiResultWithIntLabel = ([ + { metric: { test: 1 }, value: [1610367143, 10] }, + { metric: { test: 2 }, value: [1610367144, 20] }, + ] as unknown) as LokiMatrixResult[]; + + const table = ResultTransformer.lokiResultsToTableModel(lokiResultWithIntLabel, 1, 'A', {}); + expect(table.columns[0].type).toBe('time'); + expect(table.columns[1].type).toBe('string'); + expect(table.columns[2].type).toBe('number'); + }); + }); +}); + +describe('enhanceDataFrame', () => { + it('adds links to fields', () => { + const df = new MutableDataFrame({ fields: [{ name: 'line', values: ['nothing', 'trace1=1234', 'trace2=foo'] }] }); + ResultTransformer.enhanceDataFrame(df, { + derivedFields: [ + { + matcherRegex: 'trace1=(\\w+)', + name: 'trace1', + url: 'http://localhost/${__value.raw}', + }, + { + matcherRegex: 'trace2=(\\w+)', + name: 'trace2', + url: 'test', + datasourceUid: 'uid', + }, + { + matcherRegex: 'trace2=(\\w+)', + name: 'trace2', + url: 'test', + datasourceUid: 'uid2', + }, + ], + }); + expect(df.fields.length).toBe(3); + const fc = new FieldCache(df); + expect(fc.getFieldByName('trace1')!.values.toArray()).toEqual([null, '1234', null]); + expect(fc.getFieldByName('trace1')!.config.links![0]).toEqual({ + url: 'http://localhost/${__value.raw}', + title: '', + }); + + expect(fc.getFieldByName('trace2')!.values.toArray()).toEqual([null, null, 'foo']); + expect(fc.getFieldByName('trace2')!.config.links!.length).toBe(2); + expect(fc.getFieldByName('trace2')!.config.links![0]).toEqual({ + title: '', + internal: { datasourceName: 'Loki1', datasourceUid: 'uid', query: { query: 'test' } }, + url: '', + }); + expect(fc.getFieldByName('trace2')!.config.links![1]).toEqual({ + title: '', + internal: { datasourceName: 'Loki1', datasourceUid: 'uid2', query: { query: 'test' } }, + url: '', + }); + }); + + describe('lokiPointsToTimeseriesPoints()', () => { + /** + * NOTE on time parameters: + * - Input time series data has timestamps in sec (like Prometheus) + * - Output time series has timestamps in ms (as expected for the chart lib) + * - Start/end parameters are in ns (as expected for Loki) + * - Step is in sec (like in Prometheus) + */ + const data: Array<[number, string]> = [ + [1, '1'], + [2, '0'], + [4, '1'], + ]; + + it('returns data as is if step, start, and end align', () => { + const options: Partial = { start: 1 * 1e9, end: 4 * 1e9, step: 1 }; + const result = ResultTransformer.lokiPointsToTimeseriesPoints(data, options as TransformerOptions); + expect(result).toEqual([ + [1, 1000], + [0, 2000], + [null, 3000], + [1, 4000], + ]); + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/result_transformer.ts b/public/app/plugins/datasource/loki/result_transformer.ts new file mode 100644 index 0000000..04d5e90 --- /dev/null +++ b/public/app/plugins/datasource/loki/result_transformer.ts @@ -0,0 +1,531 @@ +import _ from 'lodash'; +import md5 from 'md5'; +import { of } from 'rxjs'; + +import { + FieldType, + TimeSeries, + Labels, + DataFrame, + ArrayVector, + MutableDataFrame, + findUniqueLabels, + DataFrameView, + DataLink, + Field, + QueryResultMetaStat, + QueryResultMeta, + TimeSeriesValue, + ScopedVars, +} from '@grafana/data'; + +import { getTemplateSrv, getDataSourceSrv } from '@grafana/runtime'; +import TableModel from 'app/core/table_model'; +import { formatQuery, getHighlighterExpressionsFromQuery } from './query_utils'; +import { + LokiRangeQueryRequest, + LokiResponse, + LokiMatrixResult, + LokiVectorResult, + TransformerOptions, + LokiResultType, + LokiStreamResult, + LokiTailResponse, + LokiQuery, + LokiOptions, + DerivedFieldConfig, + LokiStreamResponse, + LokiStats, +} from './types'; + +/** + * Transforms LokiStreamResult structure into a dataFrame. Used when doing standard queries and newer version of Loki. + */ +export function lokiStreamResultToDataFrame(stream: LokiStreamResult, reverse?: boolean, refId?: string): DataFrame { + const labels: Labels = stream.stream; + const labelsString = Object.entries(labels) + .map(([key, val]) => `${key}="${val}"`) + .sort() + .join(''); + + const times = new ArrayVector([]); + const timesNs = new ArrayVector([]); + const lines = new ArrayVector([]); + const uids = new ArrayVector([]); + + // We need to store and track all used uids to ensure that uids are unique + const usedUids: { string?: number } = {}; + + for (const [ts, line] of stream.values) { + // num ns epoch in string, we convert it to iso string here so it matches old format + times.add(new Date(parseInt(ts.substr(0, ts.length - 6), 10)).toISOString()); + timesNs.add(ts); + lines.add(line); + uids.add(createUid(ts, labelsString, line, usedUids, refId)); + } + + return constructDataFrame(times, timesNs, lines, uids, labels, reverse, refId); +} + +/** + * Constructs dataFrame with supplied fields and other data. Also makes sure it is properly reversed if needed. + */ +function constructDataFrame( + times: ArrayVector, + timesNs: ArrayVector, + lines: ArrayVector, + uids: ArrayVector, + labels: Labels, + reverse?: boolean, + refId?: string +) { + const dataFrame = { + refId, + fields: [ + { name: 'ts', type: FieldType.time, config: { displayName: 'Time' }, values: times }, // Time + { name: 'line', type: FieldType.string, config: {}, values: lines, labels }, // Line - needs to be the first field with string type + { name: 'id', type: FieldType.string, config: {}, values: uids }, + { name: 'tsNs', type: FieldType.time, config: { displayName: 'Time ns' }, values: timesNs }, // Time + ], + length: times.length, + }; + + if (reverse) { + const mutableDataFrame = new MutableDataFrame(dataFrame); + mutableDataFrame.reverse(); + return mutableDataFrame; + } + + return dataFrame; +} + +/** + * Transform LokiResponse data and appends it to MutableDataFrame. Used for streaming where the dataFrame can be + * a CircularDataFrame creating a fixed size rolling buffer. + * TODO: Probably could be unified with the logStreamToDataFrame function. + * @param response + * @param data Needs to have ts, line, labels, id as fields + */ +export function appendResponseToBufferedData(response: LokiTailResponse, data: MutableDataFrame) { + // Should we do anything with: response.dropped_entries? + + const streams: LokiStreamResult[] = response.streams; + if (!streams || !streams.length) { + return; + } + + let baseLabels: Labels = {}; + for (const f of data.fields) { + if (f.type === FieldType.string) { + if (f.labels) { + baseLabels = f.labels; + } + break; + } + } + + const tsField = data.fields[0]; + const tsNsField = data.fields[1]; + const lineField = data.fields[2]; + const labelsField = data.fields[3]; + const idField = data.fields[4]; + + // We are comparing used ids only within the received stream. This could be a problem if the same line + labels + nanosecond timestamp came in 2 separate batches. + // As this is very unlikely, and the result would only affect live-tailing css animation we have decided to not compare all received uids from data param as this would slow down processing. + const usedUids: { string?: number } = {}; + + for (const stream of streams) { + // Find unique labels + const unique = findUniqueLabels(stream.stream, baseLabels); + const allLabelsString = Object.entries(stream.stream) + .map(([key, val]) => `${key}="${val}"`) + .sort() + .join(''); + + // Add each line + for (const [ts, line] of stream.values) { + tsField.values.add(new Date(parseInt(ts.substr(0, ts.length - 6), 10)).toISOString()); + tsNsField.values.add(ts); + lineField.values.add(line); + labelsField.values.add(unique); + idField.values.add(createUid(ts, allLabelsString, line, usedUids, data.refId)); + } + } +} + +function createUid(ts: string, labelsString: string, line: string, usedUids: any, refId?: string): string { + // Generate id as hashed nanosecond timestamp, labels and line (this does not have to be unique) + let id = md5(`${ts}_${labelsString}_${line}`); + + // Check if generated id is unique + // If not and we've already used it, append it's count after it + if (id in usedUids) { + // Increase the count + const newCount = usedUids[id] + 1; + usedUids[id] = newCount; + // Append count to generated id to make it unique + id = `${id}_${newCount}`; + } else { + // If id is unique and wasn't used, add it to usedUids and start count at 0 + usedUids[id] = 0; + } + // Return unique id + if (refId) { + return `${id}_${refId}`; + } + return id; +} + +function lokiMatrixToTimeSeries(matrixResult: LokiMatrixResult, options: TransformerOptions): TimeSeries { + const name = createMetricLabel(matrixResult.metric, options); + return { + target: name, + title: name, + datapoints: lokiPointsToTimeseriesPoints(matrixResult.values, options), + tags: matrixResult.metric, + meta: options.meta, + refId: options.refId, + }; +} + +export function lokiPointsToTimeseriesPoints( + data: Array<[number, string]>, + options: TransformerOptions +): TimeSeriesValue[][] { + const stepMs = options.step * 1000; + const datapoints: TimeSeriesValue[][] = []; + + let baseTimestampMs = options.start / 1e6; + for (const [time, value] of data) { + let datapointValue: TimeSeriesValue = parseFloat(value); + + if (isNaN(datapointValue)) { + datapointValue = null; + } + + const timestamp = time * 1000; + for (let t = baseTimestampMs; t < timestamp; t += stepMs) { + datapoints.push([null, t]); + } + + baseTimestampMs = timestamp + stepMs; + datapoints.push([datapointValue, timestamp]); + } + + const endTimestamp = options.end / 1e6; + for (let t = baseTimestampMs; t <= endTimestamp; t += stepMs) { + datapoints.push([null, t]); + } + + return datapoints; +} + +export function lokiResultsToTableModel( + lokiResults: Array, + resultCount: number, + refId: string, + meta: QueryResultMeta, + valueWithRefId?: boolean +): TableModel { + if (!lokiResults || lokiResults.length === 0) { + return new TableModel(); + } + + // Collect all labels across all metrics + const metricLabels: Set = new Set( + lokiResults.reduce((acc, cur) => acc.concat(Object.keys(cur.metric)), [] as string[]) + ); + + // Sort metric labels, create columns for them and record their index + const sortedLabels = [...metricLabels.values()].sort(); + const table = new TableModel(); + table.refId = refId; + table.meta = meta; + table.columns = [ + { text: 'Time', type: FieldType.time }, + ...sortedLabels.map((label) => ({ text: label, filterable: true, type: FieldType.string })), + { text: resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value', type: FieldType.number }, + ]; + + // Populate rows, set value to empty string when label not present. + lokiResults.forEach((series) => { + const newSeries: LokiMatrixResult = { + metric: series.metric, + values: (series as LokiVectorResult).value + ? [(series as LokiVectorResult).value] + : (series as LokiMatrixResult).values, + }; + + if (!newSeries.values) { + return; + } + + if (!newSeries.metric) { + table.rows.concat(newSeries.values.map(([a, b]) => [a * 1000, parseFloat(b)])); + } else { + table.rows.push( + ...newSeries.values.map(([a, b]) => [ + a * 1000, + ...sortedLabels.map((label) => newSeries.metric[label] || ''), + parseFloat(b), + ]) + ); + } + }); + + return table; +} + +export function createMetricLabel(labelData: { [key: string]: string }, options?: TransformerOptions) { + let label = + options === undefined || _.isEmpty(options.legendFormat) + ? getOriginalMetricName(labelData) + : renderTemplate(getTemplateSrv().replace(options.legendFormat ?? '', options.scopedVars), labelData); + + if (!label && options) { + label = options.query; + } + return label; +} + +function renderTemplate(aliasPattern: string, aliasData: { [key: string]: string }) { + const aliasRegex = /\{\{\s*(.+?)\s*\}\}/g; + return aliasPattern.replace(aliasRegex, (_, g1) => (aliasData[g1] ? aliasData[g1] : g1)); +} + +function getOriginalMetricName(labelData: { [key: string]: string }) { + const metricName = labelData.__name__ || ''; + delete labelData.__name__; + const labelPart = Object.entries(labelData) + .map((label) => `${label[0]}="${label[1]}"`) + .join(','); + return `${metricName}{${labelPart}}`; +} + +export function decamelize(s: string): string { + return s.replace(/[A-Z]/g, (m) => ` ${m.toLowerCase()}`); +} + +// Turn loki stats { metric: value } into meta stat { title: metric, value: value } +function lokiStatsToMetaStat(stats: LokiStats | undefined): QueryResultMetaStat[] { + const result: QueryResultMetaStat[] = []; + + if (!stats) { + return result; + } + + for (const section in stats) { + const values = stats[section]; + for (const label in values) { + const value = values[label]; + let unit; + if (/time/i.test(label) && value) { + unit = 's'; + } else if (/bytes.*persecond/i.test(label)) { + unit = 'Bps'; + } else if (/bytes/i.test(label)) { + unit = 'decbytes'; + } + const title = `${_.capitalize(section)}: ${decamelize(label)}`; + result.push({ displayName: title, value, unit }); + } + } + + return result; +} + +export function lokiStreamsToDataFrames( + response: LokiStreamResponse, + target: { refId: string; expr?: string }, + limit: number, + config: LokiOptions, + reverse = false +): DataFrame[] { + const data = limit > 0 ? response.data.result : []; + const stats: QueryResultMetaStat[] = lokiStatsToMetaStat(response.data.stats); + // Use custom mechanism to identify which stat we want to promote to label + const custom = { + lokiQueryStatKey: 'Summary: total bytes processed', + }; + + const meta: QueryResultMeta = { + searchWords: getHighlighterExpressionsFromQuery(formatQuery(target.expr)), + limit, + stats, + custom, + preferredVisualisationType: 'logs', + }; + + const series: DataFrame[] = data.map((stream) => { + const dataFrame = lokiStreamResultToDataFrame(stream, reverse, target.refId); + enhanceDataFrame(dataFrame, config); + + if (meta.custom && dataFrame.fields.some((f) => f.labels && Object.keys(f.labels).some((l) => l === '__error__'))) { + meta.custom.error = 'Error when parsing some of the logs'; + } + + return { + ...dataFrame, + refId: target.refId, + meta, + }; + }); + + if (stats.length && !data.length) { + return [ + { + fields: [], + length: 0, + refId: target.refId, + meta, + }, + ]; + } + + return series; +} + +/** + * Adds new fields and DataLinks to DataFrame based on DataSource instance config. + */ +export const enhanceDataFrame = (dataFrame: DataFrame, config: LokiOptions | null): void => { + if (!config) { + return; + } + + const derivedFields = config.derivedFields ?? []; + if (!derivedFields.length) { + return; + } + const derivedFieldsGrouped = _.groupBy(derivedFields, 'name'); + + const newFields = Object.values(derivedFieldsGrouped).map(fieldFromDerivedFieldConfig); + + const view = new DataFrameView(dataFrame); + view.forEach((row: { line: string }) => { + for (const field of newFields) { + const logMatch = row.line.match(derivedFieldsGrouped[field.name][0].matcherRegex); + field.values.add(logMatch && logMatch[1]); + } + }); + + dataFrame.fields = [...dataFrame.fields, ...newFields]; +}; + +/** + * Transform derivedField config into dataframe field with config that contains link. + */ +function fieldFromDerivedFieldConfig(derivedFieldConfigs: DerivedFieldConfig[]): Field { + const dataSourceSrv = getDataSourceSrv(); + + const dataLinks = derivedFieldConfigs.reduce((acc, derivedFieldConfig) => { + // Having field.datasourceUid means it is an internal link. + if (derivedFieldConfig.datasourceUid) { + const dsSettings = dataSourceSrv.getInstanceSettings(derivedFieldConfig.datasourceUid); + + acc.push({ + // Will be filled out later + title: '', + url: '', + // This is hardcoded for Jaeger or Zipkin not way right now to specify datasource specific query object + internal: { + query: { query: derivedFieldConfig.url }, + datasourceUid: derivedFieldConfig.datasourceUid, + datasourceName: dsSettings?.name ?? 'Data source not found', + }, + }); + } else if (derivedFieldConfig.url) { + acc.push({ + // We do not know what title to give here so we count on presentation layer to create a title from metadata. + title: '', + // This is hardcoded for Jaeger or Zipkin not way right now to specify datasource specific query object + url: derivedFieldConfig.url, + }); + } + return acc; + }, [] as DataLink[]); + + return { + name: derivedFieldConfigs[0].name, + type: FieldType.string, + config: { + links: dataLinks, + }, + // We are adding values later on + values: new ArrayVector([]), + }; +} + +export function rangeQueryResponseToTimeSeries( + response: LokiResponse, + query: LokiRangeQueryRequest, + target: LokiQuery, + responseListLength: number, + scopedVars: ScopedVars +): TimeSeries[] { + /** Show results of Loki metric queries only in graph */ + const meta: QueryResultMeta = { + preferredVisualisationType: 'graph', + }; + const transformerOptions: TransformerOptions = { + format: target.format, + legendFormat: target.legendFormat ?? '', + start: query.start!, + end: query.end!, + step: query.step!, + query: query.query, + responseListLength, + refId: target.refId, + meta, + valueWithRefId: target.valueWithRefId, + scopedVars, + }; + + switch (response.data.resultType) { + case LokiResultType.Vector: + return response.data.result.map((vecResult) => + lokiMatrixToTimeSeries({ metric: vecResult.metric, values: [vecResult.value] }, transformerOptions) + ); + case LokiResultType.Matrix: + return response.data.result.map((matrixResult) => lokiMatrixToTimeSeries(matrixResult, transformerOptions)); + default: + return []; + } +} + +export function processRangeQueryResponse( + response: LokiResponse, + target: LokiQuery, + query: LokiRangeQueryRequest, + responseListLength: number, + limit: number, + config: LokiOptions, + scopedVars: ScopedVars, + reverse = false +) { + switch (response.data.resultType) { + case LokiResultType.Stream: + return of({ + data: lokiStreamsToDataFrames(response as LokiStreamResponse, target, limit, config, reverse), + key: `${target.refId}_log`, + }); + + case LokiResultType.Vector: + case LokiResultType.Matrix: + return of({ + data: rangeQueryResponseToTimeSeries( + response, + query, + { + ...target, + format: 'time_series', + }, + responseListLength, + scopedVars + ), + key: target.refId, + }); + default: + throw new Error(`Unknown result type "${(response.data as any).resultType}".`); + } +} diff --git a/public/app/plugins/datasource/loki/syntax.test.ts b/public/app/plugins/datasource/loki/syntax.test.ts new file mode 100644 index 0000000..3b96c52 --- /dev/null +++ b/public/app/plugins/datasource/loki/syntax.test.ts @@ -0,0 +1,49 @@ +import syntax from './syntax'; +import Prism from 'prismjs'; + +describe('Loki syntax', () => { + it('should highlight Loki query correctly', () => { + expect(Prism.highlight('{key="val#ue"}', syntax, 'loki')).toBe( + '{key="val#ue"}' + ); + expect(Prism.highlight('{key="#value"}', syntax, 'loki')).toBe( + '{key="#value"}' + ); + expect(Prism.highlight('{key="value#"}', syntax, 'loki')).toBe( + '{key="value#"}' + ); + expect(Prism.highlight('#test{key="value"}', syntax, 'loki')).toBe( + '#test{key="value"}' + ); + expect(Prism.highlight('{key="value"}#test', syntax, 'loki')).toBe( + '{key="value"}#test' + ); + expect(Prism.highlight('{key="value"', syntax, 'loki')).toBe( + '{key="value"' + ); + }); + it('should highlight functions in Loki query correctly', () => { + expect(Prism.highlight('rate({key="value"}[5m])', syntax, 'loki')).toContain( + 'rate' + ); + expect(Prism.highlight('avg_over_time({key="value"}[5m])', syntax, 'loki')).toContain( + 'avg_over_time' + ); + }); + it('should highlight operators in Loki query correctly', () => { + expect(Prism.highlight('{key="value"} |= "test"', syntax, 'loki')).toContain( + ' |= ' + ); + expect(Prism.highlight('{key="value"} |~"test"', syntax, 'loki')).toContain( + ' |~' + ); + }); + it('should highlight pipe operations in Loki query correctly', () => { + expect(Prism.highlight('{key="value"} |= "test" | logfmt', syntax, 'loki')).toContain( + '| logfmt' + ); + expect(Prism.highlight('{key="value"} |= "test" | label_format', syntax, 'loki')).toContain( + ' | label_format' + ); + }); +}); diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts new file mode 100644 index 0000000..979b5e0 --- /dev/null +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -0,0 +1,242 @@ +import { Grammar } from 'prismjs'; +import { CompletionItem } from '@grafana/ui'; + +const AGGREGATION_OPERATORS: CompletionItem[] = [ + { + label: 'sum', + insertText: 'sum', + documentation: 'Calculate sum over dimensions', + }, + { + label: 'min', + insertText: 'min', + documentation: 'Select minimum over dimensions', + }, + { + label: 'max', + insertText: 'max', + documentation: 'Select maximum over dimensions', + }, + { + label: 'avg', + insertText: 'avg', + documentation: 'Calculate the average over dimensions', + }, + { + label: 'stddev', + insertText: 'stddev', + documentation: 'Calculate population standard deviation over dimensions', + }, + { + label: 'stdvar', + insertText: 'stdvar', + documentation: 'Calculate population standard variance over dimensions', + }, + { + label: 'count', + insertText: 'count', + documentation: 'Count number of elements in the vector', + }, + { + label: 'bottomk', + insertText: 'bottomk', + documentation: 'Smallest k elements by sample value', + }, + { + label: 'topk', + insertText: 'topk', + documentation: 'Largest k elements by sample value', + }, +]; + +export const PIPE_PARSERS: CompletionItem[] = [ + { + label: 'json', + insertText: 'json', + documentation: 'Extracting labels from the log line using json parser. Only available in Loki 2.0+.', + }, + { + label: 'regexp', + insertText: 'regexp ""', + documentation: 'Extracting labels from the log line using regexp parser. Only available in Loki 2.0+.', + move: -1, + }, + { + label: 'logfmt', + insertText: 'logfmt', + documentation: 'Extracting labels from the log line using logfmt parser. Only available in Loki 2.0+.', + }, +]; + +export const PIPE_OPERATORS: CompletionItem[] = [ + { + label: 'unwrap', + insertText: 'unwrap', + detail: 'unwrap identifier', + documentation: + 'Take labels and use the values as sample data for metric aggregations. Only available in Loki 2.0+.', + }, + { + label: 'label_format', + insertText: 'label_format', + documentation: + 'Use to rename, modify or add labels. For example, | label_format foo=bar . Only available in Loki 2.0+.', + }, + { + label: 'line_format', + insertText: 'line_format', + documentation: + 'Rewrites log line content. For example, | line_format "{{.query}} {{.duration}}" . Only available in Loki 2.0+.', + }, +]; + +export const RANGE_VEC_FUNCTIONS = [ + { + insertText: 'avg_over_time', + label: 'avg_over_time', + detail: 'avg_over_time(range-vector)', + documentation: 'The average of all values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'min_over_time', + label: 'min_over_time', + detail: 'min_over_time(range-vector)', + documentation: 'The minimum of all values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'max_over_time', + label: 'max_over_time', + detail: 'max_over_time(range-vector)', + documentation: 'The maximum of all values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'sum_over_time', + label: 'sum_over_time', + detail: 'sum_over_time(range-vector)', + documentation: 'The sum of all values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'count_over_time', + label: 'count_over_time', + detail: 'count_over_time(range-vector)', + documentation: 'The count of all values in the specified interval.', + }, + { + insertText: 'stdvar_over_time', + label: 'stdvar_over_time', + detail: 'stdvar_over_time(range-vector)', + documentation: + 'The population standard variance of the values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'stddev_over_time', + label: 'stddev_over_time', + detail: 'stddev_over_time(range-vector)', + documentation: + 'The population standard deviation of the values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'quantile_over_time', + label: 'quantile_over_time', + detail: 'quantile_over_time(scalar, range-vector)', + documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval. Only available in Loki 2.0+.', + }, + { + insertText: 'bytes_over_time', + label: 'bytes_over_time', + detail: 'bytes_over_time(range-vector)', + documentation: 'Counts the amount of bytes used by each log stream for a given range', + }, + { + insertText: 'bytes_rate', + label: 'bytes_rate', + detail: 'bytes_rate(range-vector)', + documentation: 'Calculates the number of bytes per second for each stream.', + }, + { + insertText: 'rate', + label: 'rate', + detail: 'rate(v range-vector)', + documentation: + "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.", + }, +]; + +export const FUNCTIONS = [...AGGREGATION_OPERATORS, ...RANGE_VEC_FUNCTIONS]; + +const tokenizer: Grammar = { + comment: { + pattern: /#.*/, + }, + 'context-aggregation': { + pattern: /((without|by)\s*)\([^)]*\)/, // by () + lookbehind: true, + inside: { + 'label-key': { + pattern: /[^(),\s][^,)]*[^),\s]*/, + alias: 'attr-name', + }, + punctuation: /[()]/, + }, + }, + 'context-labels': { + pattern: /\{[^}]*(?=}?)/, + greedy: true, + inside: { + comment: { + pattern: /#.*/, + }, + 'label-key': { + pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, + alias: 'attr-name', + greedy: true, + }, + 'label-value': { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + alias: 'attr-value', + }, + punctuation: /[{]/, + }, + }, + 'context-pipe': { + pattern: /\s\|[^=~]\s?\w*/i, + inside: { + 'pipe-operator': { + pattern: /\|/i, + alias: 'operator', + }, + 'pipe-operations': { + pattern: new RegExp(`${[...PIPE_PARSERS, ...PIPE_OPERATORS].map((f) => f.label).join('|')}`, 'i'), + alias: 'keyword', + }, + }, + }, + function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'), + 'context-range': [ + { + pattern: /\[[^\]]*(?=\])/, // [1m] + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + { + pattern: /(offset\s+)\w+/, // offset 1m + lookbehind: true, + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + ], + number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, + operator: /\s?(\|[=~]?|!=?|<(?:=>?|<|>)?|>[>=]?)\s?/i, + punctuation: /[{}()`,.]/, +}; + +export default tokenizer; diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts new file mode 100644 index 0000000..0b751fb --- /dev/null +++ b/public/app/plugins/datasource/loki/types.ts @@ -0,0 +1,130 @@ +import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data'; + +export interface LokiInstantQueryRequest { + query: string; + limit?: number; + time?: string; + direction?: 'BACKWARD' | 'FORWARD'; +} + +export interface LokiRangeQueryRequest { + query: string; + limit?: number; + start?: number; + end?: number; + step?: number; + direction?: 'BACKWARD' | 'FORWARD'; +} + +export enum LokiResultType { + Stream = 'streams', + Vector = 'vector', + Matrix = 'matrix', +} + +export interface LokiQuery extends DataQuery { + expr: string; + query?: string; + format?: string; + reverse?: boolean; + legendFormat?: string; + valueWithRefId?: boolean; + maxLines?: number; + range?: boolean; + instant?: boolean; +} + +export interface LokiOptions extends DataSourceJsonData { + maxLines?: string; + derivedFields?: DerivedFieldConfig[]; +} + +export interface LokiStats { + [component: string]: { + [label: string]: number; + }; +} + +export interface LokiVectorResult { + metric: { [label: string]: string }; + value: [number, string]; +} + +export interface LokiVectorResponse { + status: string; + data: { + resultType: LokiResultType.Vector; + result: LokiVectorResult[]; + stats?: LokiStats; + }; +} + +export interface LokiMatrixResult { + metric: Record; + values: Array<[number, string]>; +} + +export interface LokiMatrixResponse { + status: string; + data: { + resultType: LokiResultType.Matrix; + result: LokiMatrixResult[]; + stats?: LokiStats; + }; +} + +export interface LokiStreamResult { + stream: Record; + values: Array<[string, string]>; +} + +export interface LokiStreamResponse { + status: string; + data: { + resultType: LokiResultType.Stream; + result: LokiStreamResult[]; + stats?: LokiStats; + }; +} + +export interface LokiTailResponse { + streams: LokiStreamResult[]; + dropped_entries?: Array<{ + labels: Record; + timestamp: string; + }> | null; +} + +export type LokiResult = LokiVectorResult | LokiMatrixResult | LokiStreamResult; +export type LokiResponse = LokiVectorResponse | LokiMatrixResponse | LokiStreamResponse; + +export interface LokiLogsStreamEntry { + line: string; + ts: string; +} + +export interface LokiExpression { + regexp: string; + query: string; +} + +export type DerivedFieldConfig = { + matcherRegex: string; + name: string; + url?: string; + datasourceUid?: string; +}; + +export interface TransformerOptions { + format?: string; + legendFormat?: string; + step: number; + start: number; + end: number; + query: string; + responseListLength: number; + refId: string; + scopedVars: ScopedVars; + meta?: QueryResultMeta; + valueWithRefId?: boolean; +} diff --git a/public/app/plugins/datasource/mixed/MixedDataSource.test.ts b/public/app/plugins/datasource/mixed/MixedDataSource.test.ts new file mode 100644 index 0000000..455b081 --- /dev/null +++ b/public/app/plugins/datasource/mixed/MixedDataSource.test.ts @@ -0,0 +1,100 @@ +import { from } from 'rxjs'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataSourceInstanceSettings, LoadingState } from '@grafana/data'; + +import { DatasourceSrvMock, MockDataSourceApi } from 'test/mocks/datasource_srv'; +import { getQueryOptions } from 'test/helpers/getQueryOptions'; +import { MixedDatasource } from './module'; + +const defaultDS = new MockDataSourceApi('DefaultDS', { data: ['DDD'] }); +const datasourceSrv = new DatasourceSrvMock(defaultDS, { + '-- Mixed --': new MixedDatasource({ name: 'mixed', id: 5 } as DataSourceInstanceSettings), + A: new MockDataSourceApi('DSA', { data: ['AAAA'] }), + B: new MockDataSourceApi('DSB', { data: ['BBBB'] }), + C: new MockDataSourceApi('DSC', { data: ['CCCC'] }), + D: new MockDataSourceApi('DSD', { data: [] }, {}, 'syntax error near FROM'), + E: new MockDataSourceApi('DSE', { data: [] }, {}, 'syntax error near WHERE'), +}); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getDataSourceSrv: () => { + return datasourceSrv; + }, +})); + +describe('MixedDatasource', () => { + describe('with no errors', () => { + const requestMixed = getQueryOptions({ + targets: [ + { refId: 'QA', datasource: 'A' }, // 1 + { refId: 'QB', datasource: 'B' }, // 2 + { refId: 'QC', datasource: 'C' }, // 3 + ], + }); + const results: any[] = []; + + beforeEach(async (done) => { + const ds = await getDataSourceSrv().get('-- Mixed --'); + + from(ds.query(requestMixed)).subscribe((result) => { + results.push(result); + if (result.state === LoadingState.Done) { + done(); + } + }); + }); + + it('direct query should return results', async () => { + expect(results.length).toBe(3); + expect(results[0].data).toEqual(['AAAA']); + expect(results[0].state).toEqual(LoadingState.Loading); + expect(results[1].data).toEqual(['BBBB']); + expect(results[2].data).toEqual(['CCCC']); + expect(results[2].state).toEqual(LoadingState.Done); + expect(results.length).toBe(3); + }); + }); + + describe('with errors', () => { + const requestMixed = getQueryOptions({ + targets: [ + { refId: 'QA', datasource: 'A' }, // 1 + { refId: 'QD', datasource: 'D' }, // 2 + { refId: 'QB', datasource: 'B' }, // 3 + { refId: 'QE', datasource: 'E' }, // 4 + { refId: 'QC', datasource: 'C' }, // 5 + ], + }); + const results: any[] = []; + + beforeEach(async (done) => { + const ds = await getDataSourceSrv().get('-- Mixed --'); + + from(ds.query(requestMixed)).subscribe((result) => { + results.push(result); + if (results.length === 5) { + done(); + } + }); + }); + + it('direct query should return results', async () => { + expect(results[0].data).toEqual(['AAAA']); + expect(results[0].state).toEqual(LoadingState.Loading); + expect(results[1].data).toEqual([]); + expect(results[1].state).toEqual(LoadingState.Error); + expect(results[1].error).toEqual({ message: 'DSD: syntax error near FROM' }); + expect(results[2].data).toEqual(['BBBB']); + expect(results[2].state).toEqual(LoadingState.Loading); + expect(results[3].data).toEqual([]); + expect(results[3].state).toEqual(LoadingState.Error); + expect(results[3].error).toEqual({ message: 'DSE: syntax error near WHERE' }); + expect(results[4].data).toEqual(['CCCC']); + expect(results[4].state).toEqual(LoadingState.Loading); + expect(results[5].data).toEqual([]); + expect(results[5].state).toEqual(LoadingState.Error); + expect(results[5].error).toEqual({ message: 'DSD: syntax error near FROM' }); + }); + }); +}); diff --git a/public/app/plugins/datasource/mixed/MixedDataSource.ts b/public/app/plugins/datasource/mixed/MixedDataSource.ts new file mode 100644 index 0000000..9a0ad8f --- /dev/null +++ b/public/app/plugins/datasource/mixed/MixedDataSource.ts @@ -0,0 +1,116 @@ +import cloneDeep from 'lodash/cloneDeep'; +import groupBy from 'lodash/groupBy'; +import { forkJoin, from, Observable, of } from 'rxjs'; +import { catchError, map, mergeAll, mergeMap } from 'rxjs/operators'; + +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + LoadingState, +} from '@grafana/data'; +import { getDataSourceSrv, toDataQueryError } from '@grafana/runtime'; + +export const MIXED_DATASOURCE_NAME = '-- Mixed --'; + +export interface BatchedQueries { + datasource: Promise; + targets: DataQuery[]; +} + +export class MixedDatasource extends DataSourceApi { + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + } + + query(request: DataQueryRequest): Observable { + // Remove any invalid queries + const queries = request.targets.filter((t) => { + return t.datasource !== MIXED_DATASOURCE_NAME; + }); + + if (!queries.length) { + return of({ data: [] } as DataQueryResponse); // nothing + } + + // Build groups of queries to run in parallel + const sets: { [key: string]: DataQuery[] } = groupBy(queries, 'datasource'); + const mixed: BatchedQueries[] = []; + + for (const key in sets) { + const targets = sets[key]; + const dsName = targets[0].datasource; + + mixed.push({ + datasource: getDataSourceSrv().get(dsName, request.scopedVars), + targets, + }); + } + + return this.batchQueries(mixed, request); + } + + batchQueries(mixed: BatchedQueries[], request: DataQueryRequest): Observable { + const runningQueries = mixed.filter(this.isQueryable).map((query, i) => + from(query.datasource).pipe( + mergeMap((api: DataSourceApi) => { + const dsRequest = cloneDeep(request); + dsRequest.requestId = `mixed-${i}-${dsRequest.requestId || ''}`; + dsRequest.targets = query.targets; + + return from(api.query(dsRequest)).pipe( + map((response) => { + return { + ...response, + data: response.data || [], + state: LoadingState.Loading, + key: `mixed-${i}-${response.key || ''}`, + } as DataQueryResponse; + }), + catchError((err) => { + err = toDataQueryError(err); + + err.message = `${api.name}: ${err.message}`; + + return of({ + data: [], + state: LoadingState.Error, + error: err, + key: `mixed-${i}-${dsRequest.requestId || ''}`, + }); + }) + ); + }) + ) + ); + + return forkJoin(runningQueries).pipe(map(this.finalizeResponses), mergeAll()); + } + + testDatasource() { + return Promise.resolve({}); + } + + private isQueryable(query: BatchedQueries): boolean { + return query && Array.isArray(query.targets) && query.targets.length > 0; + } + + private finalizeResponses(responses: DataQueryResponse[]): DataQueryResponse[] { + const { length } = responses; + + if (length === 0) { + return responses; + } + + const error = responses.find((response) => response.state === LoadingState.Error); + if (error) { + responses.push(error); // adds the first found error entry so error shows up in the panel + } else { + responses[length - 1].state = LoadingState.Done; + } + + return responses; + } +} diff --git a/public/app/plugins/datasource/mixed/README.md b/public/app/plugins/datasource/mixed/README.md new file mode 100644 index 0000000..0754d4c --- /dev/null +++ b/public/app/plugins/datasource/mixed/README.md @@ -0,0 +1,3 @@ +# Mixed Data Source - Native Plugin + +This is a **built in** data source that allows you to mix different data source on the same graph! You can enable this by selecting the built in -- Mixed -- data source. When selected this will allow you to specify data source on a per query basis. This will, for example, allow you to plot metrics from different Graphite servers on the same Graph or plot data from Elasticsearch alongside data from Prometheus. Mixing different data sources on the same graph works for any data source, even custom ones. \ No newline at end of file diff --git a/public/app/plugins/datasource/mixed/module.ts b/public/app/plugins/datasource/mixed/module.ts new file mode 100644 index 0000000..59fd1ae --- /dev/null +++ b/public/app/plugins/datasource/mixed/module.ts @@ -0,0 +1,2 @@ +import { MixedDatasource } from './MixedDataSource'; +export { MixedDatasource, MixedDatasource as Datasource }; diff --git a/public/app/plugins/datasource/mixed/plugin.json b/public/app/plugins/datasource/mixed/plugin.json new file mode 100644 index 0000000..b28c587 --- /dev/null +++ b/public/app/plugins/datasource/mixed/plugin.json @@ -0,0 +1,13 @@ +{ + "type": "datasource", + "name": "-- Mixed --", + "id": "mixed", + + "builtIn": true, + "mixed": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/mssql/README.md b/public/app/plugins/datasource/mssql/README.md new file mode 100644 index 0000000..b109226 --- /dev/null +++ b/public/app/plugins/datasource/mssql/README.md @@ -0,0 +1,12 @@ +# Grafana Microsoft SQL Server Data Source - Native Plugin + +Grafana ships with a built-in Microsoft SQL Server (MSSQL) data source plugin that allows you to query and visualize data from any Microsoft SQL Server 2005 or newer. + +## Adding the data source + +1. Open the side menu by clicking the Grafana icon in the top header. +2. In the side menu under the `Configuration` link you should find a link named `Data Sources`. +3. Click the `+ Add data source` button in the top header. +4. Select *Microsoft SQL Server* from the *Type* dropdown. + +For more information, check the [docs](http://docs.grafana.org/). \ No newline at end of file diff --git a/public/app/plugins/datasource/mssql/config_ctrl.ts b/public/app/plugins/datasource/mssql/config_ctrl.ts new file mode 100644 index 0000000..e1db942 --- /dev/null +++ b/public/app/plugins/datasource/mssql/config_ctrl.ts @@ -0,0 +1,33 @@ +import { + createChangeHandler, + createResetHandler, + PasswordFieldEnum, +} from '../../../features/datasources/utils/passwordHandlers'; + +export class MssqlConfigCtrl { + static templateUrl = 'partials/config.html'; + + current: any; + onPasswordReset: ReturnType; + onPasswordChange: ReturnType; + showUserCredentials: boolean; + + /** @ngInject */ + constructor($scope: any) { + this.current.jsonData.encrypt = this.current.jsonData.encrypt || 'false'; + this.current.jsonData.authenticationType = this.current.jsonData.authenticationType || 'SQL Server Authentication'; + this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); + this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + this.showUserCredentials = this.current.jsonData.authenticationType !== 'Windows Authentication'; + } + + onAuthenticationTypeChange() { + // This is using the fallback in https://github.com/denisenkom/go-mssqldb to use Windows Auth if login/user id is empty. + if (this.current.jsonData.authenticationType === 'Windows Authentication') { + this.current.user = ''; + this.current.password = ''; + } + + this.showUserCredentials = this.current.jsonData.authenticationType !== 'Windows Authentication'; + } +} diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts new file mode 100644 index 0000000..af08061 --- /dev/null +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -0,0 +1,196 @@ +import _ from 'lodash'; +import { Observable, of } from 'rxjs'; +import { catchError, map, mapTo } from 'rxjs/operators'; +import { getBackendSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +import ResponseParser, { MssqlResponse } from './response_parser'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { MssqlQueryForInterpolation } from './types'; + +export class MssqlDatasource { + id: any; + name: any; + responseParser: ResponseParser; + interval: string; + + constructor( + instanceSettings: any, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.responseParser = new ResponseParser(); + this.interval = (instanceSettings.jsonData || {}).timeInterval || '1m'; + } + + interpolateVariable(value: any, variable: any) { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + return "'" + value.replace(/'/g, `''`) + "'"; + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _.map(value, (val) => { + if (typeof value === 'number') { + return value; + } + + return "'" + val.replace(/'/g, `''`) + "'"; + }); + return quotedValues.join(','); + } + + interpolateVariablesInQueries( + queries: MssqlQueryForInterpolation[], + scopedVars: ScopedVars + ): MssqlQueryForInterpolation[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), + rawQuery: true, + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + query(options: any): Observable { + const queries = _.filter(options.targets, (item) => { + return item.hide !== true; + }).map((item) => { + return { + refId: item.refId, + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable), + format: item.format, + }; + }); + + if (queries.length === 0) { + return of({ data: [] }); + } + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: queries, + }, + }) + .pipe(map(this.responseParser.processQueryResult)); + } + + annotationQuery(options: any) { + if (!options.annotation.rawQuery) { + return Promise.reject({ message: 'Query missing in annotation definition' }); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + }, + }) + .pipe(map((data: any) => this.responseParser.transformAnnotationResponse(options, data))) + .toPromise(); + } + + metricFindQuery(query: string, optionalOptions: { variable: { name: string } }) { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable), + format: 'table', + }; + + const range = this.timeSrv.timeRange(); + const data = { + queries: [interpolatedQuery], + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + }; + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: data, + }) + .pipe(map((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data))) + .toPromise(); + } + + testDatasource() { + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: '5m', + to: 'now', + queries: [ + { + refId: 'A', + intervalMs: 1, + maxDataPoints: 1, + datasourceId: this.id, + rawSql: 'SELECT 1', + format: 'table', + }, + ], + }, + }) + .pipe( + mapTo({ status: 'success', message: 'Database Connection OK' }), + catchError((err) => { + console.error(err); + if (err.data && err.data.message) { + return of({ status: 'error', message: err.data.message }); + } + + return of({ status: 'error', message: err.status }); + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + const rawSql = target.rawSql.replace('$__', ''); + return this.templateSrv.variableExists(rawSql); + } +} diff --git a/public/app/plugins/datasource/mssql/img/sql_server_logo.svg b/public/app/plugins/datasource/mssql/img/sql_server_logo.svg new file mode 100644 index 0000000..7fb7859 --- /dev/null +++ b/public/app/plugins/datasource/mssql/img/sql_server_logo.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/mssql/module.ts b/public/app/plugins/datasource/mssql/module.ts new file mode 100644 index 0000000..bf46b6d --- /dev/null +++ b/public/app/plugins/datasource/mssql/module.ts @@ -0,0 +1,33 @@ +import { MssqlDatasource } from './datasource'; +import { MssqlQueryCtrl } from './query_ctrl'; +import { MssqlConfigCtrl } from './config_ctrl'; + +const defaultQuery = `SELECT + as time, + as text, + as tags + FROM + + WHERE + $__timeFilter(time_column) + ORDER BY + ASC`; + +class MssqlAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export { + MssqlDatasource, + MssqlDatasource as Datasource, + MssqlQueryCtrl as QueryCtrl, + MssqlConfigCtrl as ConfigCtrl, + MssqlAnnotationsQueryCtrl as AnnotationsQueryCtrl, +}; diff --git a/public/app/plugins/datasource/mssql/partials/annotations.editor.html b/public/app/plugins/datasource/mssql/partials/annotations.editor.html new file mode 100644 index 0000000..442dc21 --- /dev/null +++ b/public/app/plugins/datasource/mssql/partials/annotations.editor.html @@ -0,0 +1,54 @@ +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      Annotation Query Format
      +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event end time. Use epoch time or any native date data type. +- column with alias: text for the annotation text. +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2'. + + +Macros: +- $__time(column) -> column AS time +- $__timeEpoch(column) -> DATEDIFF(second, '1970-01-01', column) AS time +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
      +
      +
      diff --git a/public/app/plugins/datasource/mssql/partials/config.html b/public/app/plugins/datasource/mssql/partials/config.html new file mode 100644 index 0000000..51ecbd7 --- /dev/null +++ b/public/app/plugins/datasource/mssql/partials/config.html @@ -0,0 +1,125 @@ + +

      MS SQL connection

      + +
      +
      + Host + +
      + +
      + Database + +
      + +
      + +
      + + +
        +
      • SQL Server Authentication This is the default mechanism to connect to MS SQL Server. Enter the SQL Server Authentication login or the Windows Authentication login in the DOMAIN\User format.
      • +
      • Windows Authentication Windows Integrated Security - single sign on for users who are already logged onto Windows and have enabled this option for MS SQL Server.
      • +
      +
      +
      +
      +
      +
      + User + +
      +
      + +
      +
      + +
      + +
      + + + Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server. +
        +
      • disable - Data sent between client and server is not encrypted.
      • +
      • false - Data sent between client and server is not encrypted beyond the login packet. (default)
      • +
      • true - Data sent between client and server is encrypted.
      • +
      + If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect. +
      +
      +
      +
      + +

      Connection limits

      + +
      +
      + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
      +
      + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
      +
      + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever. + +
      +
      + +

      MS SQL details

      + +
      +
      +
      + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
      +
      +
      + +
      +
      +
      User Permission
      +

      + The database user should only be granted SELECT permissions on the specified database and tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like USE otherdb; and DROP TABLE user; would be executed. To protect against this we + highly recommmend you create a specific MS SQL user with restricted permissions. +

      +
      +
      + diff --git a/public/app/plugins/datasource/mssql/partials/query.editor.html b/public/app/plugins/datasource/mssql/partials/query.editor.html new file mode 100644 index 0000000..a1c71e0 --- /dev/null +++ b/public/app/plugins/datasource/mssql/partials/query.editor.html @@ -0,0 +1,90 @@ + +
      +
      + + +
      +
      + +
      +
      + +
      + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      Time series:
      +- return column named time (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below.
      +- any other columns returned will be the time point values.
      +Optional:
      +  - return column named metric to represent the series name.
      +  - If multiple value columns are returned the metric column is used as prefix.
      +  - If no column named metric is found the column name of the value column is used as series name
      +
      +Resultsets of time series queries need to be sorted by time.
      +
      +Table:
      +- return any set of columns
      +
      +Macros:
      +- $__time(column) -> column AS time
      +- $__timeEpoch(column) -> DATEDIFF(second, '1970-01-01', column) AS time
      +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
      +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877
      +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
      +- $__timeGroup(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300.
      +     by setting fillvalue grafana will fill in missing values according to the interval
      +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
      +- $__timeGroupAlias(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300 AS [time]
      +- $__unixEpochGroup(column,'5m') -> FLOOR(column/300)*300
      +- $__unixEpochGroupAlias(column,'5m') -> FLOOR(column/300)*300 AS [time]
      +
      +Example of group by and order by with $__timeGroup:
      +SELECT
      +  $__timeGroup(date_time_col, '1h') AS time,
      +  sum(value) as value
      +FROM yourtable
      +GROUP BY $__timeGroup(date_time_col, '1h')
      +ORDER BY 1
      +
      +Or build your own conditionals using these macros which just return the values:
      +- $__timeFrom() ->  '2017-04-21T05:01:17Z'
      +- $__timeTo() ->  '2017-04-21T05:01:17Z'
      +- $__unixEpochFrom() -> 1492750877
      +- $__unixEpochTo() -> 1492750877
      +- $__unixEpochNanoFrom() ->  1494410783152415214
      +- $__unixEpochNanoTo() ->  1494497183142514872
      +		
      +
      + + + +
      +
      {{ctrl.lastQueryMeta.executedQueryString}}
      +
      + +
      +
      {{ctrl.lastQueryError}}
      +
      + +
      diff --git a/public/app/plugins/datasource/mssql/plugin.json b/public/app/plugins/datasource/mssql/plugin.json new file mode 100644 index 0000000..333bf20 --- /dev/null +++ b/public/app/plugins/datasource/mssql/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "Microsoft SQL Server", + "id": "mssql", + "category": "sql", + + "info": { + "description": "Data source for Microsoft SQL Server compatible databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/sql_server_logo.svg", + "large": "img/sql_server_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/mssql/query_ctrl.ts b/public/app/plugins/datasource/mssql/query_ctrl.ts new file mode 100644 index 0000000..f826d63 --- /dev/null +++ b/public/app/plugins/datasource/mssql/query_ctrl.ts @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { auto } from 'angular'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; + +export interface MssqlQuery { + refId: string; + format: string; + alias: string; + rawSql: string; +} + +const defaultQuery = `SELECT + $__timeEpoch(), + as value, + as metric +FROM +
      +WHERE + $__timeFilter(time_column) +ORDER BY + ASC`; + +export class MssqlQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + target: MssqlQuery; + lastQueryMeta?: QueryResultMeta; + lastQueryError?: string; + showHelp: boolean; + + /** @ngInject */ + constructor($scope: any, $injector: auto.IInjectorService) { + super($scope, $injector); + + this.target.format = this.target.format || 'time_series'; + this.target.alias = ''; + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + } else { + this.target.rawSql = defaultQuery; + } + } + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } +} diff --git a/public/app/plugins/datasource/mssql/response_parser.ts b/public/app/plugins/datasource/mssql/response_parser.ts new file mode 100644 index 0000000..89808f3 --- /dev/null +++ b/public/app/plugins/datasource/mssql/response_parser.ts @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import { MetricFindValue } from '@grafana/data'; + +interface TableResponse extends Record { + type: string; + refId: string; + meta: any; +} + +interface SeriesResponse extends Record { + target: string; + refId: string; + meta: any; + datapoints: [any[]]; +} + +export interface MssqlResponse { + data: Array; +} + +export default class ResponseParser { + processQueryResult(res: any): MssqlResponse { + const data: any[] = []; + + if (!res.data.results) { + return { data }; + } + + for (const key in res.data.results) { + const queryRes = res.data.results[key]; + + if (queryRes.series) { + for (const series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + refId: queryRes.refId, + meta: queryRes.meta, + }); + } + } + + if (queryRes.tables) { + for (const table of queryRes.tables) { + table.type = 'table'; + table.refId = queryRes.refId; + table.meta = queryRes.meta; + data.push(table); + } + } + } + + return { data: data }; + } + + parseMetricFindQueryResult(refId: string, results: any): MetricFindValue[] { + if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { + return []; + } + + const columns = results.data.results[refId].tables[0].columns; + const rows = results.data.results[refId].tables[0].rows; + const textColIndex = this.findColIndex(columns, '__text'); + const valueColIndex = this.findColIndex(columns, '__value'); + + if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) { + return this.transformToKeyValueList(rows, textColIndex, valueColIndex); + } + + return this.transformToSimpleList(rows); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ text: rows[i][textColIndex], value: rows[i][valueColIndex] }); + } + } + + return res; + } + + transformToSimpleList(rows: any): MetricFindValue[] { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return _.map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any[], key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + transformAnnotationResponse(options: any, data: any) { + const table = data.data.results[options.annotation.name].tables[0]; + + let timeColumnIndex = -1; + let timeEndColumnIndex = -1; + let textColumnIndex = -1; + let tagsColumnIndex = -1; + + for (let i = 0; i < table.columns.length; i++) { + if (table.columns[i].text === 'time') { + timeColumnIndex = i; + } else if (table.columns[i].text === 'timeend') { + timeEndColumnIndex = i; + } else if (table.columns[i].text === 'text') { + textColumnIndex = i; + } else if (table.columns[i].text === 'tags') { + tagsColumnIndex = i; + } + } + + if (timeColumnIndex === -1) { + return Promise.reject({ message: 'Missing mandatory time column (with time column alias) in annotation query.' }); + } + + const list = []; + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]; + const timeEnd = + timeEndColumnIndex !== -1 && row[timeEndColumnIndex] ? Math.floor(row[timeEndColumnIndex]) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(row[timeColumnIndex]), + timeEnd, + text: row[textColumnIndex], + tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/mssql/specs/datasource.test.ts b/public/app/plugins/datasource/mssql/specs/datasource.test.ts new file mode 100644 index 0000000..e1415f1 --- /dev/null +++ b/public/app/plugins/datasource/mssql/specs/datasource.test.ts @@ -0,0 +1,336 @@ +import { of } from 'rxjs'; +import { dateTime } from '@grafana/data'; + +import { MssqlDatasource } from '../datasource'; +import { TimeSrvStub } from 'test/specs/helpers'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +describe('MSSQLDatasource', () => { + const templateSrv: TemplateSrv = new TemplateSrv(); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + + const ctx: any = { + timeSrv: new TimeSrvStub(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ctx.instanceSettings = { name: 'mssql' }; + ctx.ds = new MssqlDatasource(ctx.instanceSettings, templateSrv, ctx.timeSrv); + }); + + describe('When performing annotationQuery', () => { + let results: any; + + const annotationName = 'MyAnno'; + + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + + const response = { + results: { + MyAnno: { + refId: annotationName, + tables: [ + { + columns: [{ text: 'time' }, { text: 'text' }, { text: 'tags' }], + rows: [ + [1521545610656, 'some text', 'TagA,TagB'], + [1521546251185, 'some text2', ' TagB , TagC'], + [1521546501378, 'some text3'], + ], + }, + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.annotationQuery(options).then((data: any) => { + results = data; + }); + }); + + it('should return annotation list', () => { + expect(results.length).toBe(3); + + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + let results: any; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query).then((data: any) => { + results = data; + }); + }); + + it('should return list of all column values', () => { + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + let results: any; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__value' }, { text: '__text' }], + rows: [ + ['value1', 'aTitle'], + ['value2', 'aTitle2'], + ['value3', 'aTitle3'], + ], + }, + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query).then((data: any) => { + results = data; + }); + }); + + it('should return list of as text, value', () => { + expect(results.length).toBe(3); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('value1'); + expect(results[2].text).toBe('aTitle3'); + expect(results[2].value).toBe('value3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + let results: any; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__text' }, { text: '__value' }], + rows: [ + ['aTitle', 'same'], + ['aTitle', 'same'], + ['aTitle', 'diff'], + ], + }, + ], + }, + }, + }; + + beforeEach(() => { + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + return ctx.ds.metricFindQuery(query).then((data: any) => { + results = data; + }); + }); + + it('should return list of unique keys', () => { + expect(results.length).toBe(1); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('same'); + }); + }); + + describe('When performing metricFindQuery', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 1, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }], + rows: [['aTitle']], + }, + ], + }, + }, + }; + const time = { + from: dateTime(1521545610656), + to: dateTime(1521546251185), + }; + + beforeEach(() => { + ctx.timeSrv.setTime(time); + + fetchMock.mockImplementation(() => of(createFetchResponse(response))); + + return ctx.ds.metricFindQuery(query); + }); + + it('should pass timerange to datasourceRequest', () => { + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.from).toBe(time.from.valueOf().toString()); + expect(fetchMock.mock.calls[0][0].data.to).toBe(time.to.valueOf().toString()); + expect(fetchMock.mock.calls[0][0].data.queries.length).toBe(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe(query); + }); + }); + + describe('When interpolating variables', () => { + beforeEach(() => { + ctx.variable = { ...initialCustomVariableModelState }; + }); + + describe('and value is a string', () => { + it('should return an unquoted value', () => { + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and value is a string', () => { + it('should return a quoted value', () => { + ctx.variable.multi = true; + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + ctx.variable.multi = true; + expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'"); + }); + }); + + describe('and variable allows all and value is a string', () => { + it('should return a quoted value', () => { + ctx.variable.includeAll = true; + expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const rawSql = `SELECT + $__timeGroup(createdAt,'$summarize') as time, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY $__timeGroup(createdAt,'$summarize'), hostname + ORDER BY 1`; + const query = { + rawSql, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ctx.ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const rawSql = `SELECT + $__timeGroup(createdAt,'$__interval') as time, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' + GROUP BY $__timeGroup(createdAt,'$summarize'), hostname + ORDER BY 1`; + const query = { + rawSql, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ctx.ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts new file mode 100644 index 0000000..a166a5c --- /dev/null +++ b/public/app/plugins/datasource/mssql/types.ts @@ -0,0 +1,7 @@ +export interface MssqlQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId?: any; + hide?: any; +} diff --git a/public/app/plugins/datasource/mysql/README.md b/public/app/plugins/datasource/mysql/README.md new file mode 100644 index 0000000..2d843b0 --- /dev/null +++ b/public/app/plugins/datasource/mysql/README.md @@ -0,0 +1,14 @@ +# MySQL Data Source - Native Plugin + +Grafana ships with a built-in MySQL data source plugin that allows you to query and visualize data from a MySQL compatible database. + +## Adding the data source + +1. Open the side menu by clicking the Grafana icon in the top header. +2. In the side menu under the Dashboards link you should find a link named Data Sources. +3. Click the + Add data source button in the top header. +4. Select MySQL from the Type dropdown. + +Read more about it here: + +[http://docs.grafana.org/features/datasources/mysql/](http://docs.grafana.org/features/datasources/mysql/) diff --git a/public/app/plugins/datasource/mysql/datasource.ts b/public/app/plugins/datasource/mysql/datasource.ts new file mode 100644 index 0000000..d5c1ef7 --- /dev/null +++ b/public/app/plugins/datasource/mysql/datasource.ts @@ -0,0 +1,223 @@ +import _ from 'lodash'; +import { Observable, of } from 'rxjs'; +import { catchError, map, mapTo } from 'rxjs/operators'; +import { getBackendSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; +import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query'; +import ResponseParser, { MysqlResponse } from './response_parser'; +import { MysqlMetricFindValue, MysqlQueryForInterpolation } from './types'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { getSearchFilterScopedVar } from '../../../features/variables/utils'; + +export class MysqlDatasource { + id: any; + name: any; + responseParser: ResponseParser; + queryModel: MysqlQuery; + interval: string; + + constructor( + instanceSettings: any, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.responseParser = new ResponseParser(); + this.queryModel = new MysqlQuery({}); + this.interval = (instanceSettings.jsonData || {}).timeInterval || '1m'; + } + + interpolateVariable = (value: string | string[] | number, variable: any) => { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + const result = this.queryModel.quoteLiteral(value); + return result; + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _.map(value, (v: any) => { + return this.queryModel.quoteLiteral(v); + }); + return quotedValues.join(','); + }; + + interpolateVariablesInQueries( + queries: MysqlQueryForInterpolation[], + scopedVars: ScopedVars + ): MysqlQueryForInterpolation[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), + rawQuery: true, + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + query(options: any): Observable { + const queries = _.filter(options.targets, (target) => { + return target.hide !== true; + }).map((target) => { + const queryModel = new MysqlQuery(target, this.templateSrv, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + rawSql: queryModel.render(this.interpolateVariable as any), + format: target.format, + }; + }); + + if (queries.length === 0) { + return of({ data: [] }); + } + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: queries, + }, + }) + .pipe(map(this.responseParser.processQueryResult)); + } + + annotationQuery(options: any) { + if (!options.annotation.rawQuery) { + return Promise.reject({ + message: 'Query missing in annotation definition', + }); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + }, + }) + .pipe(map((data: any) => this.responseParser.transformAnnotationResponse(options, data))) + .toPromise(); + } + + metricFindQuery(query: string, optionalOptions: any): Promise { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const rawSql = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }), + this.interpolateVariable + ); + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql, + format: 'table', + }; + + const range = this.timeSrv.timeRange(); + const data = { + queries: [interpolatedQuery], + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + }; + + if (optionalOptions && optionalOptions.range && optionalOptions.range.from) { + data['from'] = optionalOptions.range.from.valueOf().toString(); + } + if (optionalOptions && optionalOptions.range && optionalOptions.range.to) { + data['to'] = optionalOptions.range.to.valueOf().toString(); + } + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: data, + }) + .pipe(map((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data))) + .toPromise(); + } + + testDatasource() { + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: '5m', + to: 'now', + queries: [ + { + refId: 'A', + intervalMs: 1, + maxDataPoints: 1, + datasourceId: this.id, + rawSql: 'SELECT 1', + format: 'table', + }, + ], + }, + }) + .pipe( + mapTo({ status: 'success', message: 'Database Connection OK' }), + catchError((err) => { + console.error(err); + if (err.data && err.data.message) { + return of({ status: 'error', message: err.data.message }); + } else { + return of({ status: 'error', message: err.status }); + } + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + let rawSql = ''; + + if (target.rawQuery) { + rawSql = target.rawSql; + } else { + const query = new MysqlQuery(target); + rawSql = query.buildQuery(); + } + + rawSql = rawSql.replace('$__', ''); + + return this.templateSrv.variableExists(rawSql); + } +} diff --git a/public/app/plugins/datasource/mysql/img/mysql_logo.svg b/public/app/plugins/datasource/mysql/img/mysql_logo.svg new file mode 100644 index 0000000..6d1d5c8 --- /dev/null +++ b/public/app/plugins/datasource/mysql/img/mysql_logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/mysql/meta_query.ts b/public/app/plugins/datasource/mysql/meta_query.ts new file mode 100644 index 0000000..eb03dc4 --- /dev/null +++ b/public/app/plugins/datasource/mysql/meta_query.ts @@ -0,0 +1,142 @@ +export class MysqlMetaQuery { + constructor(private target: any, private queryModel: any) {} + + getOperators(datatype: string) { + switch (datatype) { + case 'double': + case 'float': { + return ['=', '!=', '<', '<=', '>', '>=']; + } + case 'text': + case 'tinytext': + case 'mediumtext': + case 'longtext': + case 'varchar': + case 'char': { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE']; + } + default: { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN']; + } + } + } + + // quote identifier as literal to use in metadata queries + quoteIdentAsLiteral(value: string) { + return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value)); + } + + findMetricTable() { + // query that returns first table found that has a timestamp(tz) column and a float column + const query = ` + SELECT + table_name as table_name, + ( SELECT + column_name as column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN ('timestamp', 'datetime') + ORDER BY ordinal_position LIMIT 1 + ) AS time_column, + ( SELECT + column_name AS column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN('float', 'int', 'bigint') + ORDER BY ordinal_position LIMIT 1 + ) AS value_column + FROM information_schema.tables t + WHERE + t.table_schema = database() AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN ('timestamp', 'datetime') + ) AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + c.data_type IN('float', 'int', 'bigint') + ) + LIMIT 1 +;`; + return query; + } + + buildTableConstraint(table: string) { + let query = ''; + + // check for schema qualified table + if (table.includes('.')) { + const parts = table.split('.'); + query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]); + return query; + } else { + query = 'table_schema = database() AND table_name = ' + this.quoteIdentAsLiteral(table); + + return query; + } + } + + buildTableQuery() { + return 'SELECT table_name FROM information_schema.tables WHERE table_schema = database() ORDER BY table_name'; + } + + buildColumnQuery(type?: string) { + let query = 'SELECT column_name FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + + switch (type) { + case 'time': { + query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')"; + break; + } + case 'metric': { + query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; + break; + } + case 'value': { + query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')"; + query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn); + break; + } + case 'group': { + query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; + break; + } + } + + query += ' ORDER BY column_name'; + + return query; + } + + buildValueQuery(column: string) { + let query = 'SELECT DISTINCT QUOTE(' + column + ')'; + query += ' FROM ' + this.target.table; + query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')'; + query += ' ORDER BY 1 LIMIT 100'; + return query; + } + + buildDatatypeQuery(column: string) { + let query = ` +SELECT data_type +FROM information_schema.columns +WHERE `; + query += ' table_name = ' + this.quoteIdentAsLiteral(this.target.table); + query += ' AND column_name = ' + this.quoteIdentAsLiteral(column); + return query; + } +} diff --git a/public/app/plugins/datasource/mysql/module.ts b/public/app/plugins/datasource/mysql/module.ts new file mode 100644 index 0000000..f5181d3 --- /dev/null +++ b/public/app/plugins/datasource/mysql/module.ts @@ -0,0 +1,48 @@ +import { MysqlDatasource } from './datasource'; +import { MysqlQueryCtrl } from './query_ctrl'; +import { + createChangeHandler, + createResetHandler, + PasswordFieldEnum, +} from '../../../features/datasources/utils/passwordHandlers'; + +class MysqlConfigCtrl { + static templateUrl = 'partials/config.html'; + current: any; + onPasswordReset: ReturnType; + onPasswordChange: ReturnType; + + constructor() { + this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); + this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + } +} + +const defaultQuery = `SELECT + UNIX_TIMESTAMP() as time_sec, + as text, + as tags + FROM
      + WHERE $__timeFilter(time_column) + ORDER BY ASC + LIMIT 100 + `; + +class MysqlAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export { + MysqlDatasource, + MysqlDatasource as Datasource, + MysqlQueryCtrl as QueryCtrl, + MysqlConfigCtrl as ConfigCtrl, + MysqlAnnotationsQueryCtrl as AnnotationsQueryCtrl, +}; diff --git a/public/app/plugins/datasource/mysql/mysql_query.ts b/public/app/plugins/datasource/mysql/mysql_query.ts new file mode 100644 index 0000000..67c5f64 --- /dev/null +++ b/public/app/plugins/datasource/mysql/mysql_query.ts @@ -0,0 +1,235 @@ +import _ from 'lodash'; +import { TemplateSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +export default class MysqlQuery { + target: any; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(target: any, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + + target.format = target.format || 'time_series'; + target.timeColumn = target.timeColumn || 'time'; + target.metricColumn = target.metricColumn || 'none'; + + target.group = target.group || []; + target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }]; + target.select = target.select || [[{ type: 'column', params: ['value'] }]]; + + // handle pre query gui panels gracefully + if (!('rawQuery' in this.target)) { + if ('rawSql' in target) { + // pre query gui panel + target.rawQuery = true; + } else { + // new panel + target.rawQuery = false; + } + } + + // give interpolateQueryStr access to this + this.interpolateQueryStr = this.interpolateQueryStr.bind(this); + } + + // remove identifier quoting from identifier to use in metadata queries + unquoteIdentifier(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else { + return value; + } + } + + quoteIdentifier(value: string) { + return '"' + value.replace(/"/g, '""') + '"'; + } + + quoteLiteral(value: string) { + return "'" + value.replace(/'/g, "''") + "'"; + } + + escapeLiteral(value: any) { + return String(value).replace(/'/g, "''"); + } + + hasTimeGroup() { + return _.find(this.target.group, (g: any) => g.type === 'time'); + } + + hasMetricColumn() { + return this.target.metricColumn !== 'none'; + } + + interpolateQueryStr(value: string, variable: { multi: any; includeAll: any }, defaultFormatFn: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return this.escapeLiteral(value); + } + + if (typeof value === 'string') { + return this.quoteLiteral(value); + } + + const escapedValues = _.map(value, this.quoteLiteral); + return escapedValues.join(','); + } + + render(interpolate?: boolean) { + const target = this.target; + + // new query with no table set yet + if (!this.target.rawQuery && !('table' in this.target)) { + return ''; + } + + if (!target.rawQuery) { + target.rawSql = this.buildQuery(); + } + + if (interpolate) { + return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr); + } else { + return target.rawSql; + } + } + + hasUnixEpochTimecolumn() { + return ['int', 'bigint', 'double'].indexOf(this.target.timeColumnType) > -1; + } + + buildTimeColumn(alias = true) { + const timeGroup = this.hasTimeGroup(); + let query; + let macro = '$__timeGroup'; + + if (timeGroup) { + let args; + if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') { + args = timeGroup.params.join(','); + } else { + args = timeGroup.params[0]; + } + if (this.hasUnixEpochTimecolumn()) { + macro = '$__unixEpochGroup'; + } + if (alias) { + macro += 'Alias'; + } + query = macro + '(' + this.target.timeColumn + ',' + args + ')'; + } else { + query = this.target.timeColumn; + if (alias) { + query += ' AS "time"'; + } + } + + return query; + } + + buildMetricColumn() { + if (this.hasMetricColumn()) { + return this.target.metricColumn + ' AS metric'; + } + + return ''; + } + + buildValueColumns() { + let query = ''; + for (const column of this.target.select) { + query += ',\n ' + this.buildValueColumn(column); + } + + return query; + } + + buildValueColumn(column: any) { + let query = ''; + + const columnName: any = _.find(column, (g: any) => g.type === 'column'); + query = columnName.params[0]; + + const aggregate: any = _.find(column, (g: any) => g.type === 'aggregate'); + + if (aggregate) { + const func = aggregate.params[0]; + query = func + '(' + query + ')'; + } + + const alias: any = _.find(column, (g: any) => g.type === 'alias'); + if (alias) { + query += ' AS ' + this.quoteIdentifier(alias.params[0]); + } + + return query; + } + + buildWhereClause() { + let query = ''; + const conditions = _.map(this.target.where, (tag, index) => { + switch (tag.type) { + case 'macro': + return tag.name + '(' + this.target.timeColumn + ')'; + break; + case 'expression': + return tag.params.join(' '); + break; + } + }); + + if (conditions.length > 0) { + query = '\nWHERE\n ' + conditions.join(' AND\n '); + } + + return query; + } + + buildGroupClause() { + let query = ''; + let groupSection = ''; + + for (let i = 0; i < this.target.group.length; i++) { + const part = this.target.group[i]; + if (i > 0) { + groupSection += ', '; + } + if (part.type === 'time') { + groupSection += '1'; + } else { + groupSection += part.params[0]; + } + } + + if (groupSection.length) { + query = '\nGROUP BY ' + groupSection; + if (this.hasMetricColumn()) { + query += ',2'; + } + } + return query; + } + + buildQuery() { + let query = 'SELECT'; + + query += '\n ' + this.buildTimeColumn(); + if (this.hasMetricColumn()) { + query += ',\n ' + this.buildMetricColumn(); + } + query += this.buildValueColumns(); + + query += '\nFROM ' + this.target.table; + + query += this.buildWhereClause(); + query += this.buildGroupClause(); + + query += '\nORDER BY ' + this.buildTimeColumn(false); + + return query; + } +} diff --git a/public/app/plugins/datasource/mysql/partials/annotations.editor.html b/public/app/plugins/datasource/mysql/partials/annotations.editor.html new file mode 100644 index 0000000..a9530b0 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/annotations.editor.html @@ -0,0 +1,54 @@ +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      Annotation Query Format
      +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time or time_sec column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time or time_sec for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event end time. Use epoch time or any native date data type. +- column with alias: text for the annotation text +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2' + + +Macros: +- $__time(column) -> UNIX_TIMESTAMP(column) as time (or as time_sec) +- $__timeEpoch(column) -> UNIX_TIMESTAMP(column) as time (or as time_sec) +- $__timeFilter(column) -> column BETWEEN FROM_UNIXTIME(1492750877) AND FROM_UNIXTIME(1492750877) +- $__unixEpochFilter(column) -> time_unix_epoch > 1492750877 AND time_unix_epoch < 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> FROM_UNIXTIME(1492750877) +- $__timeTo() -> FROM_UNIXTIME(1492750877) +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
      +
      +
      diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html new file mode 100644 index 0000000..b9455b1 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/config.html @@ -0,0 +1,113 @@ +

      MySQL Connection

      + +
      +
      + Host + +
      + +
      + Database + +
      + +
      +
      + User + +
      +
      + +
      +
      + +
      +
      + + +
      +
      + +
      +
      + + + + +Connection limits + +
      +
      + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
      +
      + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
      +
      + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever.

      + This should always be lower than configured wait_timeout in MySQL. +
      +
      +
      + +

      MySQL details

      + +
      +
      +
      + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
      +
      +
      + +
      +
      +
      User Permission
      +

      + The database user should only be granted SELECT permissions on the specified database & tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like USE otherdb; and DROP TABLE user; would be executed. To protect against this we + Highly recommmend you create a specific MySQL user with restricted permissions. + + Checkout the MySQL Data Source Docs for more information. +

      +
      +
      diff --git a/public/app/plugins/datasource/mysql/partials/query.editor.html b/public/app/plugins/datasource/mysql/partials/query.editor.html new file mode 100644 index 0000000..a04ea78 --- /dev/null +++ b/public/app/plugins/datasource/mysql/partials/query.editor.html @@ -0,0 +1,190 @@ + + +
      +
      +
      + + +
      +
      +
      + +
      +
      +
      + + + + + + + + +
      + +
      +
      +
      + +
      + +
      +
      + +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      +
      + +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      + +
      + +
      +
      + + + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      + +
      +
      + +
      + +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      Time series:
      +- return column named time or time_sec (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below.
      +- return column(s) with numeric datatype as values
      +Optional:
      +  - return column named metric to represent the series name.
      +  - If multiple value columns are returned the metric column is used as prefix.
      +  - If no column named metric is found the column name of the value column is used as series name
      +
      +Resultsets of time series queries need to be sorted by time.
      +
      +Table:
      +- return any set of columns
      +
      +Macros:
      +- $__time(column) -> UNIX_TIMESTAMP(column) as time_sec
      +- $__timeEpoch(column) -> UNIX_TIMESTAMP(column) as time_sec
      +- $__timeFilter(column) -> column BETWEEN FROM_UNIXTIME(1492750877) AND FROM_UNIXTIME(1492750877)
      +- $__unixEpochFilter(column) ->  time_unix_epoch > 1492750877 AND time_unix_epoch < 1492750877
      +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
      +- $__timeGroup(column,'5m'[, fillvalue]) -> cast(cast(UNIX_TIMESTAMP(column)/(300) as signed)*300 as signed)
      +     by setting fillvalue grafana will fill in missing values according to the interval
      +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
      +- $__timeGroupAlias(column,'5m') -> cast(cast(UNIX_TIMESTAMP(column)/(300) as signed)*300 as signed) AS "time"
      +- $__unixEpochGroup(column,'5m') -> column DIV 300 * 300
      +- $__unixEpochGroupAlias(column,'5m') -> column DIV 300 * 300 AS "time"
      +
      +Example of group by and order by with $__timeGroup:
      +SELECT
      +  $__timeGroupAlias(timestamp_col, '1h'),
      +  sum(value_double) as value
      +FROM yourtable
      +GROUP BY 1
      +ORDER BY 1
      +
      +Or build your own conditionals using these macros which just return the values:
      +- $__timeFrom() -> FROM_UNIXTIME(1492750877)
      +- $__timeTo() ->  FROM_UNIXTIME(1492750877)
      +- $__unixEpochFrom() ->  1492750877
      +- $__unixEpochTo() ->  1492750877
      +- $__unixEpochNanoFrom() ->  1494410783152415214
      +- $__unixEpochNanoTo() ->  1494497183142514872
      +    
      +
      + +
      + +
      +
      {{ctrl.lastQueryMeta.executedQueryString}}
      +
      + +
      +
      {{ctrl.lastQueryError}}
      +
      + + diff --git a/public/app/plugins/datasource/mysql/plugin.json b/public/app/plugins/datasource/mysql/plugin.json new file mode 100644 index 0000000..2918371 --- /dev/null +++ b/public/app/plugins/datasource/mysql/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "MySQL", + "id": "mysql", + "category": "sql", + + "info": { + "description": "Data source for MySQL databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/mysql_logo.svg", + "large": "img/mysql_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/mysql/query_ctrl.ts b/public/app/plugins/datasource/mysql/query_ctrl.ts new file mode 100644 index 0000000..f146a7a --- /dev/null +++ b/public/app/plugins/datasource/mysql/query_ctrl.ts @@ -0,0 +1,634 @@ +import _ from 'lodash'; +import appEvents from 'app/core/app_events'; +import { MysqlMetaQuery } from './meta_query'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { SqlPart } from 'app/core/components/sql_part/sql_part'; +import MysqlQuery from './mysql_query'; +import sqlPart from './sql_part'; +import { auto } from 'angular'; +import { CoreEvents } from 'app/types'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { TemplateSrv } from '@grafana/runtime'; + +const defaultQuery = `SELECT + UNIX_TIMESTAMP() as time_sec, + as value, + as metric +FROM
      +WHERE $__timeFilter(time_column) +ORDER BY ASC +`; + +export class MysqlQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + lastQueryError?: string; + showHelp!: boolean; + + queryModel: MysqlQuery; + metaBuilder: MysqlMetaQuery; + lastQueryMeta?: QueryResultMeta; + tableSegment: any; + whereAdd: any; + timeColumnSegment: any; + metricColumnSegment: any; + selectMenu: any[] = []; + selectParts: SqlPart[][] = []; + groupParts: SqlPart[] = []; + whereParts: SqlPart[] = []; + groupAdd: any; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private templateSrv: TemplateSrv, + private uiSegmentSrv: any + ) { + super($scope, $injector); + + this.target = this.target; + this.queryModel = new MysqlQuery(this.target, templateSrv, this.panel.scopedVars); + this.metaBuilder = new MysqlMetaQuery(this.target, this.queryModel); + this.updateProjection(); + + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + this.target.rawQuery = true; + } else { + this.target.rawSql = defaultQuery; + this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then((result: any) => { + if (result.length > 0) { + this.target.table = result[0].text; + let segment = this.uiSegmentSrv.newSegment(this.target.table); + this.tableSegment.html = segment.html; + this.tableSegment.value = segment.value; + + this.target.timeColumn = result[1].text; + segment = this.uiSegmentSrv.newSegment(this.target.timeColumn); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + + this.target.timeColumnType = 'timestamp'; + this.target.select = [[{ type: 'column', params: [result[2].text] }]]; + this.updateProjection(); + this.updateRawSqlAndRefresh(); + } + }); + } + } + + if (!this.target.table) { + this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true }); + } else { + this.tableSegment = uiSegmentSrv.newSegment(this.target.table); + } + + this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn); + this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn); + + this.buildSelectMenu(); + this.whereAdd = this.uiSegmentSrv.newPlusButton(); + this.groupAdd = this.uiSegmentSrv.newPlusButton(); + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + updateRawSqlAndRefresh() { + if (!this.target.rawQuery) { + this.target.rawSql = this.queryModel.buildQuery(); + } + + this.panelCtrl.refresh(); + } + + updateProjection() { + this.selectParts = _.map(this.target.select, (parts: any) => { + return _.map(parts, sqlPart.create).filter((n) => n); + }); + this.whereParts = _.map(this.target.where, sqlPart.create).filter((n) => n); + this.groupParts = _.map(this.target.group, sqlPart.create).filter((n) => n); + } + + updatePersistedParts() { + this.target.select = _.map(this.selectParts, (selectParts) => { + return _.map(selectParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + }); + this.target.where = _.map(this.whereParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params }; + }); + this.target.group = _.map(this.groupParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + } + + buildSelectMenu() { + const aggregates = { + text: 'Aggregate Functions', + value: 'aggregate', + submenu: [ + { text: 'Average', value: 'avg' }, + { text: 'Count', value: 'count' }, + { text: 'Maximum', value: 'max' }, + { text: 'Minimum', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Standard deviation', value: 'stddev' }, + { text: 'Variance', value: 'variance' }, + ], + }; + + this.selectMenu.push(aggregates); + this.selectMenu.push({ text: 'Alias', value: 'alias' }); + this.selectMenu.push({ text: 'Column', value: 'column' }); + } + + toggleEditorMode() { + if (this.target.rawQuery) { + appEvents.emit(CoreEvents.showConfirmModal, { + title: 'Warning', + text2: 'Switching to query builder may overwrite your raw SQL.', + icon: 'exclamation-triangle', + yesText: 'Switch', + onConfirm: () => { + this.target.rawQuery = !this.target.rawQuery; + }, + }); + } else { + this.target.rawQuery = !this.target.rawQuery; + } + } + + resetPlusButton(button: { html: any; value: any }) { + const plusButton = this.uiSegmentSrv.newPlusButton(); + button.html = plusButton.html; + button.value = plusButton.value; + } + + getTableSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildTableQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + tableChanged() { + this.target.table = this.tableSegment.value; + this.target.where = []; + this.target.group = []; + this.updateProjection(); + + const segment = this.uiSegmentSrv.newSegment('none'); + this.metricColumnSegment.html = segment.html; + this.metricColumnSegment.value = segment.value; + this.target.metricColumn = 'none'; + + const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then((result: any) => { + // check if time column is still valid + if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) { + const segment = this.uiSegmentSrv.newSegment(result[0].text); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + } + return this.timeColumnChanged(false); + }); + const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then((result: any) => { + if (result.length > 0) { + this.target.select = [[{ type: 'column', params: [result[0].text] }]]; + this.updateProjection(); + } + }); + + Promise.all([task1, task2]).then(() => { + this.updateRawSqlAndRefresh(); + }); + } + + getTimeColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('time')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + timeColumnChanged(refresh?: boolean) { + this.target.timeColumn = this.timeColumnSegment.value; + return this.datasource + .metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)) + .then((result: any) => { + if (result.length === 1) { + if (this.target.timeColumnType !== result[0].text) { + this.target.timeColumnType = result[0].text; + } + let partModel; + if (this.queryModel.hasUnixEpochTimecolumn()) { + partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] }); + } else { + partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] }); + } + + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + } + + this.updatePersistedParts(); + if (refresh !== false) { + this.updateRawSqlAndRefresh(); + } + }); + } + + getMetricColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('metric')) + .then(this.transformToSegments({ addNone: true })) + .catch(this.handleQueryError.bind(this)); + } + + metricColumnChanged() { + this.target.metricColumn = this.metricColumnSegment.value; + this.updateRawSqlAndRefresh(); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } + + transformToSegments(config: any) { + return (results: any) => { + const segments = _.map(results, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (config.addTemplateVars) { + for (const variable of this.templateSrv.getVariables()) { + let value; + value = '$' + variable.name; + if (config.templateQuoter && ((variable as unknown) as VariableWithMultiSupport).multi === false) { + value = config.templateQuoter(value); + } + + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: value, + expandable: true, + }) + ); + } + } + + if (config.addNone) { + segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true })); + } + + return segments; + }; + } + + findAggregateIndex(selectParts: any) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile'); + } + + findWindowIndex(selectParts: any) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window'); + } + + addSelectPart(selectParts: any[], item: { value: any }, subItem: { type: any; value: any }) { + let partType = item.value; + if (subItem && subItem.type) { + partType = subItem.type; + } + let partModel = sqlPart.create({ type: partType }); + if (subItem) { + partModel.params[0] = subItem.value; + } + let addAlias = false; + + switch (partType) { + case 'column': + const parts = _.map(selectParts, (part: any) => { + return sqlPart.create({ type: part.def.type, params: _.clone(part.params) }); + }); + this.selectParts.push(parts); + break; + case 'percentile': + case 'aggregate': + // add group by if no group by yet + if (this.target.group.length === 0) { + this.addGroup('time', '$__interval'); + } + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + // replace current aggregation + selectParts[aggIndex] = partModel; + } else { + selectParts.splice(1, 0, partModel); + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'moving_window': + case 'window': + const windowIndex = this.findWindowIndex(selectParts); + if (windowIndex !== -1) { + // replace current window function + selectParts[windowIndex] = partModel; + } else { + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + selectParts.splice(aggIndex + 1, 0, partModel); + } else { + selectParts.splice(1, 0, partModel); + } + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'alias': + addAlias = true; + break; + } + + if (addAlias) { + // set initial alias name to column name + partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] }); + if (selectParts[selectParts.length - 1].def.type === 'alias') { + selectParts[selectParts.length - 1] = partModel; + } else { + selectParts.push(partModel); + } + } + + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + } + + removeSelectPart(selectParts: any, part: { def: { type: string } }) { + if (part.def.type === 'column') { + // remove all parts of column unless its last column + if (this.selectParts.length > 1) { + const modelsIndex = _.indexOf(this.selectParts, selectParts); + this.selectParts.splice(modelsIndex, 1); + } + } else { + const partIndex = _.indexOf(selectParts, part); + selectParts.splice(partIndex, 1); + } + + this.updatePersistedParts(); + } + + handleSelectPartEvent(selectParts: any, part: { def: any }, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + switch (part.def.type) { + // case 'aggregate': + // return this.datasource + // .metricFindQuery(this.metaBuilder.buildAggregateQuery()) + // .then(this.transformToSegments({})) + // .catch(this.handleQueryError.bind(this)); + case 'column': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('value')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeSelectPart(selectParts, part); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + handleGroupPartEvent(part: any, index: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeGroup(part, index); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + addGroup(partType: string, value: string) { + let params = [value]; + if (partType === 'time') { + params = ['$__interval', 'none']; + } + const partModel = sqlPart.create({ type: partType, params: params }); + + if (partType === 'time') { + // put timeGroup at start + this.groupParts.splice(0, 0, partModel); + } else { + this.groupParts.push(partModel); + } + + // add aggregates when adding group by + for (const selectParts of this.selectParts) { + if (!selectParts.some((part) => part.def.type === 'aggregate')) { + const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] }); + selectParts.splice(1, 0, aggregate); + if (!selectParts.some((part) => part.def.type === 'alias')) { + const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] }); + selectParts.push(alias); + } + } + } + + this.updatePersistedParts(); + } + + removeGroup(part: { def: { type: string } }, index: number) { + if (part.def.type === 'time') { + // remove aggregations + this.selectParts = _.map(this.selectParts, (s: any) => { + return _.filter(s, (part: any) => { + if (part.def.type === 'aggregate' || part.def.type === 'percentile') { + return false; + } + return true; + }); + }); + } + + this.groupParts.splice(index, 1); + this.updatePersistedParts(); + } + + handleWherePartEvent(whereParts: any, part: any, evt: any, index: any) { + switch (evt.name) { + case 'get-param-options': { + switch (evt.param.name) { + case 'left': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'right': + if (['int', 'bigint', 'double', 'datetime'].indexOf(part.datatype) > -1) { + // don't do value lookups for numerical fields + return Promise.resolve([]); + } else { + return this.datasource + .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0])) + .then( + this.transformToSegments({ + addTemplateVars: true, + templateQuoter: (v: string) => { + return this.queryModel.quoteLiteral(v); + }, + }) + ) + .catch(this.handleQueryError.bind(this)); + } + case 'op': + return Promise.resolve(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype))); + default: + return Promise.resolve([]); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => { + if (d.length === 1) { + part.datatype = d[0].text; + } + }); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + // remove element + whereParts.splice(index, 1); + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + getWhereOptions() { + const options = []; + if (this.queryModel.hasUnixEpochTimecolumn()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' })); + } else { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' })); + } + options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' })); + return Promise.resolve(options); + } + + addWhereAction(part: any, index: number) { + switch (this.whereAdd.type) { + case 'macro': { + const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] }); + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + break; + } + default: { + this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] })); + } + } + + this.updatePersistedParts(); + this.resetPlusButton(this.whereAdd); + this.updateRawSqlAndRefresh(); + } + + getGroupOptions() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('group')) + .then((tags: any) => { + const options = []; + if (!this.queryModel.hasTimeGroup()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + addGroupAction() { + switch (this.groupAdd.value) { + default: { + this.addGroup(this.groupAdd.type, this.groupAdd.value); + } + } + + this.resetPlusButton(this.groupAdd); + this.updateRawSqlAndRefresh(); + } + + handleQueryError(err: any): any[] { + this.error = err.message || 'Failed to issue metric query'; + return []; + } +} diff --git a/public/app/plugins/datasource/mysql/response_parser.ts b/public/app/plugins/datasource/mysql/response_parser.ts new file mode 100644 index 0000000..c02ffa5 --- /dev/null +++ b/public/app/plugins/datasource/mysql/response_parser.ts @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import { MysqlMetricFindValue } from './types'; + +interface TableResponse extends Record { + type: string; + refId: string; + meta: any; +} + +interface SeriesResponse extends Record { + target: string; + refId: string; + meta: any; + datapoints: [any[]]; +} + +export interface MysqlResponse { + data: Array; +} + +export default class ResponseParser { + processQueryResult(res: any): MysqlResponse { + const data: any[] = []; + + if (!res.data.results) { + return { data: data }; + } + + for (const key in res.data.results) { + const queryRes = res.data.results[key]; + + if (queryRes.series) { + for (const series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + refId: queryRes.refId, + meta: queryRes.meta, + }); + } + } + + if (queryRes.tables) { + for (const table of queryRes.tables) { + table.type = 'table'; + table.refId = queryRes.refId; + table.meta = queryRes.meta; + data.push(table); + } + } + } + + return { data: data }; + } + + parseMetricFindQueryResult(refId: string, results: any): MysqlMetricFindValue[] { + if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { + return []; + } + + const columns = results.data.results[refId].tables[0].columns; + const rows = results.data.results[refId].tables[0].rows; + const textColIndex = this.findColIndex(columns, '__text'); + const valueColIndex = this.findColIndex(columns, '__value'); + + if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) { + return this.transformToKeyValueList(rows, textColIndex, valueColIndex); + } + + return this.transformToSimpleList(rows); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ + text: rows[i][textColIndex], + value: rows[i][valueColIndex], + }); + } + } + + return res; + } + + transformToSimpleList(rows: any) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return _.map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any[], key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + transformAnnotationResponse(options: any, data: any) { + const table = data.data.results[options.annotation.name].tables[0]; + + let timeColumnIndex = -1; + let timeEndColumnIndex = -1; + let textColumnIndex = -1; + let tagsColumnIndex = -1; + + for (let i = 0; i < table.columns.length; i++) { + if (table.columns[i].text === 'time_sec' || table.columns[i].text === 'time') { + timeColumnIndex = i; + } else if (table.columns[i].text === 'timeend') { + timeEndColumnIndex = i; + } else if (table.columns[i].text === 'title') { + throw { + message: 'The title column for annotations is deprecated, now only a column named text is returned', + }; + } else if (table.columns[i].text === 'text') { + textColumnIndex = i; + } else if (table.columns[i].text === 'tags') { + tagsColumnIndex = i; + } + } + + if (timeColumnIndex === -1) { + throw { + message: 'Missing mandatory time column (with time_sec column alias) in annotation query.', + }; + } + + const list = []; + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]; + const timeEnd = + timeEndColumnIndex !== -1 && row[timeEndColumnIndex] ? Math.floor(row[timeEndColumnIndex]) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(row[timeColumnIndex]), + timeEnd, + text: row[textColumnIndex] ? row[textColumnIndex].toString() : '', + tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/mysql/specs/datasource.test.ts b/public/app/plugins/datasource/mysql/specs/datasource.test.ts new file mode 100644 index 0000000..a36e5b9 --- /dev/null +++ b/public/app/plugins/datasource/mysql/specs/datasource.test.ts @@ -0,0 +1,368 @@ +import { of } from 'rxjs'; +import { dateTime, toUtc } from '@grafana/data'; + +import { MysqlDatasource } from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { FetchResponse } from '@grafana/runtime'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +describe('MySQLDatasource', () => { + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + const setupTextContext = (response: any) => { + const instanceSettings = { name: 'mysql' }; + const templateSrv: TemplateSrv = new TemplateSrv(); + const raw = { + from: toUtc('2018-04-25 10:00'), + to: toUtc('2018-04-25 11:00'), + }; + const timeSrvMock: any = { + timeRange: () => ({ + from: raw.from, + to: raw.to, + raw: raw, + }), + }; + const variable = { ...initialCustomVariableModelState }; + + jest.clearAllMocks(); + fetchMock.mockImplementation((options) => of(createFetchResponse(response))); + + const ds = new MysqlDatasource(instanceSettings, templateSrv, timeSrvMock); + + return { ds, variable, templateSrv }; + }; + + describe('When performing annotationQuery', () => { + const annotationName = 'MyAnno'; + + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time_sec, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + + const response = { + results: { + MyAnno: { + refId: annotationName, + tables: [ + { + columns: [{ text: 'time_sec' }, { text: 'text' }, { text: 'tags' }], + rows: [ + [1432288355, 'some text', 'TagA,TagB'], + [1432288390, 'some text2', ' TagB , TagC'], + [1432288400, 'some text3'], + ], + }, + ], + }, + }, + }; + + it('should return annotation list', async () => { + const { ds } = setupTextContext(response); + const results = await ds.annotationQuery(options); + + expect(results.length).toBe(3); + + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' }); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe( + "select title from atable where title LIKE 'aTit%'" + ); + expect(results.length).toBe(6); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + it('should return list of all column values', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'"); + expect(results.length).toBe(6); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__value' }, { text: '__text' }], + rows: [ + ['value1', 'aTitle'], + ['value2', 'aTitle2'], + ['value3', 'aTitle3'], + ], + }, + ], + }, + }, + }; + + it('should return list of as text, value', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(3); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('value1'); + expect(results[2].text).toBe('aTitle3'); + expect(results[2].value).toBe('value3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__text' }, { text: '__value' }], + rows: [ + ['aTitle', 'same'], + ['aTitle', 'same'], + ['aTitle', 'diff'], + ], + }, + ], + }, + }, + }; + + it('should return list of unique keys', async () => { + const { ds } = setupTextContext(response); + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(1); + expect(results[0].text).toBe('aTitle'); + expect(results[0].value).toBe('same'); + }); + }); + + describe('When interpolating variables', () => { + describe('and value is a string', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable('abc', variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable(1000, variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + const { ds, variable } = setupTextContext({}); + expect(ds.interpolateVariable(['a', 'b', 'c'], variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and value is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.multi = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.multi = true; + expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'"); + }); + }); + + describe('and variable allows all and value is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTextContext({}); + variable.includeAll = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const { ds, templateSrv } = setupTextContext({}); + const rawSql = `SELECT + $__timeGroup(createdAt,'$summarize') as time_sec, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY 1, 3 + ORDER BY 1`; + const query = { + rawSql, + rawQuery: true, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const { ds, templateSrv } = setupTextContext({}); + const rawSql = `SELECT + $__timeGroup(createdAt,'$__interval') as time_sec, + avg(value) as value, + hostname as metric + FROM + grafana_metric + WHERE + $__timeFilter(createdAt) AND + measurement = 'logins.count' + GROUP BY 1, 3 + ORDER BY 1`; + const query = { + rawSql, + rawQuery: true, + }; + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + expect(ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); + +const createFetchResponse = (data: T): FetchResponse => ({ + data, + status: 200, + url: 'http://localhost:3000/api/query', + config: { url: 'http://localhost:3000/api/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: ({} as unknown) as Headers, + ok: true, +}); diff --git a/public/app/plugins/datasource/mysql/sql_part.ts b/public/app/plugins/datasource/mysql/sql_part.ts new file mode 100644 index 0000000..2b6bd66 --- /dev/null +++ b/public/app/plugins/datasource/mysql/sql_part.ts @@ -0,0 +1,86 @@ +import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part'; + +const index: any[] = []; + +function createPart(part: any): any { + const def = index[part.type]; + if (!def) { + return null; + } + + return new SqlPart(part, def); +} + +function register(options: any) { + index[options.type] = new SqlPartDef(options); +} + +register({ + type: 'column', + style: 'label', + params: [{ type: 'column', dynamicLookup: true }], + defaultParams: ['value'], +}); + +register({ + type: 'expression', + style: 'expression', + label: 'Expr:', + params: [ + { name: 'left', type: 'string', dynamicLookup: true }, + { name: 'op', type: 'string', dynamicLookup: true }, + { name: 'right', type: 'string', dynamicLookup: true }, + ], + defaultParams: ['value', '=', 'value'], +}); + +register({ + type: 'macro', + style: 'label', + label: 'Macro:', + params: [], + defaultParams: [], +}); + +register({ + type: 'aggregate', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'], + }, + ], + defaultParams: ['avg'], +}); + +register({ + type: 'alias', + style: 'label', + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], +}); + +register({ + type: 'time', + style: 'function', + label: 'time', + params: [ + { + name: 'interval', + type: 'interval', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + { + name: 'fill', + type: 'string', + options: ['none', 'NULL', 'previous', '0'], + }, + ], + defaultParams: ['$__interval', 'none'], +}); + +export default { + create: createPart, +}; diff --git a/public/app/plugins/datasource/mysql/types.ts b/public/app/plugins/datasource/mysql/types.ts new file mode 100644 index 0000000..e97ab56 --- /dev/null +++ b/public/app/plugins/datasource/mysql/types.ts @@ -0,0 +1,13 @@ +import { MetricFindValue } from '@grafana/data'; + +export interface MysqlQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId?: any; + hide?: any; +} + +export interface MysqlMetricFindValue extends MetricFindValue { + value?: string; +} diff --git a/public/app/plugins/datasource/opentsdb/README.md b/public/app/plugins/datasource/opentsdb/README.md new file mode 100644 index 0000000..4afd5dc --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/README.md @@ -0,0 +1,7 @@ +# OpenTSDB Data Source - Native Plugin + +Grafana ships with **built in** support for OpenTSDB, a scalable, distributed time series database. + +Read more about it here: + +[http://docs.grafana.org/datasources/opentsdb/](http://docs.grafana.org/datasources/opentsdb/) diff --git a/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx new file mode 100644 index 0000000..dc0bc71 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/ConfigEditor.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { OpenTsdbDetails } from './OpenTsdbDetails'; +import { OpenTsdbOptions } from '../types'; + +export const ConfigEditor = (props: DataSourcePluginOptionsEditorProps) => { + const { options, onOptionsChange } = props; + + return ( + <> + + + + ); +}; diff --git a/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx b/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx new file mode 100644 index 0000000..0457fe6 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/components/OpenTsdbDetails.tsx @@ -0,0 +1,82 @@ +import React, { SyntheticEvent } from 'react'; +import { InlineFormLabel, LegacyForms } from '@grafana/ui'; +const { Select, Input } = LegacyForms; +import { DataSourceSettings, SelectableValue } from '@grafana/data'; +import { OpenTsdbOptions } from '../types'; + +const tsdbVersions = [ + { label: '<=2.1', value: 1 }, + { label: '==2.2', value: 2 }, + { label: '==2.3', value: 3 }, +]; + +const tsdbResolutions = [ + { label: 'second', value: 1 }, + { label: 'millisecond', value: 2 }, +]; + +interface Props { + value: DataSourceSettings; + onChange: (value: DataSourceSettings) => void; +} + +export const OpenTsdbDetails = (props: Props) => { + const { onChange, value } = props; + + return ( + <> +
      OpenTSDB settings
      +
      + Version + resolution.value === value.jsonData.tsdbResolution) ?? + tsdbResolutions[0] + } + onChange={onSelectChangeHandler('tsdbResolution', value, onChange)} + /> +
      +
      + Lookup Limit + +
      + + ); +}; + +const onSelectChangeHandler = (key: keyof OpenTsdbOptions, value: Props['value'], onChange: Props['onChange']) => ( + newValue: SelectableValue +) => { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + [key]: newValue.value, + }, + }); +}; + +const onInputChangeHandler = (key: keyof OpenTsdbOptions, value: Props['value'], onChange: Props['onChange']) => ( + event: SyntheticEvent +) => { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + [key]: event.currentTarget.value, + }, + }); +}; diff --git a/public/app/plugins/datasource/opentsdb/datasource.d.ts b/public/app/plugins/datasource/opentsdb/datasource.d.ts new file mode 100644 index 0000000..cd73e95 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/datasource.d.ts @@ -0,0 +1,2 @@ +declare var OpenTsDatasource: any; +export default OpenTsDatasource; diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts new file mode 100644 index 0000000..59847c9 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -0,0 +1,559 @@ +import angular from 'angular'; +import _ from 'lodash'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { FetchResponse, getBackendSrv } from '@grafana/runtime'; +import { + AnnotationEvent, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + dateMath, + ScopedVars, +} from '@grafana/data'; + +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { OpenTsdbOptions, OpenTsdbQuery } from './types'; + +export default class OpenTsDatasource extends DataSourceApi { + type: any; + url: any; + name: any; + withCredentials: any; + basicAuth: any; + tsdbVersion: any; + tsdbResolution: any; + lookupLimit: any; + tagKeys: any; + + aggregatorsPromise: any; + filterTypesPromise: any; + + constructor(instanceSettings: any, private readonly templateSrv: TemplateSrv = getTemplateSrv()) { + super(instanceSettings); + this.type = 'opentsdb'; + this.url = instanceSettings.url; + this.name = instanceSettings.name; + this.withCredentials = instanceSettings.withCredentials; + this.basicAuth = instanceSettings.basicAuth; + instanceSettings.jsonData = instanceSettings.jsonData || {}; + this.tsdbVersion = instanceSettings.jsonData.tsdbVersion || 1; + this.tsdbResolution = instanceSettings.jsonData.tsdbResolution || 1; + this.lookupLimit = instanceSettings.jsonData.lookupLimit || 1000; + this.tagKeys = {}; + + this.aggregatorsPromise = null; + this.filterTypesPromise = null; + } + + // Called once per panel (graph) + query(options: DataQueryRequest): Observable { + const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); + const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); + const qs: any[] = []; + + _.each(options.targets, (target) => { + if (!target.metric) { + return; + } + qs.push(this.convertTargetToQuery(target, options, this.tsdbVersion)); + }); + + const queries = _.compact(qs); + + // No valid targets, return the empty result to save a round trip. + if (_.isEmpty(queries)) { + return of({ data: [] }); + } + + const groupByTags: any = {}; + _.each(queries, (query) => { + if (query.filters && query.filters.length > 0) { + _.each(query.filters, (val) => { + groupByTags[val.tagk] = true; + }); + } else { + _.each(query.tags, (val, key) => { + groupByTags[key] = true; + }); + } + }); + + options.targets = _.filter(options.targets, (query) => { + return query.hide !== true; + }); + + return this.performTimeSeriesQuery(queries, start, end).pipe( + map((response) => { + const metricToTargetMapping = this.mapMetricsToTargets(response.data, options, this.tsdbVersion); + const result = _.map(response.data, (metricData: any, index: number) => { + index = metricToTargetMapping[index]; + if (index === -1) { + index = 0; + } + this._saveTagKeys(metricData); + + return this.transformMetricData( + metricData, + groupByTags, + options.targets[index], + options, + this.tsdbResolution + ); + }); + return { data: result }; + }) + ); + } + + annotationQuery(options: any): Promise { + const start = this.convertToTSDBTime(options.rangeRaw.from, false, options.timezone); + const end = this.convertToTSDBTime(options.rangeRaw.to, true, options.timezone); + const qs = []; + const eventList: any[] = []; + + qs.push({ aggregator: 'sum', metric: options.annotation.target }); + + const queries = _.compact(qs); + + return this.performTimeSeriesQuery(queries, start, end) + .pipe( + map((results) => { + if (results.data[0]) { + let annotationObject = results.data[0].annotations; + if (options.annotation.isGlobal) { + annotationObject = results.data[0].globalAnnotations; + } + if (annotationObject) { + _.each(annotationObject, (annotation) => { + const event = { + text: annotation.description, + time: Math.floor(annotation.startTime) * 1000, + annotation: options.annotation, + }; + + eventList.push(event); + }); + } + } + return eventList; + }) + ) + .toPromise(); + } + + targetContainsTemplate(target: any) { + if (target.filters && target.filters.length > 0) { + for (let i = 0; i < target.filters.length; i++) { + if (this.templateSrv.variableExists(target.filters[i].filter)) { + return true; + } + } + } + + if (target.tags && Object.keys(target.tags).length > 0) { + for (const tagKey in target.tags) { + if (this.templateSrv.variableExists(target.tags[tagKey])) { + return true; + } + } + } + + return false; + } + + performTimeSeriesQuery(queries: any[], start: any, end: any): Observable { + let msResolution = false; + if (this.tsdbResolution === 2) { + msResolution = true; + } + const reqBody: any = { + start: start, + queries: queries, + msResolution: msResolution, + globalAnnotations: true, + }; + if (this.tsdbVersion === 3) { + reqBody.showQuery = true; + } + + // Relative queries (e.g. last hour) don't include an end time + if (end) { + reqBody.end = end; + } + + const options = { + method: 'POST', + url: this.url + '/api/query', + data: reqBody, + }; + + this._addCredentialOptions(options); + return getBackendSrv().fetch(options); + } + + suggestTagKeys(metric: string | number) { + return Promise.resolve(this.tagKeys[metric] || []); + } + + _saveTagKeys(metricData: { tags: {}; aggregateTags: any; metric: string | number }) { + const tagKeys = Object.keys(metricData.tags); + _.each(metricData.aggregateTags, (tag) => { + tagKeys.push(tag); + }); + + this.tagKeys[metricData.metric] = tagKeys; + } + + _performSuggestQuery(query: string, type: string): Observable { + return this._get('/api/suggest', { type, q: query, max: this.lookupLimit }).pipe( + map((result: any) => { + return result.data; + }) + ); + } + + _performMetricKeyValueLookup(metric: string, keys: any): Observable { + if (!metric || !keys) { + return of([]); + } + + const keysArray = keys.split(',').map((key: any) => { + return key.trim(); + }); + const key = keysArray[0]; + let keysQuery = key + '=*'; + + if (keysArray.length > 1) { + keysQuery += ',' + keysArray.splice(1).join(','); + } + + const m = metric + '{' + keysQuery + '}'; + + return this._get('/api/search/lookup', { m: m, limit: this.lookupLimit }).pipe( + map((result: any) => { + result = result.data.results; + const tagvs: any[] = []; + _.each(result, (r) => { + if (tagvs.indexOf(r.tags[key]) === -1) { + tagvs.push(r.tags[key]); + } + }); + return tagvs; + }) + ); + } + + _performMetricKeyLookup(metric: any): Observable { + if (!metric) { + return of([]); + } + + return this._get('/api/search/lookup', { m: metric, limit: 1000 }).pipe( + map((result: any) => { + result = result.data.results; + const tagks: any[] = []; + _.each(result, (r) => { + _.each(r.tags, (tagv, tagk) => { + if (tagks.indexOf(tagk) === -1) { + tagks.push(tagk); + } + }); + }); + return tagks; + }) + ); + } + + _get( + relativeUrl: string, + params?: { type?: string; q?: string; max?: number; m?: any; limit?: number } + ): Observable { + const options = { + method: 'GET', + url: this.url + relativeUrl, + params: params, + }; + + this._addCredentialOptions(options); + + return getBackendSrv().fetch(options); + } + + _addCredentialOptions(options: any) { + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + if (this.basicAuth) { + options.headers = { Authorization: this.basicAuth }; + } + } + + metricFindQuery(query: string) { + if (!query) { + return Promise.resolve([]); + } + + let interpolated; + try { + interpolated = this.templateSrv.replace(query, {}, 'distributed'); + } catch (err) { + return Promise.reject(err); + } + + const responseTransform = (result: any) => { + return _.map(result, (value) => { + return { text: value }; + }); + }; + + const metricsRegex = /metrics\((.*)\)/; + const tagNamesRegex = /tag_names\((.*)\)/; + const tagValuesRegex = /tag_values\((.*?),\s?(.*)\)/; + const tagNamesSuggestRegex = /suggest_tagk\((.*)\)/; + const tagValuesSuggestRegex = /suggest_tagv\((.*)\)/; + + const metricsQuery = interpolated.match(metricsRegex); + if (metricsQuery) { + return this._performSuggestQuery(metricsQuery[1], 'metrics').pipe(map(responseTransform)).toPromise(); + } + + const tagNamesQuery = interpolated.match(tagNamesRegex); + if (tagNamesQuery) { + return this._performMetricKeyLookup(tagNamesQuery[1]).pipe(map(responseTransform)).toPromise(); + } + + const tagValuesQuery = interpolated.match(tagValuesRegex); + if (tagValuesQuery) { + return this._performMetricKeyValueLookup(tagValuesQuery[1], tagValuesQuery[2]) + .pipe(map(responseTransform)) + .toPromise(); + } + + const tagNamesSuggestQuery = interpolated.match(tagNamesSuggestRegex); + if (tagNamesSuggestQuery) { + return this._performSuggestQuery(tagNamesSuggestQuery[1], 'tagk').pipe(map(responseTransform)).toPromise(); + } + + const tagValuesSuggestQuery = interpolated.match(tagValuesSuggestRegex); + if (tagValuesSuggestQuery) { + return this._performSuggestQuery(tagValuesSuggestQuery[1], 'tagv').pipe(map(responseTransform)).toPromise(); + } + + return Promise.resolve([]); + } + + testDatasource() { + return this._performSuggestQuery('cpu', 'metrics') + .pipe( + map(() => { + return { status: 'success', message: 'Data source is working' }; + }) + ) + .toPromise(); + } + + getAggregators() { + if (this.aggregatorsPromise) { + return this.aggregatorsPromise; + } + + this.aggregatorsPromise = this._get('/api/aggregators') + .pipe( + map((result: any) => { + if (result.data && _.isArray(result.data)) { + return result.data.sort(); + } + return []; + }) + ) + .toPromise(); + return this.aggregatorsPromise; + } + + getFilterTypes() { + if (this.filterTypesPromise) { + return this.filterTypesPromise; + } + + this.filterTypesPromise = this._get('/api/config/filters') + .pipe( + map((result: any) => { + if (result.data) { + return Object.keys(result.data).sort(); + } + return []; + }) + ) + .toPromise(); + return this.filterTypesPromise; + } + + transformMetricData(md: { dps: any }, groupByTags: any, target: any, options: any, tsdbResolution: number) { + const metricLabel = this.createMetricLabel(md, target, groupByTags, options); + const dps: any[] = []; + + // TSDB returns datapoints has a hash of ts => value. + // Can't use _.pairs(invert()) because it stringifies keys/values + _.each(md.dps, (v: any, k: number) => { + if (tsdbResolution === 2) { + dps.push([v, k * 1]); + } else { + dps.push([v, k * 1000]); + } + }); + + return { target: metricLabel, datapoints: dps }; + } + + createMetricLabel( + md: { dps?: any; tags?: any; metric?: any }, + target: { alias: string }, + groupByTags: any, + options: { scopedVars: any } + ) { + if (target.alias) { + const scopedVars = _.clone(options.scopedVars || {}); + _.each(md.tags, (value, key) => { + scopedVars['tag_' + key] = { value: value }; + }); + return this.templateSrv.replace(target.alias, scopedVars); + } + + let label = md.metric; + const tagData: any[] = []; + + if (!_.isEmpty(md.tags)) { + _.each(_.toPairs(md.tags), (tag) => { + if (_.has(groupByTags, tag[0])) { + tagData.push(tag[0] + '=' + tag[1]); + } + }); + } + + if (!_.isEmpty(tagData)) { + label += '{' + tagData.join(', ') + '}'; + } + + return label; + } + + convertTargetToQuery(target: any, options: any, tsdbVersion: number) { + if (!target.metric || target.hide) { + return null; + } + + const query: any = { + metric: this.templateSrv.replace(target.metric, options.scopedVars, 'pipe'), + aggregator: 'avg', + }; + + if (target.aggregator) { + query.aggregator = this.templateSrv.replace(target.aggregator); + } + + if (target.shouldComputeRate) { + query.rate = true; + query.rateOptions = { + counter: !!target.isCounter, + }; + + if (target.counterMax && target.counterMax.length) { + query.rateOptions.counterMax = parseInt(target.counterMax, 10); + } + + if (target.counterResetValue && target.counterResetValue.length) { + query.rateOptions.resetValue = parseInt(target.counterResetValue, 10); + } + + if (tsdbVersion >= 2) { + query.rateOptions.dropResets = + !query.rateOptions.counterMax && (!query.rateOptions.ResetValue || query.rateOptions.ResetValue === 0); + } + } + + if (!target.disableDownsampling) { + let interval = this.templateSrv.replace(target.downsampleInterval || options.interval); + + if (interval.match(/\.[0-9]+s/)) { + interval = parseFloat(interval) * 1000 + 'ms'; + } + + query.downsample = interval + '-' + target.downsampleAggregator; + + if (target.downsampleFillPolicy && target.downsampleFillPolicy !== 'none') { + query.downsample += '-' + target.downsampleFillPolicy; + } + } + + if (target.filters && target.filters.length > 0) { + query.filters = angular.copy(target.filters); + if (query.filters) { + for (const filterKey in query.filters) { + query.filters[filterKey].filter = this.templateSrv.replace( + query.filters[filterKey].filter, + options.scopedVars, + 'pipe' + ); + } + } + } else { + query.tags = angular.copy(target.tags); + if (query.tags) { + for (const tagKey in query.tags) { + query.tags[tagKey] = this.templateSrv.replace(query.tags[tagKey], options.scopedVars, 'pipe'); + } + } + } + + if (target.explicitTags) { + query.explicitTags = true; + } + + return query; + } + + mapMetricsToTargets(metrics: any, options: any, tsdbVersion: number) { + let interpolatedTagValue, arrTagV; + return _.map(metrics, (metricData) => { + if (tsdbVersion === 3) { + return metricData.query.index; + } else { + return _.findIndex(options.targets as any[], (target) => { + if (target.filters && target.filters.length > 0) { + return target.metric === metricData.metric; + } else { + return ( + target.metric === metricData.metric && + _.every(target.tags, (tagV, tagK) => { + interpolatedTagValue = this.templateSrv.replace(tagV, options.scopedVars, 'pipe'); + arrTagV = interpolatedTagValue.split('|'); + return _.includes(arrTagV, metricData.tags[tagK]) || interpolatedTagValue === '*'; + }) + ); + } + }); + } + }); + } + + interpolateVariablesInQueries(queries: OpenTsdbQuery[], scopedVars: ScopedVars): OpenTsdbQuery[] { + if (!queries.length) { + return queries; + } + + return queries.map((query) => ({ + ...query, + metric: this.templateSrv.replace(query.metric, scopedVars), + })); + } + + convertToTSDBTime(date: any, roundUp: any, timezone: any) { + if (date === 'now') { + return null; + } + + date = dateMath.parse(date, roundUp, timezone); + return date.valueOf(); + } +} diff --git a/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png b/public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..113f2052bc580d52a8d42687dfb47835cd85c32f GIT binary patch literal 9599 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 +
      + OpenTSDB metrics query + +
      + + diff --git a/public/app/plugins/datasource/opentsdb/partials/query.editor.html b/public/app/plugins/datasource/opentsdb/partials/query.editor.html new file mode 100644 index 0000000..635acd3 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/partials/query.editor.html @@ -0,0 +1,262 @@ + +
      +
      + + + +
      +
      + +
      + +
      +
      +
      + + +
      + +
      +
      +
      +
      + +
      +
      + + + + blank for auto, or for example 1m + +
      + +
      + +
      + +
      +
      + +
      + +
      + +
      +
      + + + + +
      +
      +
      +
      + +
      +
      + + + +
      + {{fil.tagk}} = {{fil.type}}({{fil.filter}}) , groupBy = {{fil.groupBy}} + + + + + + +
      + +
      + +
      +
      + + +
      + +
      + +
      + +
      +
      + +
      + + +
      + + + + +
      + + +
      + +
      + +
      +
      +
      +
      + +
      +
      + +
      + +
      + +
      + +
      + +
      + +
      + + + + + + + + +
      + +
      +
      +
      +
      + +
      + + + + + + + +
      + + + + + + + +
      + +
      + + +
      + +
      +
      +
      +
      +
      + diff --git a/public/app/plugins/datasource/opentsdb/plugin.json b/public/app/plugins/datasource/opentsdb/plugin.json new file mode 100644 index 0000000..12e9d1e --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/plugin.json @@ -0,0 +1,23 @@ +{ + "type": "datasource", + "name": "OpenTSDB", + "id": "opentsdb", + "category": "tsdb", + + "metrics": true, + "defaultMatchFormat": "pipe", + "annotations": true, + "alerting": true, + + "info": { + "description": "Open source time series database", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/opentsdb_logo.png", + "large": "img/opentsdb_logo.png" + } + } +} diff --git a/public/app/plugins/datasource/opentsdb/query_ctrl.ts b/public/app/plugins/datasource/opentsdb/query_ctrl.ts new file mode 100644 index 0000000..c95072b --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/query_ctrl.ts @@ -0,0 +1,222 @@ +import _ from 'lodash'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { auto } from 'angular'; +import { textUtil, rangeUtil } from '@grafana/data'; + +export class OpenTsQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + aggregators: any; + fillPolicies: any; + filterTypes: any; + tsdbVersion: any; + aggregator: any; + downsampleInterval: any; + downsampleAggregator: any; + downsampleFillPolicy: any; + errors: any; + suggestMetrics: any; + suggestTagKeys: any; + suggestTagValues: any; + addTagMode: boolean; + addFilterMode: boolean; + + /** @ngInject */ + constructor($scope: any, $injector: auto.IInjectorService) { + super($scope, $injector); + + this.errors = this.validateTarget(); + this.aggregators = ['avg', 'sum', 'min', 'max', 'dev', 'zimsum', 'mimmin', 'mimmax']; + this.fillPolicies = ['none', 'nan', 'null', 'zero']; + this.filterTypes = [ + 'wildcard', + 'iliteral_or', + 'not_iliteral_or', + 'not_literal_or', + 'iwildcard', + 'literal_or', + 'regexp', + ]; + + this.tsdbVersion = this.datasource.tsdbVersion; + + if (!this.target.aggregator) { + this.target.aggregator = 'sum'; + } + + if (!this.target.downsampleAggregator) { + this.target.downsampleAggregator = 'avg'; + } + + if (!this.target.downsampleFillPolicy) { + this.target.downsampleFillPolicy = 'none'; + } + + this.datasource.getAggregators().then((aggs: { length: number }) => { + if (aggs.length !== 0) { + this.aggregators = aggs; + } + }); + + this.datasource.getFilterTypes().then((filterTypes: { length: number }) => { + if (filterTypes.length !== 0) { + this.filterTypes = filterTypes; + } + }); + + // needs to be defined here as it is called from typeahead + this.suggestMetrics = (query: string, callback: any) => { + this.datasource + .metricFindQuery('metrics(' + query + ')') + .then(this.getTextValues) + .then(callback); + }; + + this.suggestTagKeys = (query: any, callback: any) => { + this.datasource.suggestTagKeys(this.target.metric).then(callback); + }; + + this.suggestTagValues = (query: string, callback: any) => { + this.datasource + .metricFindQuery('suggest_tagv(' + query + ')') + .then(this.getTextValues) + .then(callback); + }; + } + + targetBlur() { + this.errors = this.validateTarget(); + this.refresh(); + } + + getTextValues(metricFindResult: any) { + return _.map(metricFindResult, (value) => { + return textUtil.escapeHtml(value.text); + }); + } + + addTag() { + if (this.target.filters && this.target.filters.length > 0) { + this.errors.tags = 'Please remove filters to use tags, tags and filters are mutually exclusive.'; + } + + if (!this.addTagMode) { + this.addTagMode = true; + return; + } + + if (!this.target.tags) { + this.target.tags = {}; + } + + this.errors = this.validateTarget(); + + if (!this.errors.tags) { + this.target.tags[this.target.currentTagKey] = this.target.currentTagValue; + this.target.currentTagKey = ''; + this.target.currentTagValue = ''; + this.targetBlur(); + } + + this.addTagMode = false; + } + + removeTag(key: string | number) { + delete this.target.tags[key]; + this.targetBlur(); + } + + editTag(key: string | number, value: any) { + this.removeTag(key); + this.target.currentTagKey = key; + this.target.currentTagValue = value; + this.addTag(); + } + + closeAddTagMode() { + this.addTagMode = false; + return; + } + + addFilter() { + if (this.target.tags && _.size(this.target.tags) > 0) { + this.errors.filters = 'Please remove tags to use filters, tags and filters are mutually exclusive.'; + } + + if (!this.addFilterMode) { + this.addFilterMode = true; + return; + } + + if (!this.target.filters) { + this.target.filters = []; + } + + if (!this.target.currentFilterType) { + this.target.currentFilterType = 'iliteral_or'; + } + + if (!this.target.currentFilterGroupBy) { + this.target.currentFilterGroupBy = false; + } + + this.errors = this.validateTarget(); + + if (!this.errors.filters) { + const currentFilter = { + type: this.target.currentFilterType, + tagk: this.target.currentFilterKey, + filter: this.target.currentFilterValue, + groupBy: this.target.currentFilterGroupBy, + }; + this.target.filters.push(currentFilter); + this.target.currentFilterType = 'literal_or'; + this.target.currentFilterKey = ''; + this.target.currentFilterValue = ''; + this.target.currentFilterGroupBy = false; + this.targetBlur(); + } + + this.addFilterMode = false; + } + + removeFilter(index: number) { + this.target.filters.splice(index, 1); + this.targetBlur(); + } + + editFilter(fil: { tagk: any; filter: any; type: any; groupBy: any }, index: number) { + this.removeFilter(index); + this.target.currentFilterKey = fil.tagk; + this.target.currentFilterValue = fil.filter; + this.target.currentFilterType = fil.type; + this.target.currentFilterGroupBy = fil.groupBy; + this.addFilter(); + } + + closeAddFilterMode() { + this.addFilterMode = false; + return; + } + + validateTarget() { + const errs: any = {}; + + if (this.target.shouldDownsample) { + try { + if (this.target.downsampleInterval) { + rangeUtil.describeInterval(this.target.downsampleInterval); + } else { + errs.downsampleInterval = "You must supply a downsample interval (e.g. '1m' or '1h')."; + } + } catch (err) { + errs.downsampleInterval = err.message; + } + } + + if (this.target.tags && _.has(this.target.tags, this.target.currentTagKey)) { + errs.tags = "Duplicate tag key '" + this.target.currentTagKey + "'."; + } + + return errs; + } +} diff --git a/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts new file mode 100644 index 0000000..a7b05d1 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/specs/datasource.test.ts @@ -0,0 +1,141 @@ +import OpenTsDatasource from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { OpenTsdbQuery } from '../types'; +import { createFetchResponse } from '../../../../../test/helpers/createFetchResponse'; +import { of } from 'rxjs'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +const metricFindQueryData = [ + { + target: 'prod1.count', + datapoints: [ + [10, 1], + [12, 1], + ], + }, +]; + +describe('opentsdb', () => { + function getTestcontext({ data = metricFindQueryData }: { data?: any } = {}) { + jest.clearAllMocks(); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(() => of(createFetchResponse(data))); + + const instanceSettings = { url: '', jsonData: { tsdbVersion: 1 } }; + const replace = jest.fn((value) => value); + const templateSrv: any = { + replace, + }; + + const ds = new OpenTsDatasource(instanceSettings, templateSrv); + + return { ds, templateSrv, fetchMock }; + } + + describe('When performing metricFindQuery', () => { + it('metrics() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('metrics(pew)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('metrics'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('pew'); + expect(results).not.toBe(null); + }); + + it('tag_names(cpu) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_names(cpu)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*}'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname, env=$env)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*,env=$env}'); + expect(results).not.toBe(null); + }); + + it('tag_values(cpu, test) should generate lookup query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('tag_values(cpu, hostname, env=$env, region=$region)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/search/lookup'); + expect(fetchMock.mock.calls[0][0].params?.m).toBe('cpu{hostname=*,env=$env,region=$region}'); + expect(results).not.toBe(null); + }); + + it('suggest_tagk() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('suggest_tagk(foo)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('tagk'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('foo'); + expect(results).not.toBe(null); + }); + + it('suggest_tagv() should generate api suggest query', async () => { + const { ds, fetchMock } = getTestcontext(); + + const results = await ds.metricFindQuery('suggest_tagv(bar)'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].url).toBe('/api/suggest'); + expect(fetchMock.mock.calls[0][0].params?.type).toBe('tagv'); + expect(fetchMock.mock.calls[0][0].params?.q).toBe('bar'); + expect(results).not.toBe(null); + }); + }); + + describe('When interpolating variables', () => { + it('should return an empty array if no queries are provided', () => { + const { ds } = getTestcontext(); + expect(ds.interpolateVariablesInQueries([], {})).toHaveLength(0); + }); + + it('should replace correct variables', () => { + const { ds, templateSrv } = getTestcontext(); + const variableName = 'someVar'; + const logQuery: OpenTsdbQuery = { + refId: 'someRefId', + metric: `$${variableName}`, + }; + + ds.interpolateVariablesInQueries([logQuery], {}); + + expect(templateSrv.replace).toHaveBeenCalledWith('$someVar', {}); + expect(templateSrv.replace).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts b/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts new file mode 100644 index 0000000..e574470 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/specs/query_ctrl.test.ts @@ -0,0 +1,93 @@ +import { OpenTsQueryCtrl } from '../query_ctrl'; + +describe('OpenTsQueryCtrl', () => { + const ctx = { + target: { target: '' }, + datasource: { + tsdbVersion: '', + getAggregators: () => Promise.resolve([]), + getFilterTypes: () => Promise.resolve([]), + }, + } as any; + + ctx.panelCtrl = { + panel: { + targets: [ctx.target], + }, + refresh: () => {}, + }; + + OpenTsQueryCtrl.prototype = Object.assign(OpenTsQueryCtrl.prototype, ctx); + + beforeEach(() => { + ctx.ctrl = new OpenTsQueryCtrl({}, {} as any); + }); + + describe('init query_ctrl variables', () => { + it('filter types should be initialized', () => { + expect(ctx.ctrl.filterTypes.length).toBe(7); + }); + + it('aggregators should be initialized', () => { + expect(ctx.ctrl.aggregators.length).toBe(8); + }); + + it('fill policy options should be initialized', () => { + expect(ctx.ctrl.fillPolicies.length).toBe(4); + }); + }); + + describe('when adding filters and tags', () => { + it('addTagMode should be false when closed', () => { + ctx.ctrl.addTagMode = true; + ctx.ctrl.closeAddTagMode(); + expect(ctx.ctrl.addTagMode).toBe(false); + }); + + it('addFilterMode should be false when closed', () => { + ctx.ctrl.addFilterMode = true; + ctx.ctrl.closeAddFilterMode(); + expect(ctx.ctrl.addFilterMode).toBe(false); + }); + + it('removing a tag from the tags list', () => { + ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; + ctx.ctrl.removeTag('tagk'); + expect(Object.keys(ctx.ctrl.target.tags).length).toBe(1); + }); + + it('removing a filter from the filters list', () => { + ctx.ctrl.target.filters = [ + { + tagk: 'tag_key', + filter: 'tag_value2', + type: 'wildcard', + groupBy: true, + }, + ]; + ctx.ctrl.removeFilter(0); + expect(ctx.ctrl.target.filters.length).toBe(0); + }); + + it('adding a filter when tags exist should generate error', () => { + ctx.ctrl.target.tags = { tagk: 'tag_key', tagk2: 'tag_value2' }; + ctx.ctrl.addFilter(); + expect(ctx.ctrl.errors.filters).toBe( + 'Please remove tags to use filters, tags and filters are mutually exclusive.' + ); + }); + + it('adding a tag when filters exist should generate error', () => { + ctx.ctrl.target.filters = [ + { + tagk: 'tag_key', + filter: 'tag_value2', + type: 'wildcard', + groupBy: true, + }, + ]; + ctx.ctrl.addTag(); + expect(ctx.ctrl.errors.tags).toBe('Please remove filters to use tags, tags and filters are mutually exclusive.'); + }); + }); +}); diff --git a/public/app/plugins/datasource/opentsdb/types.ts b/public/app/plugins/datasource/opentsdb/types.ts new file mode 100644 index 0000000..4a5cf58 --- /dev/null +++ b/public/app/plugins/datasource/opentsdb/types.ts @@ -0,0 +1,11 @@ +import { DataQuery, DataSourceJsonData } from '@grafana/data'; + +export interface OpenTsdbQuery extends DataQuery { + metric?: any; +} + +export interface OpenTsdbOptions extends DataSourceJsonData { + tsdbVersion: number; + tsdbResolution: number; + lookupLimit: number; +} diff --git a/public/app/plugins/datasource/postgres/README.md b/public/app/plugins/datasource/postgres/README.md new file mode 100644 index 0000000..75c3615 --- /dev/null +++ b/public/app/plugins/datasource/postgres/README.md @@ -0,0 +1,12 @@ +# Grafana PostgreSQL Data Source - Native Plugin + +Grafana ships with a built-in PostgreSQL data source plugin that allows you to query and visualize data from a PostgreSQL compatible database. + +## Adding the data source + +1. Open the side menu by clicking the Grafana icon in the top header. +2. In the side menu under the Dashboards link you should find a link named Data Sources. +3. Click the + Add data source button in the top header. +4. Select PostgreSQL from the Type dropdown. + +[http://docs.grafana.org/features/datasources/postgres/](http://docs.grafana.org/features/datasources/postgres/) diff --git a/public/app/plugins/datasource/postgres/config_ctrl.ts b/public/app/plugins/datasource/postgres/config_ctrl.ts new file mode 100644 index 0000000..85449dc --- /dev/null +++ b/public/app/plugins/datasource/postgres/config_ctrl.ts @@ -0,0 +1,89 @@ +import _ from 'lodash'; +import { + createChangeHandler, + createResetHandler, + PasswordFieldEnum, +} from '../../../features/datasources/utils/passwordHandlers'; +import DatasourceSrv from 'app/features/plugins/datasource_srv'; + +export class PostgresConfigCtrl { + static templateUrl = 'partials/config.html'; + + current: any; + datasourceSrv: any; + showTimescaleDBHelp: boolean; + onPasswordReset: ReturnType; + onPasswordChange: ReturnType; + + /** @ngInject */ + constructor($scope: any, datasourceSrv: DatasourceSrv) { + this.datasourceSrv = datasourceSrv; + this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full'; + this.current.jsonData.tlsConfigurationMethod = this.current.jsonData.tlsConfigurationMethod || 'file-path'; + this.current.jsonData.postgresVersion = this.current.jsonData.postgresVersion || 903; + this.showTimescaleDBHelp = false; + this.autoDetectFeatures(); + this.onPasswordReset = createResetHandler(this, PasswordFieldEnum.Password); + this.onPasswordChange = createChangeHandler(this, PasswordFieldEnum.Password); + this.tlsModeMapping(); + } + + autoDetectFeatures() { + if (!this.current.id) { + return; + } + + this.datasourceSrv.loadDatasource(this.current.name).then((ds: any) => { + return ds.getVersion().then((version: any) => { + version = Number(version[0].text); + + // timescaledb is only available for 9.6+ + if (version >= 906) { + ds.getTimescaleDBVersion().then((version: any) => { + if (version.length === 1) { + this.current.jsonData.timescaledb = true; + } + }); + } + + const major = Math.trunc(version / 100); + const minor = version % 100; + let name = String(major); + if (version < 1000) { + name = String(major) + '.' + String(minor); + } + if (!_.find(this.postgresVersions, (p: any) => p.value === version)) { + this.postgresVersions.push({ name: name, value: version }); + } + this.current.jsonData.postgresVersion = version; + }); + }); + } + + toggleTimescaleDBHelp() { + this.showTimescaleDBHelp = !this.showTimescaleDBHelp; + } + + tlsModeMapping() { + if (this.current.jsonData.sslmode === 'disable') { + this.current.jsonData.tlsAuth = false; + this.current.jsonData.tlsAuthWithCACert = false; + this.current.jsonData.tlsSkipVerify = true; + } else { + this.current.jsonData.tlsAuth = true; + this.current.jsonData.tlsAuthWithCACert = true; + this.current.jsonData.tlsSkipVerify = false; + } + } + + // the value portion is derived from postgres server_version_num/100 + postgresVersions = [ + { name: '9.3', value: 903 }, + { name: '9.4', value: 904 }, + { name: '9.5', value: 905 }, + { name: '9.6', value: 906 }, + { name: '10', value: 1000 }, + { name: '11', value: 1100 }, + { name: '12', value: 1200 }, + ]; +} diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts new file mode 100644 index 0000000..63d282f --- /dev/null +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -0,0 +1,211 @@ +import _ from 'lodash'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { getBackendSrv } from '@grafana/runtime'; +import { DataQueryResponse, ScopedVars } from '@grafana/data'; + +import ResponseParser from './response_parser'; +import PostgresQuery from 'app/plugins/datasource/postgres/postgres_query'; +import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +//Types +import { PostgresMetricFindValue, PostgresQueryForInterpolation } from './types'; +import { getSearchFilterScopedVar } from '../../../features/variables/utils'; + +export class PostgresDatasource { + id: any; + name: any; + jsonData: any; + responseParser: ResponseParser; + queryModel: PostgresQuery; + interval: string; + + constructor( + instanceSettings: { name: any; id?: any; jsonData?: any }, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.jsonData = instanceSettings.jsonData; + this.responseParser = new ResponseParser(); + this.queryModel = new PostgresQuery({}); + this.interval = (instanceSettings.jsonData || {}).timeInterval || '1m'; + } + + interpolateVariable = (value: string | string[], variable: { multi: any; includeAll: any }) => { + if (typeof value === 'string') { + if (variable.multi || variable.includeAll) { + return this.queryModel.quoteLiteral(value); + } else { + return value; + } + } + + if (typeof value === 'number') { + return value; + } + + const quotedValues = _.map(value, (v) => { + return this.queryModel.quoteLiteral(v); + }); + return quotedValues.join(','); + }; + + interpolateVariablesInQueries( + queries: PostgresQueryForInterpolation[], + scopedVars: ScopedVars + ): PostgresQueryForInterpolation[] { + let expandedQueries = queries; + if (queries && queries.length > 0) { + expandedQueries = queries.map((query) => { + const expandedQuery = { + ...query, + datasource: this.name, + rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), + rawQuery: true, + }; + return expandedQuery; + }); + } + return expandedQueries; + } + + query(options: any): Observable { + const queries = _.filter(options.targets, (target) => { + return target.hide !== true; + }).map((target) => { + const queryModel = new PostgresQuery(target, this.templateSrv, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + rawSql: queryModel.render(this.interpolateVariable), + format: target.format, + }; + }); + + if (queries.length === 0) { + return of({ data: [] }); + } + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: queries, + }, + }) + .pipe(map(this.responseParser.processQueryResult)); + } + + annotationQuery(options: any) { + if (!options.annotation.rawQuery) { + return Promise.reject({ + message: 'Query missing in annotation definition', + }); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + }, + }) + .pipe(map((data: any) => this.responseParser.transformAnnotationResponse(options, data))) + .toPromise(); + } + + metricFindQuery( + query: string, + optionalOptions: { variable?: any; searchFilter?: string } + ): Promise { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const rawSql = this.templateSrv.replace( + query, + getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }), + this.interpolateVariable + ); + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql, + format: 'table', + }; + + const range = this.timeSrv.timeRange(); + const data = { + queries: [interpolatedQuery], + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), + }; + + return getBackendSrv() + .fetch({ + url: '/api/tsdb/query', + method: 'POST', + data: data, + }) + .pipe(map((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data))) + .toPromise(); + } + + getVersion() { + return this.metricFindQuery("SELECT current_setting('server_version_num')::int/100", {}); + } + + getTimescaleDBVersion() { + return this.metricFindQuery("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'", {}); + } + + testDatasource() { + return this.metricFindQuery('SELECT 1', {}) + .then((res: any) => { + return { status: 'success', message: 'Database Connection OK' }; + }) + .catch((err: any) => { + console.error(err); + if (err.data && err.data.message) { + return { status: 'error', message: err.data.message }; + } else { + return { status: 'error', message: err.status }; + } + }); + } + + targetContainsTemplate(target: any) { + let rawSql = ''; + + if (target.rawQuery) { + rawSql = target.rawSql; + } else { + const query = new PostgresQuery(target); + rawSql = query.buildQuery(); + } + + rawSql = rawSql.replace('$__', ''); + + return this.templateSrv.variableExists(rawSql); + } +} diff --git a/public/app/plugins/datasource/postgres/img/postgresql_logo.svg b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg new file mode 100644 index 0000000..e79f8bf --- /dev/null +++ b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/postgres/meta_query.ts b/public/app/plugins/datasource/postgres/meta_query.ts new file mode 100644 index 0000000..e885333 --- /dev/null +++ b/public/app/plugins/datasource/postgres/meta_query.ts @@ -0,0 +1,168 @@ +import QueryModel from './postgres_query'; + +export class PostgresMetaQuery { + constructor(private target: { table: string; timeColumn: string }, private queryModel: QueryModel) {} + + getOperators(datatype: string) { + switch (datatype) { + case 'float4': + case 'float8': { + return ['=', '!=', '<', '<=', '>', '>=']; + } + case 'text': + case 'varchar': + case 'char': { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '~', '~*', '!~', '!~*']; + } + default: { + return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN']; + } + } + } + + // quote identifier as literal to use in metadata queries + quoteIdentAsLiteral(value: string) { + return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value)); + } + + findMetricTable() { + // query that returns first table found that has a timestamp(tz) column and a float column + let query = ` +SELECT + quote_ident(table_name) as table_name, + ( SELECT + quote_ident(column_name) as column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name IN ('timestamptz','timestamp') + ORDER BY ordinal_position LIMIT 1 + ) AS time_column, + ( SELECT + quote_ident(column_name) AS column_name + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name='float8' + ORDER BY ordinal_position LIMIT 1 + ) AS value_column +FROM information_schema.tables t +WHERE `; + query += this.buildSchemaConstraint(); + query += ` AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name IN ('timestamptz','timestamp') + ) AND + EXISTS + ( SELECT 1 + FROM information_schema.columns c + WHERE + c.table_schema = t.table_schema AND + c.table_name = t.table_name AND + udt_name='float8' + ) +LIMIT 1 +;`; + return query; + } + + buildSchemaConstraint() { + const query = ` +table_schema IN ( + SELECT + CASE WHEN trim(s[i]) = '"$user"' THEN user ELSE trim(s[i]) END + FROM + generate_series( + array_lower(string_to_array(current_setting('search_path'),','),1), + array_upper(string_to_array(current_setting('search_path'),','),1) + ) as i, + string_to_array(current_setting('search_path'),',') s +)`; + return query; + } + + buildTableConstraint(table: string) { + let query = ''; + + // check for schema qualified table + if (table.includes('.')) { + const parts = table.split('.'); + query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]); + return query; + } else { + query = this.buildSchemaConstraint(); + query += ' AND table_name = ' + this.quoteIdentAsLiteral(table); + + return query; + } + } + + buildTableQuery() { + let query = 'SELECT quote_ident(table_name) FROM information_schema.tables WHERE '; + query += this.buildSchemaConstraint(); + query += ' ORDER BY table_name'; + return query; + } + + buildColumnQuery(type?: string) { + let query = 'SELECT quote_ident(column_name) FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + + switch (type) { + case 'time': { + query += + " AND data_type IN ('timestamp without time zone','timestamp with time zone','bigint','integer','double precision','real')"; + break; + } + case 'metric': { + query += " AND data_type IN ('text','character','character varying')"; + break; + } + case 'value': { + query += " AND data_type IN ('bigint','integer','double precision','real')"; + query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn); + break; + } + case 'group': { + query += " AND data_type IN ('text','character','character varying')"; + break; + } + } + + query += ' ORDER BY column_name'; + + return query; + } + + buildValueQuery(column: string) { + let query = 'SELECT DISTINCT quote_literal(' + column + ')'; + query += ' FROM ' + this.target.table; + query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')'; + query += ' AND ' + column + ' IS NOT NULL'; + query += ' ORDER BY 1 LIMIT 100'; + return query; + } + + buildDatatypeQuery(column: string) { + let query = 'SELECT udt_name FROM information_schema.columns WHERE '; + query += this.buildTableConstraint(this.target.table); + query += ' AND column_name = ' + this.quoteIdentAsLiteral(column); + return query; + } + + buildAggregateQuery() { + let query = 'SELECT DISTINCT proname FROM pg_aggregate '; + query += 'INNER JOIN pg_proc ON pg_aggregate.aggfnoid = pg_proc.oid '; + query += 'INNER JOIN pg_type ON pg_type.oid=pg_proc.prorettype '; + query += "WHERE pronargs=1 AND typname IN ('float8') AND aggkind='n' ORDER BY 1"; + return query; + } +} diff --git a/public/app/plugins/datasource/postgres/mode-sql.js b/public/app/plugins/datasource/postgres/mode-sql.js new file mode 100644 index 0000000..2a6d897 --- /dev/null +++ b/public/app/plugins/datasource/postgres/mode-sql.js @@ -0,0 +1,114 @@ +ace.define( + 'ace/mode/sql_highlight_rules', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], + function (require, exports, module) { + 'use strict'; + + var oop = require('../lib/oop'); + var TextHighlightRules = require('./text_highlight_rules').TextHighlightRules; + + var SqlHighlightRules = function () { + var keywords = + 'select|insert|update|delete|from|where|and|or|group|by|order|limit|offset|having|as|case|' + + 'when|else|end|type|left|right|join|on|outer|desc|asc|union|create|table|primary|key|if|' + + 'foreign|not|references|default|null|inner|cross|natural|database|drop|grant'; + + var builtinConstants = 'true|false'; + + var builtinFunctions = + 'avg|count|first|last|max|min|sum|upper|lower|substring|char_length|round|rank|now|' + 'coalesce'; + + var dataTypes = + 'int|int2|int4|int8|numeric|decimal|date|varchar|char|bigint|float|bool|bytea|text|timestamp|' + + 'time|money|real|integer'; + + var keywordMapper = this.createKeywordMapper( + { + 'support.function': builtinFunctions, + keyword: keywords, + 'constant.language': builtinConstants, + 'storage.type': dataTypes, + }, + 'identifier', + true + ); + + this.$rules = { + start: [ + { + token: 'comment', + regex: '--.*$', + }, + { + token: 'comment', + start: '/\\*', + end: '\\*/', + }, + { + token: 'string', // " string + regex: '".*?"', + }, + { + token: 'string', // ' string + regex: "'.*?'", + }, + { + token: 'constant.numeric', // float + regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', + }, + { + token: keywordMapper, + regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', + }, + { + token: 'keyword.operator', + regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=', + }, + { + token: 'paren.lparen', + regex: '[\\(]', + }, + { + token: 'paren.rparen', + regex: '[\\)]', + }, + { + token: 'text', + regex: '\\s+', + }, + ], + }; + this.normalizeRules(); + }; + + oop.inherits(SqlHighlightRules, TextHighlightRules); + + exports.SqlHighlightRules = SqlHighlightRules; + } +); + +ace.define( + 'ace/mode/sql', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/sql_highlight_rules'], + function (require, exports, module) { + 'use strict'; + + var oop = require('../lib/oop'); + var TextMode = require('./text').Mode; + var SqlHighlightRules = require('./sql_highlight_rules').SqlHighlightRules; + + var Mode = function () { + this.HighlightRules = SqlHighlightRules; + this.$behaviour = this.$defaultBehaviour; + }; + oop.inherits(Mode, TextMode); + + (function () { + this.lineCommentStart = '--'; + + this.$id = 'ace/mode/sql'; + }.call(Mode.prototype)); + + exports.Mode = Mode; + } +); diff --git a/public/app/plugins/datasource/postgres/module.ts b/public/app/plugins/datasource/postgres/module.ts new file mode 100644 index 0000000..f81cdb7 --- /dev/null +++ b/public/app/plugins/datasource/postgres/module.ts @@ -0,0 +1,32 @@ +import { PostgresDatasource } from './datasource'; +import { PostgresQueryCtrl } from './query_ctrl'; +import { PostgresConfigCtrl } from './config_ctrl'; + +const defaultQuery = `SELECT + extract(epoch from time_column) AS time, + text_column as text, + tags_column as tags +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +class PostgresAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + annotation: any; + + /** @ngInject */ + constructor() { + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export { + PostgresDatasource, + PostgresDatasource as Datasource, + PostgresQueryCtrl as QueryCtrl, + PostgresConfigCtrl as ConfigCtrl, + PostgresAnnotationsQueryCtrl as AnnotationsQueryCtrl, +}; diff --git a/public/app/plugins/datasource/postgres/partials/annotations.editor.html b/public/app/plugins/datasource/postgres/partials/annotations.editor.html new file mode 100644 index 0000000..03e6d25 --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/annotations.editor.html @@ -0,0 +1,54 @@ +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      Annotation Query Format
      +An annotation is an event that is overlaid on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time for the annotation event time. Use epoch time or any native date data type. +- column with alias: timeend for the annotation event time-end. Use epoch time or any native date data type. +- column with alias: text for the annotation text +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2' + + +Macros: +- $__time(column) -> column as "time" +- $__timeEpoch -> extract(epoch from column) as "time" +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' +- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 +- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> '2017-04-21T05:01:17Z' +- $__timeTo() -> '2017-04-21T05:01:17Z' +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +- $__unixEpochNanoFrom() -> 1494410783152415214 +- $__unixEpochNanoTo() -> 1494497183142514872 +
      +
      +
      diff --git a/public/app/plugins/datasource/postgres/partials/config.html b/public/app/plugins/datasource/postgres/partials/config.html new file mode 100644 index 0000000..9bff000 --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/config.html @@ -0,0 +1,195 @@ + +

      PostgreSQL Connection

      + +
      +
      + Host + +
      + +
      + Database + +
      + +
      +
      + User + +
      +
      + +
      +
      + +
      + +
      + + + This option determines whether or with what priority a secure TLS/SSL TCP/IP connection will be negotiated with the server. + +
      +
      + +
      + +
      + + + This option determines how TLS/SSL certifications are configured. Selecting File system path will allow + you to configure certificates by specifying paths to existing certificates on the local file system where + Grafana is running. Be sure that the file is readable by the user executing the Grafana process.

      + + Selecting Certificate content will allow you to configure certificates by specifying its content. + The content will be stored encrypted in Grafana's database. When connecting to the database the certificates + will be written as files to Grafana's configured data path on the local file system. +
      +
      +
      +
      + +
      +
      +
      TLS/SSL Auth Details
      +
      +
      + TLS/SSL Root Certificate + + + If the selected TLS/SSL mode requires a server root certificate, provide the path to the file here. + +
      +
      + TLS/SSL Client Certificate + + + To authenticate with an TLS/SSL client certificate, provide the path to the file here. + Be sure that the file is readable by the user executing the grafana process. + +
      +
      + TLS/SSL Client Key + + + To authenticate with a client TLS/SSL certificate, provide the path to the corresponding key file here. + Be sure that the file is only readable by the user executing the grafana process. + +
      +
      + + + +Connection limits + +
      +
      + Max open + + + The maximum number of open connections to the database. If Max idle connections is greater than 0 and the + Max open connections is less than Max idle connections, then Max idle connections will be + reduced to match the Max open connections limit. If set to 0, there is no limit on the number of open + connections. + +
      +
      + Max idle + + + The maximum number of connections in the idle connection pool. If Max open connections is greater than 0 but + less than the Max idle connections, then the Max idle connections will be reduced to match the + Max open connections limit. If set to 0, no idle connections are retained. + +
      +
      + Max lifetime + + + The maximum amount of time in seconds a connection may be reused. If set to 0, connections are reused forever. + +
      +
      + +

      PostgreSQL details

      + +
      +
      + + Version + + This option controls what functions are available in the PostgreSQL query builder. + + + + + +
      +
      + + +
      + +
      +
      + Min time interval + + + A lower limit for the auto group by time interval. Recommended to be set to write frequency, + for example 1m if your data is written every minute. + +
      +
      +
      +
      +

      + TimescaleDB is a + time-series database built as a PostgreSQL extension. If enabled, Grafana will use time_bucket in + the $__timeGroup macro and display TimescaleDB specific aggregate functions in the query builder. +

      +
      +
      +
      + +
      +
      +
      User Permission
      +

      + The database user should only be granted SELECT permissions on the specified database & tables you want to query. + Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, statements + like DELETE FROM user; and DROP TABLE user; would be executed. To protect against this we + Highly recommmend you create a specific PostgreSQL user with restricted permissions. +

      +
      +
      diff --git a/public/app/plugins/datasource/postgres/partials/query.editor.html b/public/app/plugins/datasource/postgres/partials/query.editor.html new file mode 100644 index 0000000..13bba6a --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/query.editor.html @@ -0,0 +1,191 @@ + + +
      +
      +
      + + +
      +
      +
      + +
      +
      +
      + + + + + + + + +
      + +
      +
      +
      + +
      + +
      +
      + +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      +
      + +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      + +
      + +
      +
      + + + + +
      + +
      + +
      + +
      +
      +
      +
      + +
      + +
      +
      + +
      + +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + + +
      +
      Time series:
      +- return column named time (UTC in seconds or timestamp)
      +- return column(s) with numeric datatype as values
      +Optional:
      +  - return column named metric to represent the series name.
      +  - If multiple value columns are returned the metric column is used as prefix.
      +  - If no column named metric is found the column name of the value column is used as series name
      +
      +Resultsets of time series queries need to be sorted by time.
      +
      +Table:
      +- return any set of columns
      +
      +Macros:
      +- $__time(column) -> column as "time"
      +- $__timeEpoch -> extract(epoch from column) as "time"
      +- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
      +- $__unixEpochFilter(column) ->  column >= 1492750877 AND column <= 1492750877
      +- $__unixEpochNanoFilter(column) ->  column >= 1494410783152415214 AND column <= 1494497183142514872
      +- $__timeGroup(column,'5m'[, fillvalue]) -> (extract(epoch from column)/300)::bigint*300
      +     by setting fillvalue grafana will fill in missing values according to the interval
      +     fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet
      +- $__timeGroupAlias(column,'5m') -> (extract(epoch from column)/300)::bigint*300 AS "time"
      +- $__unixEpochGroup(column,'5m') -> floor(column/300)*300
      +- $__unixEpochGroupAlias(column,'5m') -> floor(column/300)*300 AS "time"
      +
      +Example of group by and order by with $__timeGroup:
      +SELECT
      +  $__timeGroup(date_time_col, '1h'),
      +  sum(value) as value
      +FROM yourtable
      +GROUP BY time
      +ORDER BY time
      +
      +Or build your own conditionals using these macros which just return the values:
      +- $__timeFrom() ->  '2017-04-21T05:01:17Z'
      +- $__timeTo() ->  '2017-04-21T05:01:17Z'
      +- $__unixEpochFrom() ->  1492750877
      +- $__unixEpochTo() ->  1492750877
      +- $__unixEpochNanoFrom() ->  1494410783152415214
      +- $__unixEpochNanoTo() ->  1494497183142514872
      +    
      +
      + + + +
      +
      {{ctrl.lastQueryMeta.executedQueryString}}
      +
      + +
      +
      {{ctrl.lastQueryError}}
      +
      + +
      diff --git a/public/app/plugins/datasource/postgres/plugin.json b/public/app/plugins/datasource/postgres/plugin.json new file mode 100644 index 0000000..815356e --- /dev/null +++ b/public/app/plugins/datasource/postgres/plugin.json @@ -0,0 +1,26 @@ +{ + "type": "datasource", + "name": "PostgreSQL", + "id": "postgres", + "category": "sql", + + "info": { + "description": "Data source for PostgreSQL and compatible databases", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/postgresql_logo.svg", + "large": "img/postgresql_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true, + + "queryOptions": { + "minInterval": true + } +} diff --git a/public/app/plugins/datasource/postgres/postgres_query.ts b/public/app/plugins/datasource/postgres/postgres_query.ts new file mode 100644 index 0000000..b2cc198 --- /dev/null +++ b/public/app/plugins/datasource/postgres/postgres_query.ts @@ -0,0 +1,297 @@ +import _ from 'lodash'; +import { TemplateSrv } from '@grafana/runtime'; +import { ScopedVars } from '@grafana/data'; + +export default class PostgresQuery { + target: any; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(target: any, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + + target.format = target.format || 'time_series'; + target.timeColumn = target.timeColumn || 'time'; + target.metricColumn = target.metricColumn || 'none'; + + target.group = target.group || []; + target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }]; + target.select = target.select || [[{ type: 'column', params: ['value'] }]]; + + // handle pre query gui panels gracefully + if (!('rawQuery' in this.target)) { + if ('rawSql' in target) { + // pre query gui panel + target.rawQuery = true; + } else { + // new panel + target.rawQuery = false; + } + } + + // give interpolateQueryStr access to this + this.interpolateQueryStr = this.interpolateQueryStr.bind(this); + } + + // remove identifier quoting from identifier to use in metadata queries + unquoteIdentifier(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else { + return value; + } + } + + quoteIdentifier(value: any) { + return '"' + String(value).replace(/"/g, '""') + '"'; + } + + quoteLiteral(value: any) { + return "'" + String(value).replace(/'/g, "''") + "'"; + } + + escapeLiteral(value: any) { + return String(value).replace(/'/g, "''"); + } + + hasTimeGroup() { + return _.find(this.target.group, (g: any) => g.type === 'time'); + } + + hasMetricColumn() { + return this.target.metricColumn !== 'none'; + } + + interpolateQueryStr(value: any, variable: { multi: any; includeAll: any }, defaultFormatFn: any) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return this.escapeLiteral(value); + } + + if (typeof value === 'string') { + return this.quoteLiteral(value); + } + + const escapedValues = _.map(value, this.quoteLiteral); + return escapedValues.join(','); + } + + render(interpolate?: any) { + const target = this.target; + + // new query with no table set yet + if (!this.target.rawQuery && !('table' in this.target)) { + return ''; + } + + if (!target.rawQuery) { + target.rawSql = this.buildQuery(); + } + + if (interpolate) { + return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr); + } else { + return target.rawSql; + } + } + + hasUnixEpochTimecolumn() { + return ['int4', 'int8', 'float4', 'float8', 'numeric'].indexOf(this.target.timeColumnType) > -1; + } + + buildTimeColumn(alias = true) { + const timeGroup = this.hasTimeGroup(); + let query; + let macro = '$__timeGroup'; + + if (timeGroup) { + let args; + if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') { + args = timeGroup.params.join(','); + } else { + args = timeGroup.params[0]; + } + if (this.hasUnixEpochTimecolumn()) { + macro = '$__unixEpochGroup'; + } + if (alias) { + macro += 'Alias'; + } + query = macro + '(' + this.target.timeColumn + ',' + args + ')'; + } else { + query = this.target.timeColumn; + if (alias) { + query += ' AS "time"'; + } + } + + return query; + } + + buildMetricColumn() { + if (this.hasMetricColumn()) { + return this.target.metricColumn + ' AS metric'; + } + + return ''; + } + + buildValueColumns() { + let query = ''; + for (const column of this.target.select) { + query += ',\n ' + this.buildValueColumn(column); + } + + return query; + } + + buildValueColumn(column: any) { + let query = ''; + + const columnName: any = _.find(column, (g: any) => g.type === 'column'); + query = columnName.params[0]; + + const aggregate: any = _.find(column, (g: any) => g.type === 'aggregate' || g.type === 'percentile'); + const windows: any = _.find(column, (g: any) => g.type === 'window' || g.type === 'moving_window'); + + if (aggregate) { + const func = aggregate.params[0]; + switch (aggregate.type) { + case 'aggregate': + if (func === 'first' || func === 'last') { + query = func + '(' + query + ',' + this.target.timeColumn + ')'; + } else { + query = func + '(' + query + ')'; + } + break; + case 'percentile': + query = func + '(' + aggregate.params[1] + ') WITHIN GROUP (ORDER BY ' + query + ')'; + break; + } + } + + if (windows) { + const overParts = []; + if (this.hasMetricColumn()) { + overParts.push('PARTITION BY ' + this.target.metricColumn); + } + overParts.push('ORDER BY ' + this.buildTimeColumn(false)); + + const over = overParts.join(' '); + let curr: string; + let prev: string; + switch (windows.type) { + case 'window': + switch (windows.params[0]) { + case 'delta': + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = curr + ' - ' + prev; + break; + case 'increase': + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev; + query += ' WHEN ' + prev + ' IS NULL THEN NULL ELSE ' + curr + ' END)'; + break; + case 'rate': + let timeColumn = this.target.timeColumn; + if (aggregate) { + timeColumn = 'min(' + timeColumn + ')'; + } + + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev; + query += ' WHEN ' + prev + ' IS NULL THEN NULL ELSE ' + curr + ' END)'; + query += '/extract(epoch from ' + timeColumn + ' - lag(' + timeColumn + ') OVER (' + over + '))'; + break; + default: + query = windows.params[0] + '(' + query + ') OVER (' + over + ')'; + break; + } + break; + case 'moving_window': + query = windows.params[0] + '(' + query + ') OVER (' + over + ' ROWS ' + windows.params[1] + ' PRECEDING)'; + break; + } + } + + const alias: any = _.find(column, (g: any) => g.type === 'alias'); + if (alias) { + query += ' AS ' + this.quoteIdentifier(alias.params[0]); + } + + return query; + } + + buildWhereClause() { + let query = ''; + const conditions = _.map(this.target.where, (tag, index) => { + switch (tag.type) { + case 'macro': + return tag.name + '(' + this.target.timeColumn + ')'; + break; + case 'expression': + return tag.params.join(' '); + break; + } + }); + + if (conditions.length > 0) { + query = '\nWHERE\n ' + conditions.join(' AND\n '); + } + + return query; + } + + buildGroupClause() { + let query = ''; + let groupSection = ''; + + for (let i = 0; i < this.target.group.length; i++) { + const part = this.target.group[i]; + if (i > 0) { + groupSection += ', '; + } + if (part.type === 'time') { + groupSection += '1'; + } else { + groupSection += part.params[0]; + } + } + + if (groupSection.length) { + query = '\nGROUP BY ' + groupSection; + if (this.hasMetricColumn()) { + query += ',2'; + } + } + return query; + } + + buildQuery() { + let query = 'SELECT'; + + query += '\n ' + this.buildTimeColumn(); + if (this.hasMetricColumn()) { + query += ',\n ' + this.buildMetricColumn(); + } + query += this.buildValueColumns(); + + query += '\nFROM ' + this.target.table; + + query += this.buildWhereClause(); + query += this.buildGroupClause(); + + query += '\nORDER BY 1'; + if (this.hasMetricColumn()) { + query += ',2'; + } + + return query; + } +} diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts new file mode 100644 index 0000000..6ac3375 --- /dev/null +++ b/public/app/plugins/datasource/postgres/query_ctrl.ts @@ -0,0 +1,668 @@ +import _ from 'lodash'; +import appEvents from 'app/core/app_events'; +import { PostgresMetaQuery } from './meta_query'; +import { QueryCtrl } from 'app/plugins/sdk'; +import { SqlPart } from 'app/core/components/sql_part/sql_part'; +import PostgresQuery from './postgres_query'; +import sqlPart from './sql_part'; +import { auto } from 'angular'; +import { CoreEvents } from 'app/types'; +import { PanelEvents, QueryResultMeta } from '@grafana/data'; +import { VariableWithMultiSupport } from 'app/features/variables/types'; +import { TemplateSrv } from '@grafana/runtime'; + +const defaultQuery = `SELECT + $__time(time_column), + value1 +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +export class PostgresQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + formats: any[]; + queryModel: PostgresQuery; + metaBuilder: PostgresMetaQuery; + lastQueryMeta?: QueryResultMeta; + lastQueryError?: string; + showHelp: boolean; + tableSegment: any; + whereAdd: any; + timeColumnSegment: any; + metricColumnSegment: any; + selectMenu: any[]; + selectParts: SqlPart[][]; + groupParts: SqlPart[]; + whereParts: SqlPart[]; + groupAdd: any; + + /** @ngInject */ + constructor( + $scope: any, + $injector: auto.IInjectorService, + private templateSrv: TemplateSrv, + private uiSegmentSrv: any + ) { + super($scope, $injector); + this.target = this.target; + this.queryModel = new PostgresQuery(this.target, templateSrv, this.panel.scopedVars); + this.metaBuilder = new PostgresMetaQuery(this.target, this.queryModel); + this.updateProjection(); + + this.formats = [ + { text: 'Time series', value: 'time_series' }, + { text: 'Table', value: 'table' }, + ]; + + if (!this.target.rawSql) { + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = 'SELECT 1'; + this.target.rawQuery = true; + } else { + this.target.rawSql = defaultQuery; + this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then((result: any) => { + if (result.length > 0) { + this.target.table = result[0].text; + let segment = this.uiSegmentSrv.newSegment(this.target.table); + this.tableSegment.html = segment.html; + this.tableSegment.value = segment.value; + + this.target.timeColumn = result[1].text; + segment = this.uiSegmentSrv.newSegment(this.target.timeColumn); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + + this.target.timeColumnType = 'timestamp'; + this.target.select = [[{ type: 'column', params: [result[2].text] }]]; + this.updateProjection(); + this.updateRawSqlAndRefresh(); + } + }); + } + } + + if (!this.target.table) { + this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true }); + } else { + this.tableSegment = uiSegmentSrv.newSegment(this.target.table); + } + + this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn); + this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn); + + this.buildSelectMenu(); + this.whereAdd = this.uiSegmentSrv.newPlusButton(); + this.groupAdd = this.uiSegmentSrv.newPlusButton(); + + this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); + } + + updateRawSqlAndRefresh() { + if (!this.target.rawQuery) { + this.target.rawSql = this.queryModel.buildQuery(); + } + + this.panelCtrl.refresh(); + } + + updateProjection() { + this.selectParts = _.map(this.target.select, (parts: any) => { + return _.map(parts, sqlPart.create).filter((n) => n); + }); + this.whereParts = _.map(this.target.where, sqlPart.create).filter((n) => n); + this.groupParts = _.map(this.target.group, sqlPart.create).filter((n) => n); + } + + updatePersistedParts() { + this.target.select = _.map(this.selectParts, (selectParts) => { + return _.map(selectParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + }); + this.target.where = _.map(this.whereParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params }; + }); + this.target.group = _.map(this.groupParts, (part: any) => { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + } + + buildSelectMenu() { + this.selectMenu = []; + const aggregates = { + text: 'Aggregate Functions', + value: 'aggregate', + submenu: [ + { text: 'Average', value: 'avg' }, + { text: 'Count', value: 'count' }, + { text: 'Maximum', value: 'max' }, + { text: 'Minimum', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Standard deviation', value: 'stddev' }, + { text: 'Variance', value: 'variance' }, + ], + }; + + // first and last aggregate are timescaledb specific + if (this.datasource.jsonData.timescaledb === true) { + aggregates.submenu.push({ text: 'First', value: 'first' }); + aggregates.submenu.push({ text: 'Last', value: 'last' }); + } + + this.selectMenu.push(aggregates); + + // ordered set aggregates require postgres 9.4+ + if (this.datasource.jsonData.postgresVersion >= 904) { + const aggregates2 = { + text: 'Ordered-Set Aggregate Functions', + value: 'percentile', + submenu: [ + { text: 'Percentile (continuous)', value: 'percentile_cont' }, + { text: 'Percentile (discrete)', value: 'percentile_disc' }, + ], + }; + this.selectMenu.push(aggregates2); + } + + const windows = { + text: 'Window Functions', + value: 'window', + submenu: [ + { text: 'Delta', value: 'delta' }, + { text: 'Increase', value: 'increase' }, + { text: 'Rate', value: 'rate' }, + { text: 'Sum', value: 'sum' }, + { text: 'Moving Average', value: 'avg', type: 'moving_window' }, + ], + }; + this.selectMenu.push(windows); + + this.selectMenu.push({ text: 'Alias', value: 'alias' }); + this.selectMenu.push({ text: 'Column', value: 'column' }); + } + + toggleEditorMode() { + if (this.target.rawQuery) { + appEvents.emit(CoreEvents.showConfirmModal, { + title: 'Warning', + text2: 'Switching to query builder may overwrite your raw SQL.', + icon: 'exclamation-triangle', + yesText: 'Switch', + onConfirm: () => { + this.target.rawQuery = !this.target.rawQuery; + }, + }); + } else { + this.target.rawQuery = !this.target.rawQuery; + } + } + + resetPlusButton(button: { html: any; value: any; type: any; fake: any }) { + const plusButton = this.uiSegmentSrv.newPlusButton(); + button.html = plusButton.html; + button.value = plusButton.value; + button.type = plusButton.type; + button.fake = plusButton.fake; + } + + getTableSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildTableQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + tableChanged() { + this.target.table = this.tableSegment.value; + this.target.where = []; + this.target.group = []; + this.updateProjection(); + + const segment = this.uiSegmentSrv.newSegment('none'); + this.metricColumnSegment.html = segment.html; + this.metricColumnSegment.value = segment.value; + this.target.metricColumn = 'none'; + + const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then((result: any) => { + // check if time column is still valid + if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) { + const segment = this.uiSegmentSrv.newSegment(result[0].text); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + } + return this.timeColumnChanged(false); + }); + const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then((result: any) => { + if (result.length > 0) { + this.target.select = [[{ type: 'column', params: [result[0].text] }]]; + this.updateProjection(); + } + }); + + Promise.all([task1, task2]).then(() => { + this.updateRawSqlAndRefresh(); + }); + } + + getTimeColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('time')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + timeColumnChanged(refresh?: boolean) { + this.target.timeColumn = this.timeColumnSegment.value; + return this.datasource + .metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)) + .then((result: any) => { + if (result.length === 1) { + if (this.target.timeColumnType !== result[0].text) { + this.target.timeColumnType = result[0].text; + } + let partModel; + if (this.queryModel.hasUnixEpochTimecolumn()) { + partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] }); + } else { + partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] }); + } + + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + } + + this.updatePersistedParts(); + if (refresh !== false) { + this.updateRawSqlAndRefresh(); + } + }); + } + + getMetricColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('metric')) + .then(this.transformToSegments({ addNone: true })) + .catch(this.handleQueryError.bind(this)); + } + + metricColumnChanged() { + this.target.metricColumn = this.metricColumnSegment.value; + this.updateRawSqlAndRefresh(); + } + + onDataReceived(dataList: any) { + this.lastQueryError = undefined; + this.lastQueryMeta = dataList[0]?.meta; + } + + onDataError(err: any) { + if (err.data && err.data.results) { + const queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryError = queryRes.error; + } + } + } + + transformToSegments(config: { addNone?: any; addTemplateVars?: any; templateQuoter?: any }) { + return (results: any) => { + const segments = _.map(results, (segment) => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (config.addTemplateVars) { + for (const variable of this.templateSrv.getVariables()) { + let value; + value = '$' + variable.name; + if (config.templateQuoter && ((variable as unknown) as VariableWithMultiSupport).multi === false) { + value = config.templateQuoter(value); + } + + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: value, + expandable: true, + }) + ); + } + } + + if (config.addNone) { + segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true })); + } + + return segments; + }; + } + + findAggregateIndex(selectParts: any) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile'); + } + + findWindowIndex(selectParts: any) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window'); + } + + addSelectPart(selectParts: any[], item: { value: any }, subItem: { type: any; value: any }) { + let partType = item.value; + if (subItem && subItem.type) { + partType = subItem.type; + } + let partModel = sqlPart.create({ type: partType }); + if (subItem) { + partModel.params[0] = subItem.value; + } + let addAlias = false; + + switch (partType) { + case 'column': + const parts = _.map(selectParts, (part: any) => { + return sqlPart.create({ type: part.def.type, params: _.clone(part.params) }); + }); + this.selectParts.push(parts); + break; + case 'percentile': + case 'aggregate': + // add group by if no group by yet + if (this.target.group.length === 0) { + this.addGroup('time', '$__interval'); + } + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + // replace current aggregation + selectParts[aggIndex] = partModel; + } else { + selectParts.splice(1, 0, partModel); + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'moving_window': + case 'window': + const windowIndex = this.findWindowIndex(selectParts); + if (windowIndex !== -1) { + // replace current window function + selectParts[windowIndex] = partModel; + } else { + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + selectParts.splice(aggIndex + 1, 0, partModel); + } else { + selectParts.splice(1, 0, partModel); + } + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'alias': + addAlias = true; + break; + } + + if (addAlias) { + // set initial alias name to column name + partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] }); + if (selectParts[selectParts.length - 1].def.type === 'alias') { + selectParts[selectParts.length - 1] = partModel; + } else { + selectParts.push(partModel); + } + } + + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + } + + removeSelectPart(selectParts: any, part: { def: { type: string } }) { + if (part.def.type === 'column') { + // remove all parts of column unless its last column + if (this.selectParts.length > 1) { + const modelsIndex = _.indexOf(this.selectParts, selectParts); + this.selectParts.splice(modelsIndex, 1); + } + } else { + const partIndex = _.indexOf(selectParts, part); + selectParts.splice(partIndex, 1); + } + + this.updatePersistedParts(); + } + + handleSelectPartEvent(selectParts: any, part: { def: any }, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + switch (part.def.type) { + case 'aggregate': + return this.datasource + .metricFindQuery(this.metaBuilder.buildAggregateQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'column': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('value')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeSelectPart(selectParts, part); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + handleGroupPartEvent(part: any, index: any, evt: { name: any }) { + switch (evt.name) { + case 'get-param-options': { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + this.removeGroup(part, index); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + addGroup(partType: string, value: string) { + let params = [value]; + if (partType === 'time') { + params = ['$__interval', 'none']; + } + const partModel = sqlPart.create({ type: partType, params: params }); + + if (partType === 'time') { + // put timeGroup at start + this.groupParts.splice(0, 0, partModel); + } else { + this.groupParts.push(partModel); + } + + // add aggregates when adding group by + for (const selectParts of this.selectParts) { + if (!selectParts.some((part) => part.def.type === 'aggregate')) { + const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] }); + selectParts.splice(1, 0, aggregate); + if (!selectParts.some((part) => part.def.type === 'alias')) { + const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] }); + selectParts.push(alias); + } + } + } + + this.updatePersistedParts(); + } + + removeGroup(part: { def: { type: string } }, index: number) { + if (part.def.type === 'time') { + // remove aggregations + this.selectParts = _.map(this.selectParts, (s: any) => { + return _.filter(s, (part: any) => { + if (part.def.type === 'aggregate' || part.def.type === 'percentile') { + return false; + } + return true; + }); + }); + } + + this.groupParts.splice(index, 1); + this.updatePersistedParts(); + } + + handleWherePartEvent(whereParts: any, part: any, evt: any, index: any) { + switch (evt.name) { + case 'get-param-options': { + switch (evt.param.name) { + case 'left': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'right': + if (['int4', 'int8', 'float4', 'float8', 'timestamp', 'timestamptz'].indexOf(part.datatype) > -1) { + // don't do value lookups for numerical fields + return Promise.resolve([]); + } else { + return this.datasource + .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0])) + .then( + this.transformToSegments({ + addTemplateVars: true, + templateQuoter: (v: string) => { + return this.queryModel.quoteLiteral(v); + }, + }) + ) + .catch(this.handleQueryError.bind(this)); + } + case 'op': + return Promise.resolve(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype))); + default: + return Promise.resolve([]); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => { + if (d.length === 1) { + part.datatype = d[0].text; + } + }); + this.updateRawSqlAndRefresh(); + break; + } + case 'action': { + // remove element + whereParts.splice(index, 1); + this.updatePersistedParts(); + this.updateRawSqlAndRefresh(); + break; + } + case 'get-part-actions': { + return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + getWhereOptions() { + const options = []; + if (this.queryModel.hasUnixEpochTimecolumn()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' })); + } else { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' })); + } + options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' })); + return Promise.resolve(options); + } + + addWhereAction(part: any, index: any) { + switch (this.whereAdd.type) { + case 'macro': { + const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] }); + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + break; + } + default: { + this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] })); + } + } + + this.updatePersistedParts(); + this.resetPlusButton(this.whereAdd); + this.updateRawSqlAndRefresh(); + } + + getGroupOptions() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('group')) + .then((tags: any) => { + const options = []; + if (!this.queryModel.hasTimeGroup()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + addGroupAction() { + switch (this.groupAdd.value) { + default: { + this.addGroup(this.groupAdd.type, this.groupAdd.value); + } + } + + this.resetPlusButton(this.groupAdd); + this.updateRawSqlAndRefresh(); + } + + handleQueryError(err: any): any[] { + this.error = err.message || 'Failed to issue metric query'; + return []; + } +} diff --git a/public/app/plugins/datasource/postgres/response_parser.ts b/public/app/plugins/datasource/postgres/response_parser.ts new file mode 100644 index 0000000..407468a --- /dev/null +++ b/public/app/plugins/datasource/postgres/response_parser.ts @@ -0,0 +1,149 @@ +import _ from 'lodash'; + +export default class ResponseParser { + processQueryResult(res: any) { + const data: any[] = []; + + if (!res.data.results) { + return { data: data }; + } + + for (const key in res.data.results) { + const queryRes = res.data.results[key]; + + if (queryRes.series) { + for (const series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + refId: queryRes.refId, + meta: queryRes.meta, + }); + } + } + + if (queryRes.tables) { + for (const table of queryRes.tables) { + table.type = 'table'; + table.refId = queryRes.refId; + table.meta = queryRes.meta; + data.push(table); + } + } + } + + return { data: data }; + } + + parseMetricFindQueryResult(refId: string, results: any) { + if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { + return []; + } + + const columns = results.data.results[refId].tables[0].columns; + const rows = results.data.results[refId].tables[0].rows; + const textColIndex = this.findColIndex(columns, '__text'); + const valueColIndex = this.findColIndex(columns, '__value'); + + if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) { + return this.transformToKeyValueList(rows, textColIndex, valueColIndex); + } + + return this.transformToSimpleList(rows); + } + + transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({ + text: rows[i][textColIndex], + value: rows[i][valueColIndex], + }); + } + } + + return res; + } + + transformToSimpleList(rows: any[][]) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + res.push(rows[i][j]); + } + } + + const unique = Array.from(new Set(res)); + + return _.map(unique, (value) => { + return { text: value }; + }); + } + + findColIndex(columns: any[], colName: string) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res: any, key: any) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + transformAnnotationResponse(options: any, data: any) { + const table = data.data.results[options.annotation.name].tables[0]; + + let timeColumnIndex = -1; + let timeEndColumnIndex = -1; + const titleColumnIndex = -1; + let textColumnIndex = -1; + let tagsColumnIndex = -1; + + for (let i = 0; i < table.columns.length; i++) { + if (table.columns[i].text === 'time') { + timeColumnIndex = i; + } else if (table.columns[i].text === 'timeend') { + timeEndColumnIndex = i; + } else if (table.columns[i].text === 'text') { + textColumnIndex = i; + } else if (table.columns[i].text === 'tags') { + tagsColumnIndex = i; + } + } + + if (timeColumnIndex === -1) { + return Promise.reject({ + message: 'Missing mandatory time column in annotation query.', + }); + } + + const list = []; + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]; + const timeEnd = + timeEndColumnIndex !== -1 && row[timeEndColumnIndex] ? Math.floor(row[timeEndColumnIndex]) : undefined; + list.push({ + annotation: options.annotation, + time: Math.floor(row[timeColumnIndex]), + timeEnd, + title: row[titleColumnIndex], + text: row[textColumnIndex], + tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [], + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/postgres/specs/datasource.test.ts b/public/app/plugins/datasource/postgres/specs/datasource.test.ts new file mode 100644 index 0000000..d6da46a --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/datasource.test.ts @@ -0,0 +1,585 @@ +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { FetchResponse } from '@grafana/runtime'; +import { dateTime, toUtc } from '@grafana/data'; + +import { PostgresDatasource } from '../datasource'; +import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer'; +import { TimeSrv } from '../../../../features/dashboard/services/TimeSrv'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +describe('PostgreSQLDatasource', () => { + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + const setupTestContext = (data: any) => { + jest.clearAllMocks(); + fetchMock.mockImplementation(() => of(createFetchResponse(data))); + + const templateSrv: TemplateSrv = new TemplateSrv(); + const raw = { + from: toUtc('2018-04-25 10:00'), + to: toUtc('2018-04-25 11:00'), + }; + const timeSrvMock = ({ + timeRange: () => ({ + from: raw.from, + to: raw.to, + raw: raw, + }), + } as unknown) as TimeSrv; + const variable = { ...initialCustomVariableModelState }; + const ds = new PostgresDatasource({ name: 'dsql' }, templateSrv, timeSrvMock); + + return { ds, templateSrv, timeSrvMock, variable }; + }; + + // https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing + const runMarbleTest = (args: { + options: any; + values: { [marble: string]: FetchResponse }; + marble: string; + expectedValues: { [marble: string]: any }; + expectedMarble: string; + }) => { + const { expectedValues, expectedMarble, options, values, marble } = args; + const scheduler: TestScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + const { ds } = setupTestContext({}); + + scheduler.run(({ cold, expectObservable }) => { + const source = cold(marble, values); + jest.clearAllMocks(); + fetchMock.mockImplementation(() => source); + + const result = ds.query(options); + expectObservable(result).toBe(expectedMarble, expectedValues); + }); + }; + + describe('When performing a time series query', () => { + it('should transform response correctly', () => { + const options = { + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'time_series', + rawQuery: true, + rawSql: 'select time, metric from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + }, + ], + }; + + const data = { + results: { + A: { + refId: 'A', + meta: { + executedQueryString: 'select time, metric from grafana_metric', + rowCount: 0, + }, + series: [ + { + name: 'America', + points: [[30.226249741223704, 1599643351085]], + }, + ], + tables: null, + dataframes: null, + }, + }, + }; + + const values = { a: createFetchResponse(data) }; + const marble = '-a|'; + const expectedMarble = '-a|'; + const expectedValues = { + a: { + data: [ + { + datapoints: [[30.226249741223704, 1599643351085]], + meta: { + executedQueryString: 'select time, metric from grafana_metric', + rowCount: 0, + }, + refId: 'A', + target: 'America', + }, + ], + }, + }; + + runMarbleTest({ options, marble, values, expectedMarble, expectedValues }); + }); + }); + + describe('When performing a table query', () => { + it('should transform response correctly', () => { + const options = { + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'table', + rawQuery: true, + rawSql: 'select time, metric, value from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + }, + ], + }; + + const data = { + results: { + A: { + refId: 'A', + meta: { + executedQueryString: 'select time, metric, value from grafana_metric', + rowCount: 1, + }, + series: null, + tables: [ + { + columns: [ + { + text: 'time', + }, + { + text: 'metric', + }, + { + text: 'value', + }, + ], + rows: [[1599643351085, 'America', 30.226249741223704]], + }, + ], + dataframes: null, + }, + }, + }; + + const values = { a: createFetchResponse(data) }; + const marble = '-a|'; + const expectedMarble = '-a|'; + const expectedValues = { + a: { + data: [ + { + columns: [ + { + text: 'time', + }, + { + text: 'metric', + }, + { + text: 'value', + }, + ], + rows: [[1599643351085, 'America', 30.226249741223704]], + type: 'table', + refId: 'A', + meta: { + executedQueryString: 'select time, metric, value from grafana_metric', + rowCount: 1, + }, + }, + ], + }, + }; + + runMarbleTest({ options, marble, values, expectedMarble, expectedValues }); + }); + }); + + describe('When performing a query with hidden target', () => { + it('should return empty result and backendSrv.fetch should not be called', async () => { + const options = { + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + targets: [ + { + format: 'table', + rawQuery: true, + rawSql: 'select time, metric, value from grafana_metric', + refId: 'A', + datasource: 'gdev-ds', + hide: true, + }, + ], + }; + + const { ds } = setupTestContext({}); + + await expect(ds.query(options)).toEmitValuesWith((received) => { + expect(received[0]).toEqual({ data: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('When performing annotationQuery', () => { + it('should return annotation list', async () => { + const annotationName = 'MyAnno'; + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time, title, text, tags from table;', + }, + range: { + from: dateTime(1432288354), + to: dateTime(1432288401), + }, + }; + const data = { + results: { + MyAnno: { + refId: annotationName, + tables: [ + { + columns: [{ text: 'time' }, { text: 'text' }, { text: 'tags' }], + rows: [ + [1432288355, 'some text', 'TagA,TagB'], + [1432288390, 'some text2', ' TagB , TagC'], + [1432288400, 'some text3'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.annotationQuery(options); + + expect(results.length).toBe(3); + + expect(results[0].text).toBe('some text'); + expect(results[0].tags[0]).toBe('TagA'); + expect(results[0].tags[1]).toBe('TagB'); + + expect(results[1].tags[0]).toBe('TagB'); + expect(results[1].tags[1]).toBe('TagC'); + + expect(results[2].tags.length).toBe(0); + }); + }); + + describe('When performing metricFindQuery', () => { + it('should return list of all column values', async () => { + const query = 'select * from atable'; + const data = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.metricFindQuery(query, {}); + + expect(results.length).toBe(6); + expect(results[0].text).toBe('aTitle'); + expect(results[5].text).toBe('some text3'); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => { + it('should return list of all column values', async () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const data = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' }); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe( + "select title from atable where title LIKE 'aTit%'" + ); + expect(results).toEqual([ + { text: 'aTitle' }, + { text: 'some text' }, + { text: 'aTitle2' }, + { text: 'some text2' }, + { text: 'aTitle3' }, + { text: 'some text3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => { + it('should return list of all column values', async () => { + const query = "select title from atable where title LIKE '$__searchFilter'"; + const data = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }, { text: 'text' }], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.metricFindQuery(query, {}); + + expect(fetchMock).toBeCalledTimes(1); + expect(fetchMock.mock.calls[0][0].data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'"); + expect(results).toEqual([ + { text: 'aTitle' }, + { text: 'some text' }, + { text: 'aTitle2' }, + { text: 'some text2' }, + { text: 'aTitle3' }, + { text: 'some text3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with key, value columns', () => { + it('should return list of as text, value', async () => { + const query = 'select * from atable'; + const data = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__value' }, { text: '__text' }], + rows: [ + ['value1', 'aTitle'], + ['value2', 'aTitle2'], + ['value3', 'aTitle3'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.metricFindQuery(query, {}); + + expect(results).toEqual([ + { text: 'aTitle', value: 'value1' }, + { text: 'aTitle2', value: 'value2' }, + { text: 'aTitle3', value: 'value3' }, + ]); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { + it('should return list of unique keys', async () => { + const query = 'select * from atable'; + const data = { + results: { + tempvar: { + meta: { + rowCount: 3, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: '__text' }, { text: '__value' }], + rows: [ + ['aTitle', 'same'], + ['aTitle', 'same'], + ['aTitle', 'diff'], + ], + }, + ], + }, + }, + }; + + const { ds } = setupTestContext(data); + + const results = await ds.metricFindQuery(query, {}); + + expect(results).toEqual([{ text: 'aTitle', value: 'same' }]); + }); + }); + + describe('When interpolating variables', () => { + describe('and value is a string', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable('abc', variable)).toEqual('abc'); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable((1000 as unknown) as string, variable)).toEqual(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + const { ds, variable } = setupTestContext({}); + expect(ds.interpolateVariable(['a', 'b', 'c'], variable)).toEqual("'a','b','c'"); + }); + }); + + describe('and variable allows multi-value and is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.multi = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + + describe('and variable contains single quote', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.multi = true; + expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'"); + expect(ds.interpolateVariable("a'b'c", variable)).toEqual("'a''b''c'"); + }); + }); + + describe('and variable allows all and is a string', () => { + it('should return a quoted value', () => { + const { ds, variable } = setupTestContext({}); + variable.includeAll = true; + expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'"); + }); + }); + }); + + describe('targetContainsTemplate', () => { + it('given query that contains template variable it should return true', () => { + const rawSql = `SELECT + $__timeGroup("createdAt",'$summarize'), + avg(value) as "value", + hostname as "metric" + FROM + grafana_metric + WHERE + $__timeFilter("createdAt") AND + measurement = 'logins.count' AND + hostname IN($host) + GROUP BY time, metric + ORDER BY time`; + const query = { + rawSql, + rawQuery: true, + }; + const { templateSrv, ds } = setupTestContext({}); + + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + + expect(ds.targetContainsTemplate(query)).toBeTruthy(); + }); + + it('given query that only contains global template variable it should return false', () => { + const rawSql = `SELECT + $__timeGroup("createdAt",'$__interval'), + avg(value) as "value", + hostname as "metric" + FROM + grafana_metric + WHERE + $__timeFilter("createdAt") AND + measurement = 'logins.count' + GROUP BY time, metric + ORDER BY time`; + const query = { + rawSql, + rawQuery: true, + }; + const { templateSrv, ds } = setupTestContext({}); + + templateSrv.init([ + { type: 'query', name: 'summarize', current: { value: '1m' } }, + { type: 'query', name: 'host', current: { value: 'a' } }, + ]); + + expect(ds.targetContainsTemplate(query)).toBeFalsy(); + }); + }); +}); + +const createFetchResponse = (data: T): FetchResponse => ({ + data, + status: 200, + url: 'http://localhost:3000/api/query', + config: { url: 'http://localhost:3000/api/query' }, + type: 'basic', + statusText: 'Ok', + redirected: false, + headers: ({} as unknown) as Headers, + ok: true, +}); diff --git a/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts new file mode 100644 index 0000000..4902859 --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts @@ -0,0 +1,171 @@ +import PostgresQuery from '../postgres_query'; +import { TemplateSrv } from 'app/features/templating/template_srv'; + +describe('PostgresQuery', () => { + // @ts-ignore + const templateSrv: TemplateSrv = { + replace: jest.fn((text) => text) as any, + }; + + describe('When initializing', () => { + it('should not be in SQL mode', () => { + const query = new PostgresQuery({}, templateSrv); + expect(query.target.rawQuery).toBe(false); + }); + it('should be in SQL mode for pre query builder queries', () => { + const query = new PostgresQuery({ rawSql: 'SELECT 1' }, templateSrv); + expect(query.target.rawQuery).toBe(true); + }); + }); + + describe('When generating time column SQL', () => { + const query = new PostgresQuery({}, templateSrv); + + query.target.timeColumn = 'time'; + expect(query.buildTimeColumn()).toBe('time AS "time"'); + query.target.timeColumn = '"time"'; + expect(query.buildTimeColumn()).toBe('"time" AS "time"'); + }); + + describe('When generating time column SQL with group by time', () => { + let query = new PostgresQuery( + { timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__timeGroup(time,5m)'); + + query = new PostgresQuery({ timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'NULL'] }] }, templateSrv); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m,NULL)'); + + query = new PostgresQuery( + { timeColumn: 'time', timeColumnType: 'int4', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__unixEpochGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__unixEpochGroup(time,5m)'); + }); + + describe('When generating metric column SQL', () => { + const query = new PostgresQuery({}, templateSrv); + + query.target.metricColumn = 'host'; + expect(query.buildMetricColumn()).toBe('host AS metric'); + query.target.metricColumn = '"host"'; + expect(query.buildMetricColumn()).toBe('"host" AS metric'); + }); + + describe('When generating value column SQL', () => { + const query = new PostgresQuery({}, templateSrv); + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [ + { type: 'column', params: ['value'] }, + { type: 'alias', params: ['alias'] }, + ]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (ORDER BY time) ' + + 'THEN v - lag(v) OVER (ORDER BY time) ' + + 'WHEN lag(v) OVER (ORDER BY time) IS NULL THEN NULL ELSE v END) AS "a"' + ); + }); + + describe('When generating value column SQL with metric column', () => { + const query = new PostgresQuery({}, templateSrv); + query.target.metricColumn = 'host'; + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [ + { type: 'column', params: ['value'] }, + { type: 'alias', params: ['alias'] }, + ]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (PARTITION BY host ORDER BY time) ' + + 'THEN v - lag(v) OVER (PARTITION BY host ORDER BY time) ' + + 'WHEN lag(v) OVER (PARTITION BY host ORDER BY time) IS NULL THEN NULL ELSE v END) AS "a"' + ); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN max(v) >= lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' + + 'THEN max(v) - lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' + + 'WHEN lag(max(v)) OVER (PARTITION BY host ORDER BY time) IS NULL THEN NULL ELSE max(v) END) AS "a"' + ); + }); + + describe('When generating WHERE clause', () => { + const query = new PostgresQuery({ where: [] }, templateSrv); + + expect(query.buildWhereClause()).toBe(''); + + query.target.timeColumn = 't'; + query.target.where = [{ type: 'macro', name: '$__timeFilter' }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t)'); + + query.target.where = [{ type: 'expression', params: ['v', '=', '1'] }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n v = 1'); + + query.target.where = [ + { type: 'macro', name: '$__timeFilter' }, + { type: 'expression', params: ['v', '=', '1'] }, + ]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t) AND\n v = 1'); + }); + + describe('When generating GROUP BY clause', () => { + const query = new PostgresQuery({ group: [], metricColumn: 'none' }, templateSrv); + + expect(query.buildGroupClause()).toBe(''); + query.target.group = [{ type: 'time', params: ['5m'] }]; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1'); + query.target.metricColumn = 'm'; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1,2'); + }); + + describe('When generating complete statement', () => { + const target: any = { + timeColumn: 't', + table: 'table', + select: [[{ type: 'column', params: ['value'] }]], + where: [], + }; + let result = 'SELECT\n t AS "time",\n value\nFROM table\nORDER BY 1'; + const query = new PostgresQuery(target, templateSrv); + + expect(query.buildQuery()).toBe(result); + + query.target.metricColumn = 'm'; + result = 'SELECT\n t AS "time",\n m AS metric,\n value\nFROM table\nORDER BY 1,2'; + expect(query.buildQuery()).toBe(result); + }); +}); diff --git a/public/app/plugins/datasource/postgres/sql_part.ts b/public/app/plugins/datasource/postgres/sql_part.ts new file mode 100644 index 0000000..34ac81b --- /dev/null +++ b/public/app/plugins/datasource/postgres/sql_part.ts @@ -0,0 +1,137 @@ +import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part'; + +const index: any[] = []; + +function createPart(part: any): any { + const def = index[part.type]; + if (!def) { + return null; + } + + return new SqlPart(part, def); +} + +function register(options: any) { + index[options.type] = new SqlPartDef(options); +} + +register({ + type: 'column', + style: 'label', + params: [{ type: 'column', dynamicLookup: true }], + defaultParams: ['value'], +}); + +register({ + type: 'expression', + style: 'expression', + label: 'Expr:', + params: [ + { name: 'left', type: 'string', dynamicLookup: true }, + { name: 'op', type: 'string', dynamicLookup: true }, + { name: 'right', type: 'string', dynamicLookup: true }, + ], + defaultParams: ['value', '=', 'value'], +}); + +register({ + type: 'macro', + style: 'label', + label: 'Macro:', + params: [], + defaultParams: [], +}); + +register({ + type: 'aggregate', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'], + }, + ], + defaultParams: ['avg'], +}); + +register({ + type: 'percentile', + label: 'Aggregate:', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['percentile_cont', 'percentile_disc'], + }, + { + name: 'fraction', + type: 'number', + options: ['0.5', '0.75', '0.9', '0.95', '0.99'], + }, + ], + defaultParams: ['percentile_cont', '0.95'], +}); + +register({ + type: 'alias', + style: 'label', + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], +}); + +register({ + type: 'time', + style: 'function', + label: 'time', + params: [ + { + name: 'interval', + type: 'interval', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + { + name: 'fill', + type: 'string', + options: ['none', 'NULL', 'previous', '0'], + }, + ], + defaultParams: ['$__interval', 'none'], +}); + +register({ + type: 'window', + style: 'label', + params: [ + { + name: 'function', + type: 'string', + options: ['delta', 'increase', 'rate', 'sum'], + }, + ], + defaultParams: ['increase'], +}); + +register({ + type: 'moving_window', + style: 'label', + label: 'Moving Window:', + params: [ + { + name: 'function', + type: 'string', + options: ['avg'], + }, + { + name: 'window_size', + type: 'number', + options: ['3', '5', '7', '10', '20'], + }, + ], + defaultParams: ['avg', '5'], +}); + +export default { + create: createPart, +}; diff --git a/public/app/plugins/datasource/postgres/types.ts b/public/app/plugins/datasource/postgres/types.ts new file mode 100644 index 0000000..dc1b015 --- /dev/null +++ b/public/app/plugins/datasource/postgres/types.ts @@ -0,0 +1,13 @@ +import { MetricFindValue } from '@grafana/data'; + +export interface PostgresQueryForInterpolation { + alias?: any; + format?: any; + rawSql?: any; + refId?: any; + hide?: any; +} + +export interface PostgresMetricFindValue extends MetricFindValue { + value?: string; +} diff --git a/public/app/plugins/datasource/prometheus/README.md b/public/app/plugins/datasource/prometheus/README.md new file mode 100644 index 0000000..2c44605 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/README.md @@ -0,0 +1,7 @@ +# Prometheus Data Source - Native Plugin + +Grafana ships with **built in** support for Prometheus, the open-source service monitoring system and time series database. + +Read more about it here: + +[http://docs.grafana.org/datasources/prometheus/](http://docs.grafana.org/datasources/prometheus/) diff --git a/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts b/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts new file mode 100644 index 0000000..e4fb6ab --- /dev/null +++ b/public/app/plugins/datasource/prometheus/add_label_to_query.test.ts @@ -0,0 +1,129 @@ +import { addLabelToQuery, addLabelToSelector } from './add_label_to_query'; + +describe('addLabelToQuery()', () => { + it('should add label to simple query', () => { + expect(() => { + addLabelToQuery('foo', '', ''); + }).toThrow(); + expect(addLabelToQuery('foo', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{}', 'bar', 'baz')).toBe('foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"}'); + expect(addLabelToQuery('metric > 0.001', 'foo', 'bar')).toBe('metric{foo="bar"} > 0.001'); + }); + + it('should add custom operator', () => { + expect(addLabelToQuery('foo{}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz"}'); + expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz",x="yy"}'); + }); + + it('should not modify ranges', () => { + expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])'); + }); + + it('should detect in-order function use', () => { + expect(addLabelToQuery('sum by (xx) (foo)', 'bar', 'baz')).toBe('sum by (xx) (foo{bar="baz"})'); + }); + + it('should convert number Infinity to +Inf', () => { + expect( + addLabelToQuery('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket[5m])) by (le)', 'le', Infinity) + ).toBe('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket{le="+Inf"}[5m])) by (le)'); + }); + + it('should handle selectors with punctuation', () => { + expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe( + 'foo{bar="baz",instance="my-host.com:9100"}' + ); + expect(addLabelToQuery('foo:metric:rate1m', 'bar', 'baz')).toBe('foo:metric:rate1m{bar="baz"}'); + expect(addLabelToQuery('avg(foo:metric:rate1m{a="b"})', 'bar', 'baz')).toBe( + 'avg(foo:metric:rate1m{a="b",bar="baz"})' + ); + expect(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz')).toBe('foo{bar="baz",list="a,b,c"}'); + }); + + it('should work on arithmetical expressions', () => { + expect(addLabelToQuery('foo + foo', 'bar', 'baz')).toBe('foo{bar="baz"} + foo{bar="baz"}'); + expect(addLabelToQuery('foo{x="yy"} + metric', 'bar', 'baz')).toBe('foo{bar="baz",x="yy"} + metric{bar="baz"}'); + expect(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz')).toBe('avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'); + expect(addLabelToQuery('foo{x="yy"} * metric{y="zz",a="bb"} * metric2', 'bar', 'baz')).toBe( + 'foo{bar="baz",x="yy"} * metric{a="bb",bar="baz",y="zz"} * metric2{bar="baz"}' + ); + }); + + it('should not add duplicate labels to a query', () => { + expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe( + 'foo{bar!="baz",x="yy"}' + ); + expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe( + 'rate(metric{foo="bar"}[1m])' + ); + expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'foo{bar="baz",list="a,b,c"}' + ); + expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe( + 'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})' + ); + }); + + it('should not remove filters', () => { + expect(addLabelToQuery('{x="y"} |="yy"', 'bar', 'baz')).toBe('{bar="baz",x="y"} |="yy"'); + expect(addLabelToQuery('{x="y"} |="yy" !~"xx"', 'bar', 'baz')).toBe('{bar="baz",x="y"} |="yy" !~"xx"'); + }); + + it('should add label to query properly with Loki datasource', () => { + expect(addLabelToQuery('{job="grafana"} |= "foo-bar"', 'filename', 'test.txt', undefined, true)).toBe( + '{filename="test.txt",job="grafana"} |= "foo-bar"' + ); + }); + + it('should add labels to metrics with logical operators', () => { + expect(addLabelToQuery('foo_info or bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} or bar_info{bar="baz"}'); + expect(addLabelToQuery('foo_info and bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} and bar_info{bar="baz"}'); + }); + + it('should not add ad-hoc filter to template variables', () => { + expect(addLabelToQuery('sum(rate({job="foo"}[2m])) by (value $variable)', 'bar', 'baz')).toBe( + 'sum(rate({bar="baz",job="foo"}[2m])) by (value $variable)' + ); + }); + + it('should not add ad-hoc filter to range', () => { + expect(addLabelToQuery('avg(rate((my_metric{job="foo"} > 0)[3h:])) by (label)', 'bar', 'baz')).toBe( + 'avg(rate((my_metric{bar="baz",job="foo"} > 0)[3h:])) by (label)' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect( + addLabelToQuery( + 'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric) * 1000', + 'bar', + 'baz' + ) + ).toBe( + 'max by (id, name, type) (my_metric{bar="baz",type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric{bar="baz"}) * 1000' + ); + }); + it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => { + expect(addLabelToQuery('rate(my_metric[${__range_s}s])', 'bar', 'baz')).toBe( + 'rate(my_metric{bar="baz"}[${__range_s}s])' + ); + }); + it('should not add ad-hoc filter to labels to math operations', () => { + expect(addLabelToQuery('count(my_metric{job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1', 'bar', 'baz')).toBe( + 'count(my_metric{bar="baz",job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1' + ); + }); +}); + +describe('addLabelToSelector()', () => { + test('should add a label to an empty selector', () => { + expect(addLabelToSelector('{}', 'foo', 'bar')).toBe('{foo="bar"}'); + expect(addLabelToSelector('', 'foo', 'bar')).toBe('{foo="bar"}'); + }); + test('should add a label to a selector', () => { + expect(addLabelToSelector('{foo="bar"}', 'baz', '42')).toBe('{baz="42",foo="bar"}'); + }); + test('should add a label to a selector with custom operator', () => { + expect(addLabelToSelector('{}', 'baz', '42', '!=')).toBe('{baz!="42"}'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/add_label_to_query.ts b/public/app/plugins/datasource/prometheus/add_label_to_query.ts new file mode 100644 index 0000000..269479a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/add_label_to_query.ts @@ -0,0 +1,143 @@ +import _ from 'lodash'; + +const keywords = 'by|without|on|ignoring|group_left|group_right|bool'; +const logicalOperators = 'or|and|unless'; + +// Duplicate from mode-prometheus.js, which can't be used in tests due to global ace not being loaded. +const builtInWords = [ + keywords, + logicalOperators, + 'count|count_values|min|max|avg|sum|stddev|stdvar|bottomk|topk|quantile', + 'true|false|null|__name__|job', + 'abs|absent|ceil|changes|clamp_max|clamp_min|count_scalar|day_of_month|day_of_week|days_in_month|delta|deriv', + 'drop_common_labels|exp|floor|histogram_quantile|holt_winters|hour|idelta|increase|irate|label_replace|ln|log2', + 'log10|minute|month|predict_linear|rate|resets|round|scalar|sort|sort_desc|sqrt|time|vector|year|avg_over_time', + 'min_over_time|max_over_time|sum_over_time|count_over_time|quantile_over_time|stddev_over_time|stdvar_over_time', +] + .join('|') + .split('|'); + +// We want to extract all possible metrics and also keywords +const metricsAndKeywordsRegexp = /([A-Za-z:][\w:]*)\b(?![\]{=!",])/g; +// Safari currently doesn't support negative lookbehind. When it does, we should refactor this. +// We are creating 2 matching groups. (\$) is for the Grafana's variables such as ${__rate_s}. We want to ignore +// ${__rate_s} and not add variable to it. +const selectorRegexp = /(\$)?{([^{]*)}/g; + +export function addLabelToQuery( + query: string, + key: string, + value: string | number, + operator?: string, + hasNoMetrics?: boolean +): string { + if (!key || !value) { + throw new Error('Need label to add to query.'); + } + + // We need to make sure that we convert the value back to string because it may be a number + const transformedValue = value === Infinity ? '+Inf' : value.toString(); + + // Add empty selectors to bare metric names + let previousWord: string; + + query = query.replace(metricsAndKeywordsRegexp, (match, word, offset) => { + const isMetric = isWordMetric(query, word, offset, previousWord, hasNoMetrics); + previousWord = word; + + return isMetric ? `${word}{}` : word; + }); + + // Adding label to existing selectors + let match = selectorRegexp.exec(query); + const parts = []; + let lastIndex = 0; + let suffix = ''; + + while (match) { + const prefix = query.slice(lastIndex, match.index); + lastIndex = match.index + match[2].length + 2; + suffix = query.slice(match.index + match[0].length); + // If we matched 1st group, we know it is Grafana's variable and we don't want to add labels + if (match[1]) { + parts.push(prefix); + parts.push(match[0]); + } else { + // If we didn't match first group, we are inside selector and we want to add labels + const selector = match[2]; + const selectorWithLabel = addLabelToSelector(selector, key, transformedValue, operator); + parts.push(prefix, selectorWithLabel); + } + + match = selectorRegexp.exec(query); + } + + parts.push(suffix); + return parts.join(''); +} + +const labelRegexp = /(\w+)\s*(=|!=|=~|!~)\s*("[^"]*")/g; + +export function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) { + const parsedLabels = []; + + // Split selector into labels + if (selector) { + let match = labelRegexp.exec(selector); + while (match) { + parsedLabels.push({ key: match[1], operator: match[2], value: match[3] }); + match = labelRegexp.exec(selector); + } + } + + // Add new label + const operatorForLabelKey = labelOperator || '='; + parsedLabels.push({ key: labelKey, operator: operatorForLabelKey, value: `"${labelValue}"` }); + + // Sort labels by key and put them together + const formatted = _.chain(parsedLabels) + .uniqWith(_.isEqual) + .compact() + .sortBy('key') + .map(({ key, operator, value }) => `${key}${operator}${value}`) + .value() + .join(','); + + return `{${formatted}}`; +} + +function isPositionInsideChars(text: string, position: number, openChar: string, closeChar: string) { + const nextSelectorStart = text.slice(position).indexOf(openChar); + const nextSelectorEnd = text.slice(position).indexOf(closeChar); + return nextSelectorEnd > -1 && (nextSelectorStart === -1 || nextSelectorStart > nextSelectorEnd); +} + +function isWordMetric(query: string, word: string, offset: number, previousWord: string, hasNoMetrics?: boolean) { + const insideSelector = isPositionInsideChars(query, offset, '{', '}'); + // Handle "sum by (key) (metric)" + const previousWordIsKeyWord = previousWord && keywords.split('|').indexOf(previousWord) > -1; + // Check for colon as as "word boundary" symbol + const isColonBounded = word.endsWith(':'); + // Check for words that start with " which means that they are not metrics + const startsWithQuote = query[offset - 1] === '"'; + // Check for template variables + const isTemplateVariable = query[offset - 1] === '$'; + // Check for time units + const isTimeUnit = ['s', 'm', 'h', 'd', 'w'].includes(word) && Boolean(Number(query[offset - 1])); + + if ( + !hasNoMetrics && + !insideSelector && + !isColonBounded && + !previousWordIsKeyWord && + !startsWithQuote && + !isTemplateVariable && + !isTimeUnit && + builtInWords.indexOf(word) === -1 + ) { + return true; + } + return false; +} + +export default addLabelToQuery; diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx new file mode 100644 index 0000000..a8d3e89 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { QueryEditorHelpProps, DataQuery } from '@grafana/data'; + +const CHEAT_SHEET_ITEMS = [ + { + title: 'Request Rate', + expression: 'rate(http_request_total[5m])', + label: + 'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.', + }, + { + title: '95th Percentile of Request Latencies', + expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))', + label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.', + }, + { + title: 'Alerts Firing', + expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))', + label: 'Sums up the alerts that have been firing over the last 24 hours.', + }, + { + title: 'Step', + label: + 'Defines the graph resolution using a duration format (15s, 1m, 3h, ...). Small steps create high-resolution graphs but can be slow over larger time ranges. Using a longer step lowers the resolution and smooths the graph by producing fewer datapoints. If no step is given the resolution is calculated automatically.', + }, +]; + +const PromCheatSheet = (props: QueryEditorHelpProps) => ( +
      +

      PromQL Cheat Sheet

      + {CHEAT_SHEET_ITEMS.map((item, index) => ( +
      +
      {item.title}
      + {item.expression ? ( +
      props.onClickExample({ refId: 'A', expr: item.expression } as DataQuery)} + > + {item.expression} +
      + ) : null} +
      {item.label}
      +
      + ))} +
      +); + +export default PromCheatSheet; diff --git a/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx new file mode 100644 index 0000000..375d10a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExemplarField.tsx @@ -0,0 +1,66 @@ +import { GrafanaTheme } from '@grafana/data'; +import { IconButton, InlineLabel, Tooltip, useStyles } from '@grafana/ui'; +import { css, cx } from 'emotion'; +import React, { useEffect, useState } from 'react'; +import { PrometheusDatasource } from '../datasource'; + +interface Props { + isEnabled: boolean; + onChange: (isEnabled: boolean) => void; + datasource: PrometheusDatasource; +} + +export function PromExemplarField({ datasource, onChange, isEnabled }: Props) { + const [error, setError] = useState(); + const styles = useStyles(getStyles); + + useEffect(() => { + const subscription = datasource.exemplarErrors.subscribe((err) => { + setError(err); + }); + return () => { + subscription.unsubscribe(); + }; + }, [datasource]); + + const iconButtonStyles = cx( + { + [styles.activeIcon]: isEnabled, + }, + styles.eyeIcon + ); + + return ( + + +
      + Exemplars + { + onChange(!isEnabled); + }} + /> +
      +
      +
      + ); +} + +function getStyles(theme: GrafanaTheme) { + return { + eyeIcon: css` + margin-left: ${theme.spacing.md}; + `, + activeIcon: css` + color: ${theme.palette.blue95}; + `, + iconWrapper: css` + display: flex; + align-items: center; + `, + }; +} diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx new file mode 100644 index 0000000..992b709 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PromExploreExtraFieldProps, PromExploreExtraField } from './PromExploreExtraField'; +import { Observable } from 'rxjs'; + +const setup = (propOverrides?: PromExploreExtraFieldProps) => { + const queryType = 'range'; + const stepValue = '1'; + const query = { exemplar: false }; + const datasource = { exemplarErrors: new Observable() }; + const onStepChange = jest.fn(); + const onQueryTypeChange = jest.fn(); + const onKeyDownFunc = jest.fn(); + + const props: any = { + queryType, + stepValue, + query, + onStepChange, + onQueryTypeChange, + onKeyDownFunc, + datasource, + }; + + Object.assign(props, propOverrides); + + return render(); +}; + +describe('PromExploreExtraField', () => { + it('should render step field', () => { + setup(); + expect(screen.getByTestId('stepField')).toBeInTheDocument(); + }); + + it('should render query type field', () => { + setup(); + expect(screen.getByTestId('queryTypeField')).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx new file mode 100644 index 0000000..4391b18 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreExtraField.tsx @@ -0,0 +1,88 @@ +// Libraries +import React, { memo } from 'react'; +import { css, cx } from 'emotion'; + +// Types +import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui'; +import { PromQuery } from '../types'; +import { PromExemplarField } from './PromExemplarField'; +import { PrometheusDatasource } from '../datasource'; + +export interface PromExploreExtraFieldProps { + queryType: string; + stepValue: string; + query: PromQuery; + onStepChange: (e: React.SyntheticEvent) => void; + onKeyDownFunc: (e: React.KeyboardEvent) => void; + onQueryTypeChange: (value: string) => void; + onChange: (value: PromQuery) => void; + datasource: PrometheusDatasource; +} + +export const PromExploreExtraField: React.FC = memo( + ({ queryType, stepValue, query, onChange, onStepChange, onQueryTypeChange, onKeyDownFunc, datasource }) => { + const rangeOptions = [ + { value: 'range', label: 'Range', description: 'Run query over a range of time.' }, + { + value: 'instant', + label: 'Instant', + description: 'Run query against a single point in time. For this query, the "To" time is used.', + }, + { value: 'both', label: 'Both', description: 'Run an Instant query and a Range query.' }, + ]; + + return ( +
      + {/*Query type field*/} +
      + Query type + + +
      + {/*Step field*/} +
      + + Step + + +
      + + onChange({ ...query, exemplar: isEnabled })} + datasource={datasource} + /> +
      + ); + } +); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx new file mode 100644 index 0000000..6501fe7 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import PromExploreQueryEditor from './PromExploreQueryEditor'; +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; +import { LoadingState, PanelData, toUtc, TimeRange } from '@grafana/data'; + +const setup = (renderMethod: any, propOverrides?: object) => { + const datasourceMock: unknown = { + languageProvider: { + syntax: () => {}, + }, + }; + const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: PromQuery = { expr: '', refId: 'A', interval: '1s' }; + const range: TimeRange = { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }; + const data: PanelData = { + state: LoadingState.NotStarted, + series: [], + request: { + requestId: '1', + dashboardId: 1, + intervalMs: 1000, + interval: '1s', + panelId: 1, + range: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + scopedVars: {}, + targets: [], + timezone: 'GMT', + app: 'Grafana', + startTime: 0, + }, + timeRange: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + raw: { + from: toUtc('2020-01-01', 'YYYY-MM-DD'), + to: toUtc('2020-01-02', 'YYYY-MM-DD'), + }, + }, + }; + const history: any[] = []; + const exploreMode = 'Metrics'; + + const props: any = { + query, + data, + range, + datasource, + exploreMode, + history, + onChange, + onRunQuery, + }; + + Object.assign(props, propOverrides); + + return renderMethod(); +}; + +describe('PromExploreQueryEditor', () => { + let originalGetSelection: typeof window.getSelection; + beforeAll(() => { + originalGetSelection = window.getSelection; + window.getSelection = () => null; + }); + + afterAll(() => { + window.getSelection = originalGetSelection; + }); + + it('should render component', () => { + const wrapper = setup(shallow); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render PromQueryField with ExtraFieldElement', async () => { + // @ts-ignore strict null errpr TS2345: Argument of type '() => Promise' is not assignable to parameter of type '() => void | undefined'. + await act(async () => { + const wrapper = setup(shallow); + expect(wrapper.html()).toContain('aria-label="Prometheus extra field"'); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx new file mode 100644 index 0000000..d3aa7e0 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx @@ -0,0 +1,81 @@ +import React, { memo, FC, useEffect } from 'react'; + +// Types +import { ExploreQueryFieldProps } from '@grafana/data'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery, PromOptions } from '../types'; + +import PromQueryField from './PromQueryField'; +import { PromExploreExtraField } from './PromExploreExtraField'; + +export type Props = ExploreQueryFieldProps; + +export const PromExploreQueryEditor: FC = (props: Props) => { + const { range, query, data, datasource, history, onChange, onRunQuery } = props; + + useEffect(() => { + if (query.exemplar === undefined) { + onChange({ ...query, exemplar: true }); + } + }, [query]); + + function onChangeQueryStep(value: string) { + const { query, onChange } = props; + const nextQuery = { ...query, interval: value }; + onChange(nextQuery); + } + + function onStepChange(e: React.SyntheticEvent) { + if (e.currentTarget.value !== query.interval) { + onChangeQueryStep(e.currentTarget.value); + } + } + + function onReturnKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) { + onRunQuery(); + } + } + + function onQueryTypeChange(value: string) { + const { query, onChange } = props; + let nextQuery; + if (value === 'instant') { + nextQuery = { ...query, instant: true, range: false }; + } else if (value === 'range') { + nextQuery = { ...query, instant: false, range: true }; + } else { + nextQuery = { ...query, instant: true, range: true }; + } + onChange(nextQuery); + } + + return ( + {}} + history={history} + data={data} + ExtraFieldElement={ + + } + /> + ); +}; + +export default memo(PromExploreQueryEditor); diff --git a/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx b/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx new file mode 100644 index 0000000..7087b7d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromLink.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PanelData } from '@grafana/data'; +import { PromQuery } from '../types'; +import { PrometheusDatasource } from '../datasource'; +import PromLink from './PromLink'; + +const getPanelData = (panelDataOverrides?: Partial) => { + const panelData = { + request: { + targets: [ + { refId: 'A', datasource: 'prom1' }, + { refId: 'B', datasource: 'prom2' }, + ], + range: { + to: { + utc: () => ({ + format: jest.fn(), + }), + }, + }, + }, + }; + + return Object.assign(panelData, panelDataOverrides) as PanelData; +}; + +const getDataSource = (datasourceOverrides?: Partial) => { + const datasource = { + getPrometheusTime: () => 123, + createQuery: () => ({ expr: 'up', step: 15 }), + directUrl: 'prom1', + }; + + return (Object.assign(datasource, datasourceOverrides) as unknown) as PrometheusDatasource; +}; + +describe('PromLink', () => { + it('should show correct link for 1 component', async () => { + render( +
      + +
      + ); + expect(screen.getByText('Prometheus')).toHaveAttribute( + 'href', + 'prom1/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + }); + it('should show different link when there are 2 components with the same panel data', () => { + render( +
      + + +
      + ); + const promLinkButtons = screen.getAllByText('Prometheus'); + expect(promLinkButtons[0]).toHaveAttribute( + 'href', + 'prom1/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + expect(promLinkButtons[1]).toHaveAttribute( + 'href', + 'prom2/graph?g0.expr=up&g0.range_input=0s&g0.end_input=undefined&g0.step_input=15&g0.tab=0' + ); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromLink.tsx b/public/app/plugins/datasource/prometheus/components/PromLink.tsx new file mode 100644 index 0000000..b88c207 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromLink.tsx @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import React, { FC, useEffect, useState, memo } from 'react'; + +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; +import { DataQueryRequest, PanelData } from '@grafana/data'; + +interface Props { + datasource: PrometheusDatasource; + query: PromQuery; + panelData?: PanelData; +} + +const PromLink: FC = ({ panelData, query, datasource }) => { + const [href, setHref] = useState(''); + + useEffect(() => { + if (panelData) { + const getExternalLink = () => { + if (!panelData.request) { + return ''; + } + + const { + request: { range, interval }, + } = panelData; + + const start = datasource.getPrometheusTime(range.from, false); + const end = datasource.getPrometheusTime(range.to, true); + const rangeDiff = Math.ceil(end - start); + const endTime = range.to.utc().format('YYYY-MM-DD HH:mm'); + + const options = { + interval, + } as DataQueryRequest; + + const queryOptions = datasource.createQuery(query, options, start, end); + const expr = { + 'g0.expr': queryOptions.expr, + 'g0.range_input': rangeDiff + 's', + 'g0.end_input': endTime, + 'g0.step_input': queryOptions.step, + 'g0.tab': 0, + }; + + const args = _.map(expr, (v: string, k: string) => { + return k + '=' + encodeURIComponent(v); + }).join('&'); + return `${datasource.directUrl}/graph?${args}`; + }; + + setHref(getExternalLink()); + } + }, [panelData]); + + return ( + + Prometheus + + ); +}; + +export default memo(PromLink); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx new file mode 100644 index 0000000..bfef578 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { dateTime } from '@grafana/data'; + +import { PromQueryEditor } from './PromQueryEditor'; +import { PrometheusDatasource } from '../datasource'; +import { PromQuery } from '../types'; + +jest.mock('app/features/dashboard/services/TimeSrv', () => { + return { + getTimeSrv: () => ({ + timeRange: () => ({ + from: dateTime(), + to: dateTime(), + }), + }), + }; +}); + +const setup = (propOverrides?: object) => { + const datasourceMock: unknown = { + createQuery: jest.fn((q) => q), + getPrometheusTime: jest.fn((date, roundup) => 123), + }; + const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; + const onRunQuery = jest.fn(); + const onChange = jest.fn(); + const query: PromQuery = { expr: '', refId: 'A' }; + + const props: any = { + datasource, + onChange, + onRunQuery, + query, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as PromQueryEditor; + + return { + instance, + wrapper, + }; +}; + +describe('Render PromQueryEditor with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx new file mode 100644 index 0000000..e12f229 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx @@ -0,0 +1,201 @@ +import _ from 'lodash'; +import React, { PureComponent } from 'react'; + +// Types +import { InlineFormLabel, LegacyForms, Select } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { PrometheusDatasource } from '../datasource'; +import { PromOptions, PromQuery } from '../types'; + +import PromQueryField from './PromQueryField'; +import PromLink from './PromLink'; +import { PromExemplarField } from './PromExemplarField'; + +const { Switch } = LegacyForms; + +export type Props = QueryEditorProps; + +const FORMAT_OPTIONS: Array> = [ + { label: 'Time series', value: 'time_series' }, + { label: 'Table', value: 'table' }, + { label: 'Heatmap', value: 'heatmap' }, +]; + +const INTERVAL_FACTOR_OPTIONS: Array> = _.map([1, 2, 3, 4, 5, 10], (value: number) => ({ + value, + label: '1/' + value, +})); + +interface State { + legendFormat?: string; + formatOption: SelectableValue; + interval?: string; + intervalFactorOption: SelectableValue; + instant: boolean; + exemplar: boolean; +} + +export class PromQueryEditor extends PureComponent { + // Query target to be modified and used for queries + query: PromQuery; + + constructor(props: Props) { + super(props); + // Use default query to prevent undefined input values + const defaultQuery: Partial = { expr: '', legendFormat: '', interval: '', exemplar: true }; + const query = Object.assign({}, defaultQuery, props.query); + this.query = query; + // Query target properties that are fully controlled inputs + this.state = { + // Fully controlled text inputs + interval: query.interval, + legendFormat: query.legendFormat, + // Select options + formatOption: FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0], + intervalFactorOption: + INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor) || INTERVAL_FACTOR_OPTIONS[0], + // Switch options + instant: Boolean(query.instant), + exemplar: Boolean(query.exemplar), + }; + } + + onFieldChange = (query: PromQuery, override?: any) => { + this.query.expr = query.expr; + }; + + onFormatChange = (option: SelectableValue) => { + this.query.format = option.value; + this.setState({ formatOption: option }, this.onRunQuery); + }; + + onInstantChange = (e: React.ChangeEvent) => { + const instant = e.target.checked; + this.query.instant = instant; + this.setState({ instant }, this.onRunQuery); + }; + + onIntervalChange = (e: React.SyntheticEvent) => { + const interval = e.currentTarget.value; + this.query.interval = interval; + this.setState({ interval }); + }; + + onIntervalFactorChange = (option: SelectableValue) => { + this.query.intervalFactor = option.value; + this.setState({ intervalFactorOption: option }, this.onRunQuery); + }; + + onLegendChange = (e: React.SyntheticEvent) => { + const legendFormat = e.currentTarget.value; + this.query.legendFormat = legendFormat; + this.setState({ legendFormat }); + }; + + onExemplarChange = (isEnabled: boolean) => { + this.query.exemplar = isEnabled; + this.setState({ exemplar: isEnabled }, this.onRunQuery); + }; + + onRunQuery = () => { + const { query } = this; + // Change of query.hide happens outside of this component and is just passed as prop. We have to update it when running queries. + const { hide } = this.props.query; + this.props.onChange({ ...query, hide }); + this.props.onRunQuery(); + }; + + render() { + const { datasource, query, range, data } = this.props; + const { formatOption, instant, interval, intervalFactorOption, legendFormat, exemplar } = this.state; + + return ( +
      + + +
      +
      + + Legend + + +
      + +
      + + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + $__interval and $__rate_interval variables. The limit is absolute and not + modified by the "Resolution" setting. + + } + > + Min step + + +
      + +
      +
      Resolution
      + + + + + + +
      + + +
      +
      + ); + } +} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx new file mode 100644 index 0000000..b50a445 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx @@ -0,0 +1,270 @@ +// @ts-ignore +import RCCascader from 'rc-cascader'; +import React from 'react'; +import PromQlLanguageProvider from '../language_provider'; +import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; +import { DataSourceInstanceSettings, dateTime } from '@grafana/data'; +import { PromOptions } from '../types'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('PromQueryField', () => { + beforeAll(() => { + // @ts-ignore + window.getSelection = () => {}; + }); + + it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', () => { + const datasource = ({ + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + }, + } as unknown) as DataSourceInstanceSettings; + + const queryField = render( + {}} + onChange={() => {}} + history={[]} + /> + ); + + expect(queryField.getAllByRole('button')).toHaveLength(1); + }); + + it('renders a disabled metrics chooser if lookups are disabled in datasource settings', () => { + const datasource = ({ + languageProvider: { + start: () => Promise.resolve([]), + syntax: () => {}, + }, + } as unknown) as DataSourceInstanceSettings; + const queryField = render( + {}} + onChange={() => {}} + history={[]} + /> + ); + + const bcButton = queryField.getByRole('button'); + expect(bcButton).toBeDisabled(); + }); + + it('refreshes metrics when the data source changes', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const queryField = render( + + ); + + checkMetricsInCascader(await screen.findByRole('button'), metrics); + + const changedMetrics = ['baz', 'moo']; + queryField.rerender( + + ); + + // If we check the cascader right away it should be in loading state + let cascader = screen.getByRole('button'); + expect(cascader.textContent).toContain('Loading'); + checkMetricsInCascader(await screen.findByRole('button'), changedMetrics); + }); + + it('does not refreshes metrics when after rounding to minute time range does not change', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const changedMetrics = ['foo', 'baz']; + const range = { + from: dateTime('2020-10-28T00:00:00Z'), + to: dateTime('2020-10-28T01:00:00Z'), + }; + + const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] }); + const queryField = render( + + ); + checkMetricsInCascader(await screen.findByRole('button'), metrics); + + const newRange = { + from: dateTime('2020-10-28T00:00:01Z'), + to: dateTime('2020-10-28T01:00:01Z'), + }; + queryField.rerender( + + ); + let cascader = screen.getByRole('button'); + // Should not show loading + expect(cascader.textContent).toContain('Metrics'); + checkMetricsInCascader(await screen.findByRole('button'), metrics); + }); + + it('refreshes metrics when time range changes but dont show loading state', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const changedMetrics = ['baz', 'moo']; + const range = { + from: dateTime('2020-10-28T00:00:00Z'), + to: dateTime('2020-10-28T01:00:00Z'), + }; + + const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] }); + const queryField = render( + + ); + checkMetricsInCascader(await screen.findByRole('button'), metrics); + + const newRange = { + from: dateTime('2020-10-28T01:00:00Z'), + to: dateTime('2020-10-28T02:00:00Z'), + }; + queryField.rerender( + + ); + let cascader = screen.getByRole('button'); + // Should not show loading + expect(cascader.textContent).toContain('Metrics'); + checkMetricsInCascader(cascader, metrics); + }); +}); + +describe('groupMetricsByPrefix()', () => { + it('returns an empty group for no metrics', () => { + expect(groupMetricsByPrefix([])).toEqual([]); + }); + + it('returns options grouped by prefix', () => { + expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([ + { + value: 'foo', + children: [ + { + value: 'foo_metric', + }, + ], + }, + ]); + }); + + it('returns options grouped by prefix with metadata', () => { + expect(groupMetricsByPrefix(['foo_metric'], { foo_metric: [{ type: 'TYPE', help: 'my help' }] })).toMatchObject([ + { + value: 'foo', + children: [ + { + value: 'foo_metric', + title: 'foo_metric\nTYPE\nmy help', + }, + ], + }, + ]); + }); + + it('returns options without prefix as toplevel option', () => { + expect(groupMetricsByPrefix(['metric'])).toMatchObject([ + { + value: 'metric', + }, + ]); + }); + + it('returns recording rules grouped separately', () => { + expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([ + { + value: RECORDING_RULES_GROUP, + children: [ + { + value: ':foo_metric:', + }, + ], + }, + ]); + }); +}); + +function makeLanguageProvider(options: { metrics: string[][] }) { + const metricsStack = [...options.metrics]; + return ({ + histogramMetrics: [] as any, + metrics: [], + metricsMetadata: {}, + lookupsDisabled: false, + start() { + this.metrics = metricsStack.shift(); + return Promise.resolve([]); + }, + } as any) as PromQlLanguageProvider; +} + +function checkMetricsInCascader(cascader: HTMLElement, metrics: string[]) { + fireEvent.keyDown(cascader, { keyCode: 40 }); + let listNodes = screen.getAllByRole('menuitem'); + for (const node of listNodes) { + expect(metrics).toContain(node.innerHTML); + } +} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx new file mode 100644 index 0000000..ac997ec --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -0,0 +1,391 @@ +import _ from 'lodash'; +import React, { ReactNode } from 'react'; + +import { Plugin } from 'slate'; +import { + ButtonCascader, + CascaderOption, + SlatePrism, + TypeaheadInput, + TypeaheadOutput, + QueryField, + BracesPlugin, + DOMUtil, + SuggestionsState, +} from '@grafana/ui'; + +import { LanguageMap, languages as prismLanguages } from 'prismjs'; + +// dom also includes Element polyfills +import { PromQuery, PromOptions, PromMetricsMetadata } from '../types'; +import { roundMsToMin } from '../language_utils'; +import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; +import { + ExploreQueryFieldProps, + QueryHint, + isDataFrame, + toLegacyResponseData, + HistoryItem, + TimeRange, +} from '@grafana/data'; +import { PrometheusDatasource } from '../datasource'; + +const HISTOGRAM_GROUP = '__histograms__'; +export const RECORDING_RULES_GROUP = '__recording_rules__'; + +function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, metrics: string[]) { + if (metricsLookupDisabled) { + return '(Disabled)'; + } + + if (!hasSyntax) { + return 'Loading metrics...'; + } + + if (metrics && metrics.length === 0) { + return '(No metrics found)'; + } + + return 'Metrics'; +} + +function addMetricsMetadata(metric: string, metadata?: PromMetricsMetadata): CascaderOption { + const option: CascaderOption = { label: metric, value: metric }; + if (metadata && metadata[metric]) { + const { type = '', help } = metadata[metric][0]; + option.title = [metric, type.toUpperCase(), help].join('\n'); + } + return option; +} + +export function groupMetricsByPrefix(metrics: string[], metadata?: PromMetricsMetadata): CascaderOption[] { + // Filter out recording rules and insert as first option + const ruleRegex = /:\w+:/; + const ruleNames = metrics.filter((metric) => ruleRegex.test(metric)); + const rulesOption = { + label: 'Recording rules', + value: RECORDING_RULES_GROUP, + children: ruleNames + .slice() + .sort() + .map((name) => ({ label: name, value: name })), + }; + + const options = ruleNames.length > 0 ? [rulesOption] : []; + + const delimiter = '_'; + const metricsOptions = _.chain(metrics) + .filter((metric: string) => !ruleRegex.test(metric)) + .groupBy((metric: string) => metric.split(delimiter)[0]) + .map( + (metricsForPrefix: string[], prefix: string): CascaderOption => { + const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix; + const children = prefixIsMetric ? [] : metricsForPrefix.sort().map((m) => addMetricsMetadata(m, metadata)); + return { + children, + label: prefix, + value: prefix, + }; + } + ) + .sortBy('label') + .value(); + + return [...options, ...metricsOptions]; +} + +export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string { + // Modify suggestion based on context + switch (typeaheadContext) { + case 'context-labels': { + const nextChar = DOMUtil.getNextCharacter(); + if (!nextChar || nextChar === '}' || nextChar === ',') { + suggestion += '='; + } + break; + } + + case 'context-label-values': { + // Always add quotes and remove existing ones instead + if (!typeaheadText.match(/^(!?=~?"|")/)) { + suggestion = `"${suggestion}`; + } + if (DOMUtil.getNextCharacter() !== '"') { + suggestion = `${suggestion}"`; + } + break; + } + + default: + } + return suggestion; +} + +interface PromQueryFieldProps extends ExploreQueryFieldProps { + history: Array>; + ExtraFieldElement?: ReactNode; +} + +interface PromQueryFieldState { + metricsOptions: any[]; + syntaxLoaded: boolean; + hint: QueryHint | null; +} + +class PromQueryField extends React.PureComponent { + plugins: Plugin[]; + languageProviderInitializationPromise: CancelablePromise; + + constructor(props: PromQueryFieldProps, context: React.Context) { + super(props, context); + + this.plugins = [ + BracesPlugin(), + SlatePrism( + { + onlyIn: (node: any) => node.type === 'code_block', + getSyntax: (node: any) => 'promql', + }, + { ...(prismLanguages as LanguageMap), promql: this.props.datasource.languageProvider.syntax } + ), + ]; + + this.state = { + metricsOptions: [], + syntaxLoaded: false, + hint: null, + }; + } + + componentDidMount() { + if (this.props.datasource.languageProvider) { + this.refreshMetrics(); + } + this.refreshHint(); + } + + componentWillUnmount() { + if (this.languageProviderInitializationPromise) { + this.languageProviderInitializationPromise.cancel(); + } + } + + componentDidUpdate(prevProps: PromQueryFieldProps) { + const { + data, + datasource: { languageProvider }, + range, + } = this.props; + + if (languageProvider !== prevProps.datasource.languageProvider) { + // We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every + // query run if using relative range. + this.setState({ + metricsOptions: [], + syntaxLoaded: false, + }); + } + + const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range); + // We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute) + if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) { + this.refreshMetrics(); + } + + if (data && prevProps.data && prevProps.data.series !== data.series) { + this.refreshHint(); + } + } + + refreshHint = () => { + const { datasource, query, data } = this.props; + + if (!data || data.series.length === 0) { + this.setState({ hint: null }); + return; + } + + const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series; + const hints = datasource.getQueryHints(query, result); + let hint = hints.length > 0 ? hints[0] : null; + + // Hint for big disabled lookups + if (!hint && datasource.lookupsDisabled) { + hint = { + label: `Labels and metrics lookup was disabled in data source settings.`, + type: 'INFO', + }; + } + this.setState({ hint }); + }; + + refreshMetrics = async () => { + const { + datasource: { languageProvider }, + } = this.props; + + this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start()); + + try { + const remainingTasks = await this.languageProviderInitializationPromise.promise; + await Promise.all(remainingTasks); + this.onUpdateLanguage(); + } catch (err) { + if (!err.isCanceled) { + throw err; + } + } + }; + + rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean { + if (range && prevRange) { + const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf()); + const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf()); + // If both are same, don't need to refresh. + return !(sameMinuteFrom && sameMinuteTo); + } + return false; + } + + onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => { + let query; + if (selectedOptions.length === 1) { + const selectedOption = selectedOptions[0]; + if (!selectedOption.children || selectedOption.children.length === 0) { + query = selectedOption.value; + } else { + // Ignore click on group + return; + } + } else { + const prefix = selectedOptions[0].value; + const metric = selectedOptions[1].value; + if (prefix === HISTOGRAM_GROUP) { + query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`; + } else { + query = metric; + } + } + this.onChangeQuery(query, true); + }; + + onChangeQuery = (value: string, override?: boolean) => { + // Send text change to parent + const { query, onChange, onRunQuery } = this.props; + if (onChange) { + const nextQuery: PromQuery = { ...query, expr: value }; + onChange(nextQuery); + + if (override && onRunQuery) { + onRunQuery(); + } + } + }; + + onClickHintFix = () => { + const { datasource, query, onChange, onRunQuery } = this.props; + const { hint } = this.state; + + onChange(datasource.modifyQuery(query, hint!.fix!.action)); + onRunQuery(); + }; + + onUpdateLanguage = () => { + const { + datasource: { languageProvider }, + } = this.props; + const { histogramMetrics, metrics, metricsMetadata } = languageProvider; + + if (!metrics) { + return; + } + + // Build metrics tree + const metricsByPrefix = groupMetricsByPrefix(metrics, metricsMetadata); + const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm })); + const metricsOptions = + histogramMetrics.length > 0 + ? [ + { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions, isLeaf: false }, + ...metricsByPrefix, + ] + : metricsByPrefix; + + this.setState({ metricsOptions, syntaxLoaded: true }); + }; + + onTypeahead = async (typeahead: TypeaheadInput): Promise => { + const { + datasource: { languageProvider }, + } = this.props; + + if (!languageProvider) { + return { suggestions: [] }; + } + + const { history } = this.props; + const { prefix, text, value, wrapperClasses, labelKey } = typeahead; + + const result = await languageProvider.provideCompletionItems( + { text, value, prefix, wrapperClasses, labelKey }, + { history } + ); + + return result; + }; + + render() { + const { + datasource, + datasource: { languageProvider }, + query, + ExtraFieldElement, + } = this.props; + const { metricsOptions, syntaxLoaded, hint } = this.state; + const cleanText = languageProvider ? languageProvider.cleanText : undefined; + const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, metricsOptions); + const buttonDisabled = !(syntaxLoaded && metricsOptions && metricsOptions.length > 0); + + return ( + <> +
      +
      + + {chooserText} + +
      +
      + +
      +
      + {ExtraFieldElement} + {hint ? ( +
      +
      + {hint.label}{' '} + {hint.fix ? ( + + {hint.fix.label} + + ) : null} +
      +
      + ) : null} + + ); + } +} + +export default PromQueryField; diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap new file mode 100644 index 0000000..24118aa --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromExploreQueryEditor should render component 1`] = ` + + } + data={ + Object { + "request": Object { + "app": "Grafana", + "dashboardId": 1, + "interval": "1s", + "intervalMs": 1000, + "panelId": 1, + "range": Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + }, + "requestId": "1", + "scopedVars": Object {}, + "startTime": 0, + "targets": Array [], + "timezone": "GMT", + }, + "series": Array [], + "state": "NotStarted", + "timeRange": Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + }, + } + } + datasource={ + Object { + "languageProvider": Object { + "syntax": [Function], + }, + } + } + history={Array []} + onBlur={[Function]} + onChange={[MockFunction]} + onRunQuery={[MockFunction]} + query={ + Object { + "expr": "", + "interval": "1s", + "refId": "A", + } + } + range={ + Object { + "from": "2020-01-01T00:00:00.000Z", + "raw": Object { + "from": "2020-01-01T00:00:00.000Z", + "to": "2020-01-02T00:00:00.000Z", + }, + "to": "2020-01-02T00:00:00.000Z", + } + } +/> +`; diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap new file mode 100644 index 0000000..4b6beed --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap @@ -0,0 +1,197 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render PromQueryEditor with basic options should render 1`] = ` +
      + +
      +
      + + Legend + + +
      +
      + + An additional lower limit for the step parameter of the Prometheus query and for the + + + $__interval + + and + + $__rate_interval + + variables. The limit is absolute and not modified by the "Resolution" setting. + + } + width={7} + > + Min step + + +
      +
      +
      + Resolution +
      + + + + + +
      + +
      +
      +`; diff --git a/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx new file mode 100644 index 0000000..93b77ce --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { PromSettings } from './PromSettings'; +import { PromOptions } from '../types'; +import { config } from 'app/core/config'; + +export type Props = DataSourcePluginOptionsEditorProps; +export const ConfigEditor = (props: Props) => { + const { options, onOptionsChange } = props; + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx new file mode 100644 index 0000000..859b374 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarSetting.tsx @@ -0,0 +1,97 @@ +import { Button, InlineField, InlineSwitch, Input } from '@grafana/ui'; +import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; +import { css } from 'emotion'; +import React, { useState } from 'react'; +import { ExemplarTraceIdDestination } from '../types'; + +type Props = { + value: ExemplarTraceIdDestination; + onChange: (value: ExemplarTraceIdDestination) => void; + onDelete: () => void; +}; + +export default function ExemplarSetting({ value, onChange, onDelete }: Props) { + const [isInternalLink, setIsInternalLink] = useState(Boolean(value.datasourceUid)); + + return ( +
      + + <> + setIsInternalLink(ev.currentTarget.checked)} /> +
      + ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx new file mode 100644 index 0000000..24c0466 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/ExemplarsSettings.tsx @@ -0,0 +1,53 @@ +import { Button } from '@grafana/ui'; +import { css } from 'emotion'; +import React from 'react'; +import { ExemplarTraceIdDestination } from '../types'; +import ExemplarSetting from './ExemplarSetting'; + +type Props = { + options?: ExemplarTraceIdDestination[]; + onChange: (value: ExemplarTraceIdDestination[]) => void; +}; + +export function ExemplarsSettings({ options, onChange }: Props) { + return ( + <> +

      Exemplars

      + + {options && + options.map((option, index) => { + return ( + { + const newOptions = [...options]; + newOptions.splice(index, 1, newField); + onChange(newOptions); + }} + onDelete={() => { + const newOptions = [...options]; + newOptions.splice(index, 1); + onChange(newOptions); + }} + /> + ); + })} + + + + ); +} diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx new file mode 100644 index 0000000..ae42697 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx @@ -0,0 +1,140 @@ +import React, { SyntheticEvent } from 'react'; +import { render, screen } from '@testing-library/react'; +import { EventsWithValidation } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { getValueFromEventItem, promSettingsValidationEvents, PromSettings } from './PromSettings'; +import { createDefaultConfigOptions } from './mocks'; + +describe('PromSettings', () => { + describe('getValueFromEventItem', () => { + describe('when called with undefined', () => { + it('then it should return empty string', () => { + const result = getValueFromEventItem( + (undefined as unknown) as SyntheticEvent | SelectableValue + ); + expect(result).toEqual(''); + }); + }); + + describe('when called with an input event', () => { + it('then it should return value from currentTarget', () => { + const value = 'An input value'; + const result = getValueFromEventItem({ currentTarget: { value } }); + expect(result).toEqual(value); + }); + }); + + describe('when called with a select event', () => { + it('then it should return value', () => { + const value = 'A select value'; + const result = getValueFromEventItem({ value }); + expect(result).toEqual(value); + }); + }); + }); + + describe('promSettingsValidationEvents', () => { + const validationEvents = promSettingsValidationEvents; + + it('should have one event handlers', () => { + expect(Object.keys(validationEvents).length).toEqual(1); + }); + + it('should have an onBlur handler', () => { + expect(validationEvents.hasOwnProperty(EventsWithValidation.onBlur)).toBe(true); + }); + + it('should have one rule', () => { + expect(validationEvents[EventsWithValidation.onBlur].length).toEqual(1); + }); + + describe('when calling the rule with an empty string', () => { + it('then it should return true', () => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule('')).toBe(true); + }); + }); + + it.each` + value | expected + ${'1ms'} | ${true} + ${'1M'} | ${true} + ${'1w'} | ${true} + ${'1d'} | ${true} + ${'1h'} | ${true} + ${'1m'} | ${true} + ${'1s'} | ${true} + ${'1y'} | ${true} + `( + "when calling the rule with correct formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); + } + ); + + it.each` + value | expected + ${'1 ms'} | ${false} + ${'1x'} | ${false} + ${' '} | ${false} + ${'w'} | ${false} + ${'1.0s'} | ${false} + `( + "when calling the rule with incorrect formatted value: '$value' then result should be '$expected'", + ({ value, expected }) => { + expect(validationEvents[EventsWithValidation.onBlur][0].rule(value)).toBe(expected); + } + ); + }); + describe('PromSettings component', () => { + const defaultProps = createDefaultConfigOptions(); + + it('should show POST httpMethod if no httpMethod and no url', () => { + const options = defaultProps; + options.url = ''; + options.jsonData.httpMethod = ''; + + render( +
      + {}} options={options} /> +
      + ); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show GET httpMethod if no httpMethod and url', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = ''; + + render( +
      + {}} options={options} /> +
      + ); + expect(screen.getByText('GET')).toBeInTheDocument(); + }); + it('should show POST httpMethod if POST httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'POST'; + + render( +
      + {}} options={options} /> +
      + ); + expect(screen.getByText('POST')).toBeInTheDocument(); + }); + it('should show GET httpMethod if GET httpMethod is configured', () => { + const options = defaultProps; + options.url = 'test_url'; + options.jsonData.httpMethod = 'GET'; + + render( +
      + {}} options={options} /> +
      + ); + expect(screen.getByText('GET')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx new file mode 100644 index 0000000..6a0386d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -0,0 +1,167 @@ +import { + DataSourcePluginOptionsEditorProps, + onUpdateDatasourceJsonDataOptionChecked, + SelectableValue, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { EventsWithValidation, InlineFormLabel, LegacyForms, regexValidation } from '@grafana/ui'; +import React, { SyntheticEvent } from 'react'; +import { PromOptions } from '../types'; +import { ExemplarsSettings } from './ExemplarsSettings'; +const { Select, Input, FormField, Switch } = LegacyForms; + +const httpOptions = [ + { value: 'POST', label: 'POST' }, + { value: 'GET', label: 'GET' }, +]; + +type Props = Pick, 'options' | 'onOptionsChange'>; + +export const PromSettings = (props: Props) => { + const { options, onOptionsChange } = props; + + /** + * We want to change the default httpMethod to 'POST' for all of the new Prometheus data sources instances (no url) added in 7.5+. + * We are explicitly adding httpMethod, as previously it could be undefined and defaulted to 'GET'. + * Undefined httpMethod is still going to be considered 'GET' for backward compatibility reasons, but if users open data + * source settings it is going to be set to 'GET' explicitly and it will be selected in httpMethod dropdown as 'GET'. + * */ + + if (!options.jsonData.httpMethod) { + options.url ? (options.jsonData.httpMethod = 'GET') : (options.jsonData.httpMethod = 'POST'); + } + + return ( + <> +
      +
      +
      + + } + tooltip="Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s." + /> +
      +
      +
      +
      + + } + tooltip="Set the Prometheus query timeout." + /> +
      +
      +
      + + HTTP Method + + +
      +
      + step + +
      +
      + +
      +
      Field formatsFor title and text fields, use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.
      +
      +
      + Title + +
      +
      + Tags + +
      +
      +
      + Text + +
      +
      +
      + +
      Other options
      +
      +
      + + +
      +
      +
      diff --git a/public/app/plugins/datasource/prometheus/plugin.json b/public/app/plugins/datasource/prometheus/plugin.json new file mode 100644 index 0000000..99c8148 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/plugin.json @@ -0,0 +1,47 @@ +{ + "type": "datasource", + "name": "Prometheus", + "id": "prometheus", + "category": "tsdb", + + "includes": [ + { + "type": "dashboard", + "name": "Prometheus Stats", + "path": "dashboards/prometheus_stats.json" + }, + { + "type": "dashboard", + "name": "Prometheus 2.0 Stats", + "path": "dashboards/prometheus_2_stats.json" + }, + { + "type": "dashboard", + "name": "Grafana Stats", + "path": "dashboards/grafana_stats.json" + } + ], + "metrics": true, + "alerting": true, + "annotations": true, + "queryOptions": { + "minInterval": true + }, + "info": { + "description": "Open source time series database & alerting", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/prometheus_logo.svg", + "large": "img/prometheus_logo.svg" + }, + "links": [ + { + "name": "Learn more", + "url": "https://prometheus.io/" + } + ] + } +} diff --git a/public/app/plugins/datasource/prometheus/promql.test.ts b/public/app/plugins/datasource/prometheus/promql.test.ts new file mode 100644 index 0000000..2b0cc20 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/promql.test.ts @@ -0,0 +1,22 @@ +import promql from './promql'; +import Prism from 'prismjs'; + +describe('Loki syntax', () => { + it('should highlight Loki query correctly', () => { + expect(Prism.highlight('{key="val#ue"}', promql, 'promql')).toBe( + '{key="val#ue"}' + ); + expect(Prism.highlight('{key="#value"}', promql, 'promql')).toBe( + '{key="#value"}' + ); + expect(Prism.highlight('{key="value#"}', promql, 'promql')).toBe( + '{key="value#"}' + ); + expect(Prism.highlight('#test{key="value"}', promql, 'promql')).toBe( + '#test{key="value"}' + ); + expect(Prism.highlight('{key="value"}#test', promql, 'promql')).toBe( + '{key="value"}#test' + ); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts new file mode 100644 index 0000000..96cab5f --- /dev/null +++ b/public/app/plugins/datasource/prometheus/promql.ts @@ -0,0 +1,442 @@ +import { Grammar } from 'prismjs'; +import { CompletionItem } from '@grafana/ui'; + +export const RATE_RANGES: CompletionItem[] = [ + { label: '$__interval', sortText: '$__interval' }, + { label: '$__rate_interval', sortText: '$__rate_interval' }, + { label: '1m', sortText: '00:01:00' }, + { label: '5m', sortText: '00:05:00' }, + { label: '10m', sortText: '00:10:00' }, + { label: '30m', sortText: '00:30:00' }, + { label: '1h', sortText: '01:00:00' }, + { label: '1d', sortText: '24:00:00' }, +]; + +export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; + +const AGGREGATION_OPERATORS: CompletionItem[] = [ + { + label: 'sum', + insertText: 'sum', + documentation: 'Calculate sum over dimensions', + }, + { + label: 'min', + insertText: 'min', + documentation: 'Select minimum over dimensions', + }, + { + label: 'max', + insertText: 'max', + documentation: 'Select maximum over dimensions', + }, + { + label: 'avg', + insertText: 'avg', + documentation: 'Calculate the average over dimensions', + }, + { + label: 'stddev', + insertText: 'stddev', + documentation: 'Calculate population standard deviation over dimensions', + }, + { + label: 'stdvar', + insertText: 'stdvar', + documentation: 'Calculate population standard variance over dimensions', + }, + { + label: 'count', + insertText: 'count', + documentation: 'Count number of elements in the vector', + }, + { + label: 'count_values', + insertText: 'count_values', + documentation: 'Count number of elements with the same value', + }, + { + label: 'bottomk', + insertText: 'bottomk', + documentation: 'Smallest k elements by sample value', + }, + { + label: 'topk', + insertText: 'topk', + documentation: 'Largest k elements by sample value', + }, + { + label: 'quantile', + insertText: 'quantile', + documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions', + }, +]; + +export const FUNCTIONS = [ + ...AGGREGATION_OPERATORS, + { + insertText: 'abs', + label: 'abs', + detail: 'abs(v instant-vector)', + documentation: 'Returns the input vector with all sample values converted to their absolute value.', + }, + { + insertText: 'absent', + label: 'absent', + detail: 'absent(v instant-vector)', + documentation: + 'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.', + }, + { + insertText: 'ceil', + label: 'ceil', + detail: 'ceil(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.', + }, + { + insertText: 'changes', + label: 'changes', + detail: 'changes(v range-vector)', + documentation: + 'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.', + }, + { + insertText: 'clamp_max', + label: 'clamp_max', + detail: 'clamp_max(v instant-vector, max scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.', + }, + { + insertText: 'clamp_min', + label: 'clamp_min', + detail: 'clamp_min(v instant-vector, min scalar)', + documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.', + }, + { + insertText: 'count_scalar', + label: 'count_scalar', + detail: 'count_scalar(v instant-vector)', + documentation: + 'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.', + }, + { + insertText: 'day_of_month', + label: 'day_of_month', + detail: 'day_of_month(v=vector(time()) instant-vector)', + documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.', + }, + { + insertText: 'day_of_week', + label: 'day_of_week', + detail: 'day_of_week(v=vector(time()) instant-vector)', + documentation: + 'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.', + }, + { + insertText: 'days_in_month', + label: 'days_in_month', + detail: 'days_in_month(v=vector(time()) instant-vector)', + documentation: + 'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.', + }, + { + insertText: 'delta', + label: 'delta', + detail: 'delta(v range-vector)', + documentation: + 'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.', + }, + { + insertText: 'deriv', + label: 'deriv', + detail: 'deriv(v range-vector)', + documentation: + 'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.', + }, + { + insertText: 'drop_common_labels', + label: 'drop_common_labels', + detail: 'drop_common_labels(instant-vector)', + documentation: 'Drops all labels that have the same name and value across all series in the input vector.', + }, + { + insertText: 'exp', + label: 'exp', + detail: 'exp(v instant-vector)', + documentation: + 'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`', + }, + { + insertText: 'floor', + label: 'floor', + detail: 'floor(v instant-vector)', + documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.', + }, + { + insertText: 'histogram_quantile', + label: 'histogram_quantile', + detail: 'histogram_quantile(φ float, b instant-vector)', + documentation: + 'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.', + }, + { + insertText: 'holt_winters', + label: 'holt_winters', + detail: 'holt_winters(v range-vector, sf scalar, tf scalar)', + documentation: + 'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.', + }, + { + insertText: 'hour', + label: 'hour', + detail: 'hour(v=vector(time()) instant-vector)', + documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.', + }, + { + insertText: 'idelta', + label: 'idelta', + detail: 'idelta(v range-vector)', + documentation: + 'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.', + }, + { + insertText: 'increase', + label: 'increase', + detail: 'increase(v range-vector)', + documentation: + 'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.', + }, + { + insertText: 'irate', + label: 'irate', + detail: 'irate(v range-vector)', + documentation: + 'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.', + }, + { + insertText: 'label_replace', + label: 'label_replace', + detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)', + documentation: + "For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.", + }, + { + insertText: 'ln', + label: 'ln', + detail: 'ln(v instant-vector)', + documentation: + 'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`', + }, + { + insertText: 'log2', + label: 'log2', + detail: 'log2(v instant-vector)', + documentation: + 'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'log10', + label: 'log10', + detail: 'log10(v instant-vector)', + documentation: + 'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.', + }, + { + insertText: 'minute', + label: 'minute', + detail: 'minute(v=vector(time()) instant-vector)', + documentation: + 'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.', + }, + { + insertText: 'month', + label: 'month', + detail: 'month(v=vector(time()) instant-vector)', + documentation: + 'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.', + }, + { + insertText: 'predict_linear', + label: 'predict_linear', + detail: 'predict_linear(v range-vector, t scalar)', + documentation: + 'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.', + }, + { + insertText: 'rate', + label: 'rate', + detail: 'rate(v range-vector)', + documentation: + "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.", + }, + { + insertText: 'resets', + label: 'resets', + detail: 'resets(v range-vector)', + documentation: + 'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.', + }, + { + insertText: 'round', + label: 'round', + detail: 'round(v instant-vector, to_nearest=1 scalar)', + documentation: + 'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.', + }, + { + insertText: 'scalar', + label: 'scalar', + detail: 'scalar(v instant-vector)', + documentation: + 'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.', + }, + { + insertText: 'sort', + label: 'sort', + detail: 'sort(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in ascending order.', + }, + { + insertText: 'sort_desc', + label: 'sort_desc', + detail: 'sort_desc(v instant-vector)', + documentation: 'Returns vector elements sorted by their sample values, in descending order.', + }, + { + insertText: 'sqrt', + label: 'sqrt', + detail: 'sqrt(v instant-vector)', + documentation: 'Calculates the square root of all elements in `v`.', + }, + { + insertText: 'time', + label: 'time', + detail: 'time()', + documentation: + 'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.', + }, + { + insertText: 'vector', + label: 'vector', + detail: 'vector(s scalar)', + documentation: 'Returns the scalar `s` as a vector with no labels.', + }, + { + insertText: 'year', + label: 'year', + detail: 'year(v=vector(time()) instant-vector)', + documentation: 'Returns the year for each of the given times in UTC.', + }, + { + insertText: 'avg_over_time', + label: 'avg_over_time', + detail: 'avg_over_time(range-vector)', + documentation: 'The average value of all points in the specified interval.', + }, + { + insertText: 'min_over_time', + label: 'min_over_time', + detail: 'min_over_time(range-vector)', + documentation: 'The minimum value of all points in the specified interval.', + }, + { + insertText: 'max_over_time', + label: 'max_over_time', + detail: 'max_over_time(range-vector)', + documentation: 'The maximum value of all points in the specified interval.', + }, + { + insertText: 'sum_over_time', + label: 'sum_over_time', + detail: 'sum_over_time(range-vector)', + documentation: 'The sum of all values in the specified interval.', + }, + { + insertText: 'count_over_time', + label: 'count_over_time', + detail: 'count_over_time(range-vector)', + documentation: 'The count of all values in the specified interval.', + }, + { + insertText: 'quantile_over_time', + label: 'quantile_over_time', + detail: 'quantile_over_time(scalar, range-vector)', + documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.', + }, + { + insertText: 'stddev_over_time', + label: 'stddev_over_time', + detail: 'stddev_over_time(range-vector)', + documentation: 'The population standard deviation of the values in the specified interval.', + }, + { + insertText: 'stdvar_over_time', + label: 'stdvar_over_time', + detail: 'stdvar_over_time(range-vector)', + documentation: 'The population standard variance of the values in the specified interval.', + }, +]; + +const tokenizer: Grammar = { + comment: { + pattern: /#.*/, + }, + 'context-aggregation': { + pattern: /((by|without)\s*)\([^)]*\)/, // by () + lookbehind: true, + inside: { + 'label-key': { + pattern: /[^(),\s][^,)]*[^),\s]*/, + alias: 'attr-name', + }, + punctuation: /[()]/, + }, + }, + 'context-labels': { + pattern: /\{[^}]*(?=}?)/, + greedy: true, + inside: { + comment: { + pattern: /#.*/, + }, + 'label-key': { + pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, + alias: 'attr-name', + greedy: true, + }, + 'label-value': { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + alias: 'attr-value', + }, + punctuation: /[{]/, + }, + }, + function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'), + 'context-range': [ + { + pattern: /\[[^\]]*(?=])/, // [1m] + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + { + pattern: /(offset\s+)\w+/, // offset 1m + lookbehind: true, + inside: { + 'range-duration': { + pattern: /\b\d+[smhdwy]\b/i, + alias: 'number', + }, + }, + }, + ], + number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, + operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'), + punctuation: /[{};()`,.]/, +}; + +export default tokenizer; diff --git a/public/app/plugins/datasource/prometheus/query_hints.test.ts b/public/app/plugins/datasource/prometheus/query_hints.test.ts new file mode 100644 index 0000000..1dbaf79 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/query_hints.test.ts @@ -0,0 +1,144 @@ +import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from './query_hints'; +import { PrometheusDatasource } from './datasource'; + +describe('getQueryHints()', () => { + it('returns no hints for no series', () => { + expect(getQueryHints('', [])).toEqual([]); + }); + + it('returns no hints for empty series', () => { + expect(getQueryHints('', [{ datapoints: [] }])).toEqual([]); + }); + + it('returns a rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total', series); + + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Metric metric_total looks like a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'metric_total', + }, + }, + }); + }); + + it('returns a certain rate hint for a counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const mock: unknown = { languageProvider: { metricsMetadata: { foo: [{ type: 'counter' }] } } }; + const datasource = mock as PrometheusDatasource; + + let hints = getQueryHints('foo', series, datasource); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Metric foo is a counter.', + fix: { + action: { + type: 'ADD_RATE', + query: 'foo', + }, + }, + }); + + // Test substring match not triggering hint + hints = getQueryHints('foo_foo', series, datasource); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has a rate', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('rate(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns no rate hint for a counter metric that already has an increase', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('increase(metric_total[1m])', series); + expect(hints).toEqual([]); + }); + + it('returns a rate hint w/o action for a complex counter metric', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('sum(metric_total)', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('rate()'); + expect(hints![0].fix).toBeUndefined(); + }); + + it('returns a histogram hint for a bucket series', () => { + const series = [{ datapoints: [[23, 1000]] }]; + const hints = getQueryHints('metric_bucket', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + label: 'Time series has buckets, you probably wanted a histogram.', + fix: { + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query: 'metric_bucket', + }, + }, + }); + }); + + it('returns a sum hint when many time series results are returned for a simple metric', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, (_) => ({ + datapoints: [ + [0, 0], + [0, 0], + ], + })); + const hints = getQueryHints('metric', series); + expect(hints!.length).toBe(1); + expect(hints![0]).toMatchObject({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: 'metric', + preventSubmit: true, + }, + }, + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts new file mode 100644 index 0000000..52f4867 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -0,0 +1,129 @@ +import _ from 'lodash'; +import { QueryHint, QueryFix } from '@grafana/data'; +import { PrometheusDatasource } from './datasource'; + +/** + * Number of time series results needed before starting to suggest sum aggregation hints + */ +export const SUM_HINT_THRESHOLD_COUNT = 20; + +export function getQueryHints(query: string, series?: any[], datasource?: PrometheusDatasource): QueryHint[] { + const hints = []; + + // ..._bucket metric needs a histogram_quantile() + const histogramMetric = query.trim().match(/^\w+_bucket$/); + if (histogramMetric) { + const label = 'Time series has buckets, you probably wanted a histogram.'; + hints.push({ + type: 'HISTOGRAM_QUANTILE', + label, + fix: { + label: 'Fix by adding histogram_quantile().', + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query, + }, + } as QueryFix, + }); + } + + // Check for need of rate() + if (query.indexOf('rate(') === -1 && query.indexOf('increase(') === -1) { + // Use metric metadata for exact types + const nameMatch = query.match(/\b(\w+_(total|sum|count))\b/); + let counterNameMetric = nameMatch ? nameMatch[1] : ''; + const metricsMetadata = datasource?.languageProvider?.metricsMetadata ?? {}; + const metricMetadataKeys = Object.keys(metricsMetadata); + let certain = false; + + if (metricMetadataKeys.length > 0) { + counterNameMetric = + metricMetadataKeys.find((metricName) => { + // Only considering first type information, could be non-deterministic + const metadata = metricsMetadata[metricName][0]; + if (metadata.type.toLowerCase() === 'counter') { + const metricRegex = new RegExp(`\\b${metricName}\\b`); + if (query.match(metricRegex)) { + certain = true; + return true; + } + } + return false; + }) ?? ''; + } + + if (counterNameMetric) { + const simpleMetric = query.trim().match(/^\w+$/); + const verb = certain ? 'is' : 'looks like'; + let label = `Metric ${counterNameMetric} ${verb} a counter.`; + let fix: QueryFix | undefined; + + if (simpleMetric) { + fix = { + label: 'Fix by adding rate().', + action: { + type: 'ADD_RATE', + query, + }, + }; + } else { + label = `${label} Try applying a rate() function.`; + } + + hints.push({ + type: 'APPLY_RATE', + label, + fix, + }); + } + } + + // Check for recording rules expansion + if (datasource && datasource.ruleMappings) { + const mapping = datasource.ruleMappings; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) > -1) { + return { + ...acc, + [ruleName]: mapping[ruleName], + }; + } + return acc; + }, {}); + if (_.size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', + label, + fix: ({ + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + mapping: mappingForQuery, + }, + } as any) as QueryFix, + }); + } + } + + if (series && series.length >= SUM_HINT_THRESHOLD_COUNT) { + const simpleMetric = query.trim().match(/^\w+$/); + if (simpleMetric) { + hints.push({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: query, + preventSubmit: true, + }, + } as QueryFix, + }); + } + } + + return hints; +} diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts new file mode 100644 index 0000000..1e47b24 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts @@ -0,0 +1,587 @@ +import { DataFrame, FieldType } from '@grafana/data'; +import { transform } from './result_transformer'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: () => ({ + replace: (str: string) => str, + }), + getDataSourceSrv: () => { + return { + getInstanceSettings: () => { + return { name: 'Tempo' }; + }, + }; + }, +})); + +const matrixResponse = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [1, '10'], + [2, '0'], + ], + }, + ], + }, +}; + +describe('Prometheus Result Transformer', () => { + const options: any = { target: {}, query: {} }; + describe('When nothing is returned', () => { + it('should return empty array', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const series = transform({ data: response } as any, options); + expect(series).toEqual([]); + }); + it('should return empty array', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result).toHaveLength(0); + }); + }); + + describe('When resultFormat is table', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [1443454528, '3846'], + [1443454530, '3848'], + ], + }, + { + metric: { + __name__: 'test2', + instance: 'localhost:8080', + job: 'otherjob', + }, + values: [ + [1443454529, '3847'], + [1443454531, '3849'], + ], + }, + ], + }, + }; + + it('should return data frame', () => { + const result = transform({ data: response } as any, { + ...options, + target: { + responseListLength: 0, + refId: 'A', + format: 'table', + }, + }); + expect(result[0].fields[0].values.toArray()).toEqual([ + 1443454528000, + 1443454530000, + 1443454529000, + 1443454531000, + ]); + expect(result[0].fields[0].name).toBe('Time'); + expect(result[0].fields[0].type).toBe(FieldType.time); + expect(result[0].fields[1].values.toArray()).toEqual(['test', 'test', 'test2', 'test2']); + expect(result[0].fields[1].name).toBe('__name__'); + expect(result[0].fields[1].config.filterable).toBe(true); + expect(result[0].fields[1].type).toBe(FieldType.string); + expect(result[0].fields[2].values.toArray()).toEqual(['', '', 'localhost:8080', 'localhost:8080']); + expect(result[0].fields[2].name).toBe('instance'); + expect(result[0].fields[2].type).toBe(FieldType.string); + expect(result[0].fields[3].values.toArray()).toEqual(['testjob', 'testjob', 'otherjob', 'otherjob']); + expect(result[0].fields[3].name).toBe('job'); + expect(result[0].fields[3].type).toBe(FieldType.string); + expect(result[0].fields[4].values.toArray()).toEqual([3846, 3848, 3847, 3849]); + expect(result[0].fields[4].name).toEqual('Value'); + expect(result[0].fields[4].type).toBe(FieldType.number); + expect(result[0].refId).toBe('A'); + }); + + it('should include refId if response count is more than 2', () => { + const result = transform({ data: response } as any, { + ...options, + target: { + refId: 'B', + format: 'table', + }, + responseListLength: 2, + }); + + expect(result[0].fields[4].name).toEqual('Value #B'); + }); + }); + + describe('When resultFormat is table and instant = true', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '3846'], + }, + ], + }, + }; + + it('should return data frame', () => { + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[0].values.toArray()).toEqual([1443454528000]); + expect(result[0].fields[0].name).toBe('Time'); + expect(result[0].fields[1].values.toArray()).toEqual(['test']); + expect(result[0].fields[1].name).toBe('__name__'); + expect(result[0].fields[2].values.toArray()).toEqual(['testjob']); + expect(result[0].fields[2].name).toBe('job'); + expect(result[0].fields[3].values.toArray()).toEqual([3846]); + expect(result[0].fields[3].name).toEqual('Value'); + }); + + it('should return le label values parsed as numbers', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { le: '102' }, + value: [1594908838, '0'], + }, + ], + }, + }; + const result = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[1].values.toArray()).toEqual([102]); + expect(result[0].fields[1].type).toEqual(FieldType.number); + }); + }); + + describe('When instant = true', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '3846'], + }, + ], + }, + }; + + it('should return data frame', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, query: { instant: true } }); + expect(result[0].name).toBe('test{job="testjob"}'); + }); + }); + + describe('When resultFormat is heatmap', () => { + const getResponse = (result: any) => ({ + status: 'success', + data: { + resultType: 'matrix', + result, + }, + }); + + const options = { + format: 'heatmap', + start: 1445000010, + end: 1445000030, + legendFormat: '{{le}}', + }; + + it('should convert cumulative histogram to regular', () => { + const response = getResponse([ + { + metric: { __name__: 'test', job: 'testjob', le: '1' }, + values: [ + [1445000010, '10'], + [1445000020, '10'], + [1445000030, '0'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '2' }, + values: [ + [1445000010, '20'], + [1445000020, '10'], + [1445000030, '30'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '3' }, + values: [ + [1445000010, '30'], + [1445000020, '10'], + [1445000030, '40'], + ], + }, + ]); + + const result = transform({ data: response } as any, { query: options, target: options } as any); + expect(result[0].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[0].fields[1].values.toArray()).toEqual([10, 10, 0]); + expect(result[1].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[1].fields[1].values.toArray()).toEqual([10, 0, 30]); + expect(result[2].fields[0].values.toArray()).toEqual([1445000010000, 1445000020000, 1445000030000]); + expect(result[2].fields[1].values.toArray()).toEqual([10, 0, 10]); + }); + + it('should handle missing datapoints', () => { + const response = getResponse([ + { + metric: { __name__: 'test', job: 'testjob', le: '1' }, + values: [ + [1445000010, '1'], + [1445000020, '2'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '2' }, + values: [ + [1445000010, '2'], + [1445000020, '5'], + [1445000030, '1'], + ], + }, + { + metric: { __name__: 'test', job: 'testjob', le: '3' }, + values: [ + [1445000010, '3'], + [1445000020, '7'], + ], + }, + ]); + const result = transform({ data: response } as any, { query: options, target: options } as any); + expect(result[0].fields[1].values.toArray()).toEqual([1, 2]); + expect(result[1].fields[1].values.toArray()).toEqual([1, 3, 1]); + expect(result[2].fields[1].values.toArray()).toEqual([1, 2]); + }); + }); + + describe('When the response is a matrix', () => { + it('should have labels with the value field', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob', instance: 'testinstance' }, + values: [ + [0, '10'], + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result: DataFrame[] = transform({ data: response } as any, { + ...options, + }); + + expect(result[0].fields[1].labels).toBeDefined(); + expect(result[0].fields[1].labels?.instance).toBe('testinstance'); + expect(result[0].fields[1].labels?.job).toBe('testjob'); + }); + + it('should transform into a data frame', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [0, '10'], + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result: DataFrame[] = transform({ data: response } as any, { + ...options, + query: { + start: 0, + end: 2, + }, + }); + expect(result[0].fields[0].values.toArray()).toEqual([0, 1000, 2000]); + expect(result[0].fields[1].values.toArray()).toEqual([10, 10, 0]); + expect(result[0].name).toBe('test{job="testjob"}'); + }); + + it('should fill null values', () => { + const result = transform({ data: matrixResponse } as any, { ...options, query: { step: 1, start: 0, end: 2 } }); + + expect(result[0].fields[0].values.toArray()).toEqual([0, 1000, 2000]); + expect(result[0].fields[1].values.toArray()).toEqual([null, 10, 0]); + }); + + it('should use __name__ label as series name', () => { + const result = transform({ data: matrixResponse } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + }, + }); + expect(result[0].name).toEqual('test{job="testjob"}'); + }); + + it('should use query as series name when __name__ is not available and metric is empty', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: {}, + values: [[0, '10']], + }, + ], + }, + }; + const expr = 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))'; + const result = transform({ data: response } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + expr, + }, + }); + expect(result[0].name).toEqual(expr); + }); + + it('should set frame name to undefined if no __name__ label but there are other labels', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { job: 'testjob' }, + values: [ + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result = transform({ data: response } as any, { + ...options, + query: { + step: 1, + start: 0, + end: 2, + }, + }); + expect(result[0].name).toBe('{job="testjob"}'); + }); + + it('should not set displayName for ValueFields', () => { + const result = transform({ data: matrixResponse } as any, options); + expect(result[0].fields[1].config.displayName).toBeUndefined(); + expect(result[0].fields[1].config.displayNameFromDS).toBe('test{job="testjob"}'); + }); + + it('should align null values with step', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + values: [ + [4, '10'], + [8, '10'], + ], + }, + ], + }, + }; + + const result = transform({ data: response } as any, { ...options, query: { step: 2, start: 0, end: 8 } }); + expect(result[0].fields[0].values.toArray()).toEqual([0, 2000, 4000, 6000, 8000]); + expect(result[0].fields[1].values.toArray()).toEqual([null, null, 10, null, 10]); + }); + }); + + describe('When infinity values are returned', () => { + describe('When resultType is scalar', () => { + const response = { + status: 'success', + data: { + resultType: 'scalar', + result: [1443454528, '+Inf'], + }, + }; + + it('should correctly parse values', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[1].values.toArray()).toEqual([Number.POSITIVE_INFINITY]); + }); + }); + + describe('When resultType is vector', () => { + const response = { + status: 'success', + data: { + resultType: 'vector', + result: [ + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '+Inf'], + }, + { + metric: { __name__: 'test', job: 'testjob' }, + value: [1443454528, '-Inf'], + }, + ], + }, + }; + + describe('When format is table', () => { + it('should correctly parse values', () => { + const result: DataFrame[] = transform({ data: response } as any, { ...options, target: { format: 'table' } }); + expect(result[0].fields[3].values.toArray()).toEqual([Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]); + }); + }); + }); + }); + + const exemplarsResponse = { + status: 'success', + data: [ + { + seriesLabels: { __name__: 'test' }, + exemplars: [ + { + timestamp: 1610449069.957, + labels: { traceID: '5020b5bc45117f07' }, + value: 0.002074123, + }, + ], + }, + ], + }; + + describe('When the response is exemplar data', () => { + it('should return as an data frame with a dataTopic annotations', () => { + const result = transform({ data: exemplarsResponse } as any, options); + + expect(result[0].meta?.dataTopic).toBe('annotations'); + expect(result[0].fields.length).toBe(4); // __name__, traceID, Time, Value + expect(result[0].length).toBe(1); + }); + + it('should return with an empty array when data is empty', () => { + const result = transform( + { + data: { + status: 'success', + data: [], + }, + } as any, + options + ); + + expect(result).toHaveLength(0); + }); + + it('should remove exemplars that are too close to each other', () => { + const response = { + status: 'success', + data: [ + { + exemplars: [ + { + timestamp: 1610449070.0, + value: 5, + }, + { + timestamp: 1610449070.0, + value: 1, + }, + { + timestamp: 1610449070.5, + value: 13, + }, + { + timestamp: 1610449070.3, + value: 20, + }, + ], + }, + ], + }; + /** + * the standard deviation for the above values is 8.4 this means that we show the highest + * value (20) and then the next value should be 2 times the standard deviation which is 1 + **/ + const result = transform({ data: response } as any, options); + expect(result[0].length).toBe(2); + }); + + describe('data link', () => { + it('should be added to the field if found with url', () => { + const result = transform({ data: exemplarsResponse } as any, { + ...options, + exemplarTraceIdDestinations: [{ name: 'traceID', url: 'http://localhost' }], + }); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(true); + }); + + it('should be added to the field if found with internal link', () => { + const result = transform({ data: exemplarsResponse } as any, { + ...options, + exemplarTraceIdDestinations: [{ name: 'traceID', datasourceUid: 'jaeger' }], + }); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(true); + }); + + it('should not add link if exemplarTraceIdDestinations is not configured', () => { + const result = transform({ data: exemplarsResponse } as any, options); + + expect(result[0].fields.some((f) => f.config.links?.length)).toBe(false); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts new file mode 100644 index 0000000..4b67e09 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -0,0 +1,473 @@ +import { + ArrayDataFrame, + ArrayVector, + DataFrame, + DataLink, + DataTopic, + Field, + FieldType, + formatLabels, + getDisplayProcessor, + Labels, + MutableField, + ScopedVars, + TIME_SERIES_TIME_FIELD_NAME, + TIME_SERIES_VALUE_FIELD_NAME, +} from '@grafana/data'; +import { FetchResponse, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; +import { descending, deviation } from 'd3'; +import { + ExemplarTraceIdDestination, + isExemplarData, + isMatrixData, + MatrixOrVectorResult, + PromDataSuccessResponse, + PromMetric, + PromQuery, + PromQueryRequest, + PromValue, + TransformOptions, +} from './types'; + +const POSITIVE_INFINITY_SAMPLE_VALUE = '+Inf'; +const NEGATIVE_INFINITY_SAMPLE_VALUE = '-Inf'; + +interface TimeAndValue { + [TIME_SERIES_TIME_FIELD_NAME]: number; + [TIME_SERIES_VALUE_FIELD_NAME]: number; +} + +export function transform( + response: FetchResponse, + transformOptions: { + query: PromQueryRequest; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; + target: PromQuery; + responseListLength: number; + scopedVars?: ScopedVars; + mixedQueries?: boolean; + } +) { + // Create options object from transformOptions + const options: TransformOptions = { + format: transformOptions.target.format, + step: transformOptions.query.step, + legendFormat: transformOptions.target.legendFormat, + start: transformOptions.query.start, + end: transformOptions.query.end, + query: transformOptions.query.expr, + responseListLength: transformOptions.responseListLength, + scopedVars: transformOptions.scopedVars, + refId: transformOptions.target.refId, + valueWithRefId: transformOptions.target.valueWithRefId, + meta: { + /** + * Fix for showing of Prometheus results in Explore table. + * We want to show result of instant query always in table and result of range query based on target.runAll; + */ + preferredVisualisationType: getPreferredVisualisationType( + transformOptions.query.instant, + transformOptions.mixedQueries + ), + }, + }; + const prometheusResult = response.data.data; + + if (isExemplarData(prometheusResult)) { + const events: TimeAndValue[] = []; + prometheusResult.forEach((exemplarData) => { + const data = exemplarData.exemplars.map((exemplar) => { + return { + [TIME_SERIES_TIME_FIELD_NAME]: exemplar.timestamp * 1000, + [TIME_SERIES_VALUE_FIELD_NAME]: exemplar.value, + ...exemplar.labels, + ...exemplarData.seriesLabels, + }; + }); + events.push(...data); + }); + + // Grouping exemplars by step + const sampledExemplars = sampleExemplars(events, options); + + const dataFrame = new ArrayDataFrame(sampledExemplars); + dataFrame.meta = { dataTopic: DataTopic.Annotations }; + + // Add data links if configured + if (transformOptions.exemplarTraceIdDestinations?.length) { + for (const exemplarTraceIdDestination of transformOptions.exemplarTraceIdDestinations) { + const traceIDField = dataFrame.fields.find((field) => field.name === exemplarTraceIdDestination!.name); + if (traceIDField) { + const links = getDataLinks(exemplarTraceIdDestination); + traceIDField.config.links = traceIDField.config.links?.length + ? [...traceIDField.config.links, ...links] + : links; + } + } + } + return [dataFrame]; + } + + if (!prometheusResult?.result) { + return []; + } + + // Return early if result type is scalar + if (prometheusResult.resultType === 'scalar') { + return [ + { + meta: options.meta, + refId: options.refId, + length: 1, + fields: [getTimeField([prometheusResult.result]), getValueField({ data: [prometheusResult.result] })], + }, + ]; + } + + // Return early again if the format is table, this needs special transformation. + if (options.format === 'table') { + const tableData = transformMetricDataToTable(prometheusResult.result, options); + return [tableData]; + } + + // Process matrix and vector results to DataFrame + const dataFrame: DataFrame[] = []; + prometheusResult.result.forEach((data: MatrixOrVectorResult) => dataFrame.push(transformToDataFrame(data, options))); + + // When format is heatmap use the already created data frames and transform it more + if (options.format === 'heatmap') { + dataFrame.sort(sortSeriesByLabel); + const seriesList = transformToHistogramOverTime(dataFrame); + return seriesList; + } + + // Return matrix or vector result as DataFrame[] + return dataFrame; +} + +function getDataLinks(options: ExemplarTraceIdDestination): DataLink[] { + const dataLinks: DataLink[] = []; + + if (options.datasourceUid) { + const dataSourceSrv = getDataSourceSrv(); + const dsSettings = dataSourceSrv.getInstanceSettings(options.datasourceUid); + + dataLinks.push({ + title: `Query with ${dsSettings?.name}`, + url: '', + internal: { + query: { query: '${__value.raw}', queryType: 'getTrace' }, + datasourceUid: options.datasourceUid, + datasourceName: dsSettings?.name ?? 'Data source not found', + }, + }); + } + + if (options.url) { + dataLinks.push({ + title: `Go to ${options.url}`, + url: options.url, + targetBlank: true, + }); + } + return dataLinks; +} + +/** + * Reduce the density of the exemplars by making sure that the highest value exemplar is included + * and then only the ones that are 2 times the standard deviation of the all the values. + * This makes sure not to show too many dots near each other. + */ +function sampleExemplars(events: TimeAndValue[], options: TransformOptions) { + const step = options.step || 15; + const bucketedExemplars: { [ts: string]: TimeAndValue[] } = {}; + const values: number[] = []; + for (const exemplar of events) { + // Align exemplar timestamp to nearest step second + const alignedTs = String(Math.floor(exemplar[TIME_SERIES_TIME_FIELD_NAME] / 1000 / step) * step * 1000); + if (!bucketedExemplars[alignedTs]) { + // New bucket found + bucketedExemplars[alignedTs] = []; + } + bucketedExemplars[alignedTs].push(exemplar); + values.push(exemplar[TIME_SERIES_VALUE_FIELD_NAME]); + } + + // Getting exemplars from each bucket + const standardDeviation = deviation(values); + const sampledBuckets = Object.keys(bucketedExemplars).sort(); + const sampledExemplars = []; + for (const ts of sampledBuckets) { + const exemplarsInBucket = bucketedExemplars[ts]; + if (exemplarsInBucket.length === 1) { + sampledExemplars.push(exemplarsInBucket[0]); + } else { + // Choose which values to sample + const bucketValues = exemplarsInBucket.map((ex) => ex[TIME_SERIES_VALUE_FIELD_NAME]).sort(descending); + const sampledBucketValues = bucketValues.reduce((acc: number[], curr) => { + if (acc.length === 0) { + // First value is max and is always added + acc.push(curr); + } else { + // Then take values only when at least 2 standard deviation distance to previously taken value + const prev = acc[acc.length - 1]; + if (standardDeviation && prev - curr >= 2 * standardDeviation) { + acc.push(curr); + } + } + return acc; + }, []); + // Find the exemplars for the sampled values + sampledExemplars.push( + ...sampledBucketValues.map( + (value) => exemplarsInBucket.find((ex) => ex[TIME_SERIES_VALUE_FIELD_NAME] === value)! + ) + ); + } + } + return sampledExemplars; +} + +function getPreferredVisualisationType(isInstantQuery?: boolean, mixedQueries?: boolean) { + if (isInstantQuery) { + return 'table'; + } + + return mixedQueries ? 'graph' : undefined; +} + +/** + * Transforms matrix and vector result from Prometheus result to DataFrame + */ +function transformToDataFrame(data: MatrixOrVectorResult, options: TransformOptions): DataFrame { + const { name, labels } = createLabelInfo(data.metric, options); + + const fields: Field[] = []; + + if (isMatrixData(data)) { + const stepMs = options.step ? options.step * 1000 : NaN; + let baseTimestamp = options.start * 1000; + const dps: PromValue[] = []; + + for (const value of data.values) { + let dpValue: number | null = parseSampleValue(value[1]); + + if (isNaN(dpValue)) { + dpValue = null; + } + + const timestamp = value[0] * 1000; + for (let t = baseTimestamp; t < timestamp; t += stepMs) { + dps.push([t, null]); + } + baseTimestamp = timestamp + stepMs; + dps.push([timestamp, dpValue]); + } + + const endTimestamp = options.end * 1000; + for (let t = baseTimestamp; t <= endTimestamp; t += stepMs) { + dps.push([t, null]); + } + fields.push(getTimeField(dps, true)); + fields.push(getValueField({ data: dps, parseValue: false, labels, displayNameFromDS: name })); + } else { + fields.push(getTimeField([data.value])); + fields.push(getValueField({ data: [data.value], labels, displayNameFromDS: name })); + } + + return { + meta: options.meta, + refId: options.refId, + length: fields[0].values.length, + fields, + name, + }; +} + +function transformMetricDataToTable(md: MatrixOrVectorResult[], options: TransformOptions): DataFrame { + if (!md || md.length === 0) { + return { + meta: options.meta, + refId: options.refId, + length: 0, + fields: [], + }; + } + + const valueText = options.responseListLength > 1 || options.valueWithRefId ? `Value #${options.refId}` : 'Value'; + + const timeField = getTimeField([]); + const metricFields = Object.keys(md.reduce((acc, series) => ({ ...acc, ...series.metric }), {})) + .sort() + .map((label) => { + // Labels have string field type, otherwise table tries to figure out the type which can result in unexpected results + // Only "le" label has a number field type + const numberField = label === 'le'; + return { + name: label, + config: { filterable: true }, + type: numberField ? FieldType.number : FieldType.string, + values: new ArrayVector(), + }; + }); + const valueField = getValueField({ data: [], valueName: valueText }); + + md.forEach((d) => { + if (isMatrixData(d)) { + d.values.forEach((val) => { + timeField.values.add(val[0] * 1000); + metricFields.forEach((metricField) => metricField.values.add(getLabelValue(d.metric, metricField.name))); + valueField.values.add(parseSampleValue(val[1])); + }); + } else { + timeField.values.add(d.value[0] * 1000); + metricFields.forEach((metricField) => metricField.values.add(getLabelValue(d.metric, metricField.name))); + valueField.values.add(parseSampleValue(d.value[1])); + } + }); + + return { + meta: options.meta, + refId: options.refId, + length: timeField.values.length, + fields: [timeField, ...metricFields, valueField], + }; +} + +function getLabelValue(metric: PromMetric, label: string): string | number { + if (metric.hasOwnProperty(label)) { + if (label === 'le') { + return parseSampleValue(metric[label]); + } + return metric[label]; + } + return ''; +} + +function getTimeField(data: PromValue[], isMs = false): MutableField { + return { + name: TIME_SERIES_TIME_FIELD_NAME, + type: FieldType.time, + config: {}, + values: new ArrayVector(data.map((val) => (isMs ? val[0] : val[0] * 1000))), + }; +} +type ValueFieldOptions = { + data: PromValue[]; + valueName?: string; + parseValue?: boolean; + labels?: Labels; + displayNameFromDS?: string; +}; + +function getValueField({ + data, + valueName = TIME_SERIES_VALUE_FIELD_NAME, + parseValue = true, + labels, + displayNameFromDS, +}: ValueFieldOptions): MutableField { + return { + name: valueName, + type: FieldType.number, + display: getDisplayProcessor(), + config: { + displayNameFromDS, + }, + labels, + values: new ArrayVector(data.map((val) => (parseValue ? parseSampleValue(val[1]) : val[1]))), + }; +} + +function createLabelInfo(labels: { [key: string]: string }, options: TransformOptions) { + if (options?.legendFormat) { + const title = renderTemplate(getTemplateSrv().replace(options.legendFormat, options?.scopedVars), labels); + return { name: title, labels }; + } + + const { __name__, ...labelsWithoutName } = labels; + const labelPart = formatLabels(labelsWithoutName); + let title = `${__name__ ?? ''}${labelPart}`; + + if (!title) { + title = options.query; + } + + return { name: title, labels: labelsWithoutName }; +} + +export function getOriginalMetricName(labelData: { [key: string]: string }) { + const metricName = labelData.__name__ || ''; + delete labelData.__name__; + const labelPart = Object.entries(labelData) + .map((label) => `${label[0]}="${label[1]}"`) + .join(','); + return `${metricName}{${labelPart}}`; +} + +export function renderTemplate(aliasPattern: string, aliasData: { [key: string]: string }) { + const aliasRegex = /\{\{\s*(.+?)\s*\}\}/g; + return aliasPattern.replace(aliasRegex, (_match, g1) => { + if (aliasData[g1]) { + return aliasData[g1]; + } + return ''; + }); +} + +function transformToHistogramOverTime(seriesList: DataFrame[]) { + /* t1 = timestamp1, t2 = timestamp2 etc. + t1 t2 t3 t1 t2 t3 + le10 10 10 0 => 10 10 0 + le20 20 10 30 => 10 0 30 + le30 30 10 35 => 10 0 5 + */ + for (let i = seriesList.length - 1; i > 0; i--) { + const topSeries = seriesList[i].fields.find((s) => s.name === TIME_SERIES_VALUE_FIELD_NAME); + const bottomSeries = seriesList[i - 1].fields.find((s) => s.name === TIME_SERIES_VALUE_FIELD_NAME); + if (!topSeries || !bottomSeries) { + throw new Error('Prometheus heatmap transform error: data should be a time series'); + } + + for (let j = 0; j < topSeries.values.length; j++) { + const bottomPoint = bottomSeries.values.get(j) || [0]; + topSeries.values.toArray()[j] -= bottomPoint; + } + } + + return seriesList; +} + +function sortSeriesByLabel(s1: DataFrame, s2: DataFrame): number { + let le1, le2; + + try { + // fail if not integer. might happen with bad queries + le1 = parseSampleValue(s1.name ?? ''); + le2 = parseSampleValue(s2.name ?? ''); + } catch (err) { + console.error(err); + return 0; + } + + if (le1 > le2) { + return 1; + } + + if (le1 < le2) { + return -1; + } + + return 0; +} + +function parseSampleValue(value: string): number { + switch (value) { + case POSITIVE_INFINITY_SAMPLE_VALUE: + return Number.POSITIVE_INFINITY; + case NEGATIVE_INFINITY_SAMPLE_VALUE: + return Number.NEGATIVE_INFINITY; + default: + return parseFloat(value); + } +} diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts new file mode 100644 index 0000000..cdf857d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -0,0 +1,148 @@ +import { DataQuery, DataSourceJsonData, QueryResultMeta, ScopedVars } from '@grafana/data'; +import { FetchError } from '@grafana/runtime'; + +export interface PromQuery extends DataQuery { + expr: string; + format?: string; + instant?: boolean; + range?: boolean; + exemplar?: boolean; + hinting?: boolean; + interval?: string; + intervalFactor?: number; + legendFormat?: string; + valueWithRefId?: boolean; + requestId?: string; + showingGraph?: boolean; + showingTable?: boolean; +} + +export interface PromOptions extends DataSourceJsonData { + timeInterval: string; + queryTimeout: string; + httpMethod: string; + directUrl: string; + customQueryParameters?: string; + disableMetricsLookup?: boolean; + exemplarTraceIdDestinations?: ExemplarTraceIdDestination[]; +} + +export type ExemplarTraceIdDestination = { + name: string; + url?: string; + datasourceUid?: string; +}; + +export interface PromQueryRequest extends PromQuery { + step?: number; + requestId?: string; + start: number; + end: number; + headers?: any; +} + +export interface PromMetricsMetadataItem { + type: string; + help: string; + unit?: string; +} + +export interface PromMetricsMetadata { + [metric: string]: PromMetricsMetadataItem[]; +} + +export interface PromDataSuccessResponse { + status: 'success'; + data: T; +} + +export interface PromDataErrorResponse { + status: 'error'; + errorType: string; + error: string; + data: T; +} + +export type PromData = PromMatrixData | PromVectorData | PromScalarData | PromExemplarData[]; + +export interface Labels { + [index: string]: any; +} + +export interface Exemplar { + labels: Labels; + value: number; + timestamp: number; +} + +export interface PromExemplarData { + seriesLabels: PromMetric; + exemplars: Exemplar[]; +} + +export interface PromVectorData { + resultType: 'vector'; + result: Array<{ + metric: PromMetric; + value: PromValue; + }>; +} + +export interface PromMatrixData { + resultType: 'matrix'; + result: Array<{ + metric: PromMetric; + values: PromValue[]; + }>; +} + +export interface PromScalarData { + resultType: 'scalar'; + result: PromValue; +} + +export type PromValue = [number, any]; + +export interface PromMetric { + __name__?: string; + [index: string]: any; +} + +export function isFetchErrorResponse(response: any): response is FetchError { + return 'cancelled' in response; +} + +export function isMatrixData(result: MatrixOrVectorResult): result is PromMatrixData['result'][0] { + return 'values' in result; +} + +export function isExemplarData(result: PromData): result is PromExemplarData[] { + if (result == null || !Array.isArray(result)) { + return false; + } + return result.length ? 'exemplars' in result[0] : false; +} + +export type MatrixOrVectorResult = PromMatrixData['result'][0] | PromVectorData['result'][0]; + +export interface TransformOptions { + format?: string; + step?: number; + legendFormat?: string; + start: number; + end: number; + query: string; + responseListLength: number; + scopedVars?: ScopedVars; + refId: string; + valueWithRefId?: boolean; + meta: QueryResultMeta; +} + +export interface PromLabelQueryResponse { + data: { + status: string; + data: string[]; + }; + cancelled?: boolean; +} diff --git a/public/app/plugins/datasource/prometheus/variables.ts b/public/app/plugins/datasource/prometheus/variables.ts new file mode 100644 index 0000000..27954f5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/variables.ts @@ -0,0 +1,56 @@ +import { from, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + DataQueryRequest, + DataQueryResponse, + rangeUtil, + StandardVariableQuery, + StandardVariableSupport, +} from '@grafana/data'; +import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; + +import { PrometheusDatasource } from './datasource'; +import { PromQuery } from './types'; +import PrometheusMetricFindQuery from './metric_find_query'; +import { getTimeSrv, TimeSrv } from '../../../features/dashboard/services/TimeSrv'; + +export class PrometheusVariableSupport extends StandardVariableSupport { + constructor( + private readonly datasource: PrometheusDatasource, + private readonly templateSrv: TemplateSrv = getTemplateSrv(), + private readonly timeSrv: TimeSrv = getTimeSrv() + ) { + super(); + this.query = this.query.bind(this); + } + + query(request: DataQueryRequest): Observable { + const query = request.targets[0].expr; + if (!query) { + return of({ data: [] }); + } + + const scopedVars = { + ...request.scopedVars, + __interval: { text: this.datasource.interval, value: this.datasource.interval }, + __interval_ms: { + text: rangeUtil.intervalToMs(this.datasource.interval), + value: rangeUtil.intervalToMs(this.datasource.interval), + }, + ...this.datasource.getRangeScopedVars(this.timeSrv.timeRange()), + }; + + const interpolated = this.templateSrv.replace(query, scopedVars, this.datasource.interpolateQueryExpr); + const metricFindQuery = new PrometheusMetricFindQuery(this.datasource, interpolated); + const metricFindStream = from(metricFindQuery.process()); + + return metricFindStream.pipe(map((results) => ({ data: results }))); + } + + toDataQuery(query: StandardVariableQuery): PromQuery { + return { + refId: 'PrometheusDatasource-VariableQuery', + expr: query.query, + }; + } +} diff --git a/public/app/plugins/datasource/tempo/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/ConfigEditor.tsx new file mode 100644 index 0000000..31c0a99 --- /dev/null +++ b/public/app/plugins/datasource/tempo/ConfigEditor.tsx @@ -0,0 +1,21 @@ +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { DataSourceHttpSettings } from '@grafana/ui'; +import { TraceToLogsSettings } from 'app/core/components/TraceToLogsSettings'; +import React from 'react'; + +export type Props = DataSourcePluginOptionsEditorProps; + +export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => { + return ( + <> + + + + + ); +}; diff --git a/public/app/plugins/datasource/tempo/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryField.tsx new file mode 100644 index 0000000..57b27d6 --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryField.tsx @@ -0,0 +1,35 @@ +import { ExploreQueryFieldProps } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { LegacyForms } from '@grafana/ui'; +import React from 'react'; +import { TempoDatasource, TempoQuery } from './datasource'; + +type Props = ExploreQueryFieldProps; +export class TempoQueryField extends React.PureComponent { + render() { + const { query, onChange } = this.props; + + return ( + +
      + + onChange({ + ...query, + query: e.currentTarget.value, + }) + } + /> +
      + + } + /> + ); + } +} diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts new file mode 100644 index 0000000..e23f263 --- /dev/null +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -0,0 +1,53 @@ +import { DataSourceInstanceSettings, FieldType, MutableDataFrame, PluginType } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { of } from 'rxjs'; +import { createFetchResponse } from 'test/helpers/createFetchResponse'; +import { TempoDatasource } from './datasource'; + +jest.mock('../../../../../packages/grafana-runtime/src/services/backendSrv.ts', () => ({ + getBackendSrv: () => backendSrv, +})); + +jest.mock('../../../../../packages/grafana-runtime/src/utils/queryResponse.ts', () => ({ + toDataQueryResponse: (resp: any) => resp, +})); + +describe('Tempo data source', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns trace when queried', async () => { + const responseDataFrame = new MutableDataFrame({ fields: [{ name: 'trace', values: ['{}'] }] }); + setupBackendSrv([responseDataFrame]); + const ds = new TempoDatasource(defaultSettings); + await expect(ds.query({ targets: [{ query: '12345' }] } as any)).toEmitValuesWith((response) => { + const field = response[0].data[0].fields[0]; + expect(field.name).toBe('trace'); + expect(field.type).toBe(FieldType.trace); + }); + }); +}); + +function setupBackendSrv(response: any) { + const defaultMock = () => of(createFetchResponse(response)); + + const fetchMock = jest.spyOn(backendSrv, 'fetch'); + fetchMock.mockImplementation(defaultMock); +} + +const defaultSettings: DataSourceInstanceSettings = { + id: 0, + uid: '0', + type: 'tracing', + name: 'jaeger', + meta: { + id: 'jaeger', + name: 'jaeger', + type: PluginType.datasource, + info: {} as any, + module: '', + baseUrl: '', + }, + jsonData: {}, +}; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts new file mode 100644 index 0000000..823542e --- /dev/null +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -0,0 +1,63 @@ +import { + DataFrame, + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceInstanceSettings, + FieldType, + MutableDataFrame, +} from '@grafana/data'; +import { DataSourceWithBackend } from '@grafana/runtime'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export type TempoQuery = { + query: string; +} & DataQuery; + +export class TempoDatasource extends DataSourceWithBackend { + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + } + + query(options: DataQueryRequest): Observable { + return super.query(options).pipe( + map((response) => { + if (response.error) { + return response; + } + + return { + data: [ + new MutableDataFrame({ + fields: [ + { + name: 'trace', + type: FieldType.trace, + values: [JSON.parse((response.data as DataFrame[])[0].fields[0].values.get(0))], + }, + ], + meta: { + preferredVisualisationType: 'trace', + }, + }), + ], + }; + }) + ); + } + + async testDatasource(): Promise { + const response = await super.query({ targets: [{ query: '', refId: 'A' }] } as any).toPromise(); + + if (!response.error?.message?.startsWith('failed to get trace')) { + return { status: 'error', message: 'Data source is not working' }; + } + + return { status: 'success', message: 'Data source is working' }; + } + + getQueryDisplayText(query: TempoQuery) { + return query.query; + } +} diff --git a/public/app/plugins/datasource/tempo/img/tempo_logo.svg b/public/app/plugins/datasource/tempo/img/tempo_logo.svg new file mode 100644 index 0000000..c1f0ea7 --- /dev/null +++ b/public/app/plugins/datasource/tempo/img/tempo_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/datasource/tempo/module.ts b/public/app/plugins/datasource/tempo/module.ts new file mode 100644 index 0000000..f8356dc --- /dev/null +++ b/public/app/plugins/datasource/tempo/module.ts @@ -0,0 +1,8 @@ +import { DataSourcePlugin } from '@grafana/data'; +import { TempoDatasource } from './datasource'; +import { TempoQueryField } from './QueryField'; +import { ConfigEditor } from './ConfigEditor'; + +export const plugin = new DataSourcePlugin(TempoDatasource) + .setConfigEditor(ConfigEditor) + .setExploreQueryField(TempoQueryField); diff --git a/public/app/plugins/datasource/tempo/plugin.json b/public/app/plugins/datasource/tempo/plugin.json new file mode 100644 index 0000000..4451adc --- /dev/null +++ b/public/app/plugins/datasource/tempo/plugin.json @@ -0,0 +1,31 @@ +{ + "type": "datasource", + "name": "Tempo", + "id": "tempo", + "category": "tracing", + + "metrics": false, + "alerting": false, + "annotations": false, + "logs": false, + "streaming": false, + "tracing": true, + + "info": { + "description": "High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/tempo_logo.svg", + "large": "img/tempo_logo.svg" + }, + "links": [ + { + "name": "GitHub Project", + "url": "https://github.com/grafana/tempo" + } + ] + } +} diff --git a/public/app/plugins/datasource/testdata/ConfigEditor.tsx b/public/app/plugins/datasource/testdata/ConfigEditor.tsx new file mode 100644 index 0000000..9cc3332 --- /dev/null +++ b/public/app/plugins/datasource/testdata/ConfigEditor.tsx @@ -0,0 +1,15 @@ +// Libraries +import React, { PureComponent } from 'react'; + +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; + +type Props = DataSourcePluginOptionsEditorProps; + +/** + * Empty Config Editor -- settings to save + */ +export class ConfigEditor extends PureComponent { + render() { + return
      ; + } +} diff --git a/public/app/plugins/datasource/testdata/LogIpsum.ts b/public/app/plugins/datasource/testdata/LogIpsum.ts new file mode 100644 index 0000000..a1afe70 --- /dev/null +++ b/public/app/plugins/datasource/testdata/LogIpsum.ts @@ -0,0 +1,162 @@ +import { LogLevel } from '@grafana/data'; + +let index = 0; + +export function getRandomLogLevel(): LogLevel { + const v = Math.random(); + if (v > 0.9) { + return LogLevel.critical; + } + if (v > 0.8) { + return LogLevel.error; + } + if (v > 0.7) { + return LogLevel.warning; + } + if (v > 0.4) { + return LogLevel.info; + } + if (v > 0.3) { + return LogLevel.debug; + } + if (v > 0.1) { + return LogLevel.trace; + } + return LogLevel.unknown; +} + +export function getNextWord() { + index = (index + Math.floor(Math.random() * 5)) % words.length; + return words[index]; +} + +export function getRandomLine(length = 60) { + let line = getNextWord(); + while (line.length < length) { + line += ' ' + getNextWord(); + } + return line; +} + +const words = [ + 'At', + 'vero', + 'eos', + 'et', + 'accusamus', + 'et', + 'iusto', + 'odio', + 'dignissimos', + 'ducimus', + 'qui', + 'blanditiis', + 'praesentium', + 'voluptatum', + 'deleniti', + 'atque', + 'corrupti', + 'quos', + 'dolores', + 'et', + 'quas', + 'molestias', + 'excepturi', + 'sint', + 'occaecati', + 'cupiditate', + 'non', + 'provident', + 'similique', + 'sunt', + 'in', + 'culpa', + 'qui', + 'officia', + 'deserunt', + 'mollitia', + 'animi', + 'id', + 'est', + 'laborum', + 'et', + 'dolorum', + 'fuga', + 'Et', + 'harum', + 'quidem', + 'rerum', + 'facilis', + 'est', + 'et', + 'expedita', + 'distinctio', + 'Nam', + 'libero', + 'tempore', + 'cum', + 'soluta', + 'nobis', + 'est', + 'eligendi', + 'optio', + 'cumque', + 'nihil', + 'impedit', + 'quo', + 'minus', + 'id', + 'quod', + 'maxime', + 'placeat', + 'facere', + 'possimus', + 'omnis', + 'voluptas', + 'assumenda', + 'est', + 'omnis', + 'dolor', + 'repellendus', + 'Temporibus', + 'autem', + 'quibusdam', + 'et', + 'aut', + 'officiis', + 'debitis', + 'aut', + 'rerum', + 'necessitatibus', + 'saepe', + 'eveniet', + 'ut', + 'et', + 'voluptates', + 'repudiandae', + 'sint', + 'et', + 'molestiae', + 'non', + 'recusandae', + 'Itaque', + 'earum', + 'rerum', + 'hic', + 'tenetur', + 'a', + 'sapiente', + 'delectus', + 'ut', + 'aut', + 'reiciendis', + 'voluptatibus', + 'maiores', + 'alias', + 'consequatur', + 'aut', + 'perferendis', + 'doloribus', + 'asperiores', + 'repellat', +]; diff --git a/public/app/plugins/datasource/testdata/QueryEditor.test.tsx b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx new file mode 100644 index 0000000..84e2dd4 --- /dev/null +++ b/public/app/plugins/datasource/testdata/QueryEditor.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { defaultQuery } from './constants'; +import { QueryEditor, Props } from './QueryEditor'; +import { scenarios } from './__mocks__/scenarios'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const mockOnChange = jest.fn(); +const props = { + onRunQuery: jest.fn(), + query: defaultQuery, + onChange: mockOnChange, + datasource: { + getScenarios: () => Promise.resolve(scenarios), + } as any, +}; + +const setup = (testProps?: Partial) => { + const editorProps = { ...props, ...testProps }; + return render(); +}; + +describe('Test Datasource Query Editor', () => { + it('should render with default scenario', async () => { + setup(); + + expect(await screen.findByText(/random walk/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Alias' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Labels' })).toBeInTheDocument(); + }); + + it('should switch scenario and display its default values', async () => { + const { rerender } = setup(); + + let select = (await screen.findByText('Scenario')).nextSibling!; + await fireEvent.keyDown(select, { keyCode: 40 }); + const scs = screen.getAllByLabelText('Select option'); + + expect(scs).toHaveLength(scenarios.length); + + await userEvent.click(screen.getByText('CSV Metric Values')); + expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ scenarioId: 'csv_metric_values' })); + await rerender( + + ); + expect(await screen.findByRole('textbox', { name: /string input/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /string input/i })).toHaveValue('1,20,90,30,5,0'); + + await fireEvent.keyDown(select, { keyCode: 40 }); + await userEvent.click(screen.getByText('Grafana API')); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ scenarioId: 'grafana_api', stringInput: 'datasources' }) + ); + rerender( + + ); + expect(await screen.findByText('Grafana API')).toBeInTheDocument(); + expect(screen.getByText('Data Sources')).toBeInTheDocument(); + + await fireEvent.keyDown(select, { keyCode: 40 }); + await userEvent.click(screen.getByText('Streaming Client')); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ scenarioId: 'streaming_client', stringInput: '' }) + ); + rerender(); + expect(await screen.findByText('Streaming Client')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByLabelText('Noise')).toHaveValue(2.2); + expect(screen.getByLabelText('Speed (ms)')).toHaveValue(250); + expect(screen.getByLabelText('Spread')).toHaveValue(3.5); + expect(screen.getByLabelText('Bands')).toHaveValue(1); + }); +}); diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx new file mode 100644 index 0000000..f0ef67a --- /dev/null +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -0,0 +1,243 @@ +// Libraries +import React, { ChangeEvent, FormEvent, useMemo } from 'react'; +import { useAsync } from 'react-use'; + +// Components +import { selectors as editorSelectors } from '@grafana/e2e-selectors'; +import { Input, InlineFieldRow, InlineField, Select, TextArea, InlineSwitch } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { StreamingClientEditor, ManualEntryEditor, RandomWalkEditor } from './components'; + +// Types +import { TestDataDataSource } from './datasource'; +import { TestDataQuery, Scenario, NodesQuery } from './types'; +import { PredictablePulseEditor } from './components/PredictablePulseEditor'; +import { CSVWaveEditor } from './components/CSVWaveEditor'; +import { defaultQuery } from './constants'; +import { GrafanaLiveEditor } from './components/GrafanaLiveEditor'; +import { NodeGraphEditor } from './components/NodeGraphEditor'; + +const showLabelsFor = ['random_walk', 'predictable_pulse', 'predictable_csv_wave']; +const endpoints = [ + { value: 'datasources', label: 'Data Sources' }, + { value: 'search', label: 'Search' }, + { value: 'annotations', label: 'Annotations' }, +]; + +const selectors = editorSelectors.components.DataSource.TestData.QueryTab; + +export interface EditorProps { + onChange: (value: any) => void; + query: TestDataQuery; +} + +export type Props = QueryEditorProps; + +export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) => { + query = { ...defaultQuery, ...query }; + + const { loading, value: scenarioList } = useAsync(async () => { + return datasource.getScenarios(); + }, []); + + const onUpdate = (query: TestDataQuery) => { + onChange(query); + onRunQuery(); + }; + + const currentScenario = useMemo(() => scenarioList?.find((scenario) => scenario.id === query.scenarioId), [ + scenarioList, + query, + ]); + const scenarioId = currentScenario?.id; + + const onScenarioChange = (item: SelectableValue) => { + const scenario = scenarioList?.find((sc) => sc.id === item.value); + + if (!scenario) { + return; + } + + const update = { ...query, scenarioId: item.value! }; + + if (scenario.stringInput) { + update.stringInput = scenario.stringInput; + } + + if (scenario.id === 'grafana_api') { + update.stringInput = 'datasources'; + } else if (scenario.id === 'streaming_client') { + update.stringInput = ''; + } else if (scenario.id === 'live') { + if (!update.channel) { + update.channel = 'random-2s-stream'; // default stream + } + } + + onUpdate(update); + }; + + const onInputChange = (e: FormEvent) => { + const { name, value, type } = e.target as HTMLInputElement | HTMLTextAreaElement; + let newValue: any = value; + + if (type === 'number') { + newValue = Number(value); + } + + if (name === 'levelColumn') { + newValue = (e.target as HTMLInputElement).checked; + } + + onUpdate({ ...query, [name]: newValue }); + }; + + const onFieldChange = (field: string) => (e: ChangeEvent) => { + const { name, value, type } = e.target as HTMLInputElement; + let newValue: any = value; + + if (type === 'number') { + newValue = Number(value); + } + + onUpdate({ ...query, [field]: { ...query[field as keyof TestDataQuery], [name]: newValue } }); + }; + + const onEndPointChange = ({ value }: SelectableValue) => { + onUpdate({ ...query, stringInput: value }); + }; + + const onStreamClientChange = onFieldChange('stream'); + const onPulseWaveChange = onFieldChange('pulseWave'); + const onCSVWaveChange = onFieldChange('csvWave'); + + const options = useMemo( + () => + (scenarioList || []) + .map((item) => ({ label: item.name, value: item.id })) + .sort((a, b) => a.label.localeCompare(b.label)), + [scenarioList] + ); + const showLabels = useMemo(() => showLabelsFor.includes(query.scenarioId), [query]); + + if (loading) { + return null; + } + + return ( + <> + + + + + )} + + + + {showLabels && ( + + Set labels using a key=value syntax: +
      + {`{ key = "value", key2 = "value" }`} +
      + key="value", key2="value" +
      + key=value, key2=value +
      + + } + > + +
      + )} +
      + + {scenarioId === 'manual_entry' && } + {scenarioId === 'random_walk' && } + {scenarioId === 'streaming_client' && } + {scenarioId === 'live' && } + {scenarioId === 'logs' && ( + + + + + + + + + )} + + {scenarioId === 'grafana_api' && ( + +