diff --git a/.circleci/config.yml b/.circleci/config.yml index 90f736fc211a5..a783c8641a9f5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -96,7 +96,7 @@ jobs: destination: expected-screenshots - store_artifacts: path: public/e2e-test/screenShots/theOutput - destination: output-screenshots + destination: output-screenshots codespell: docker: @@ -653,7 +653,7 @@ workflows: - mysql-integration-test - postgres-integration-test - build-oss-msi - filters: *filter-only-master + filters: *filter-only-master - grafana-docker-master: requires: - build-all diff --git a/conf/defaults.ini b/conf/defaults.ini index e37fe54f148c1..2d7bfd7275060 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -287,6 +287,9 @@ signout_redirect_url = # This setting is ignored if multiple OAuth providers are configured. oauth_auto_login = false +# limit of api_key seconds to live before expiration +api_key_max_seconds_to_live = -1 + #################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access diff --git a/devenv/docker/blocks/openldap/Dockerfile b/devenv/docker/blocks/openldap/Dockerfile index b0d23b9e0c91d..0200045e6071e 100644 --- a/devenv/docker/blocks/openldap/Dockerfile +++ b/devenv/docker/blocks/openldap/Dockerfile @@ -19,6 +19,8 @@ EXPOSE 389 VOLUME ["/etc/ldap", "/var/lib/ldap"] +COPY ldap.conf /etc/ldap.dist/ldap.conf + COPY modules/ /etc/ldap.dist/modules COPY prepopulate/ /etc/ldap.dist/prepopulate diff --git a/devenv/docker/blocks/openldap/docker-compose.yaml b/devenv/docker/blocks/openldap/docker-compose.yaml index d11858ccfb9d0..8874a6a96b8f5 100644 --- a/devenv/docker/blocks/openldap/docker-compose.yaml +++ b/devenv/docker/blocks/openldap/docker-compose.yaml @@ -1,4 +1,5 @@ openldap: + container_name: ldap build: docker/blocks/openldap environment: SLAPD_PASSWORD: grafana diff --git a/devenv/docker/blocks/openldap/entrypoint.sh b/devenv/docker/blocks/openldap/entrypoint.sh index d202ed14b31f9..dac56daf10d9a 100755 --- a/devenv/docker/blocks/openldap/entrypoint.sh +++ b/devenv/docker/blocks/openldap/entrypoint.sh @@ -49,10 +49,6 @@ EOF dc_string="$dc_string,dc=$dc_part" done - base_string="BASE ${dc_string:1}" - - sed -i "s/^#BASE.*/${base_string}/g" /etc/ldap/ldap.conf - if [[ -n "$SLAPD_CONFIG_PASSWORD" ]]; then password_hash=`slappasswd -s "${SLAPD_CONFIG_PASSWORD}"` diff --git a/devenv/docker/blocks/openldap/ldap.conf b/devenv/docker/blocks/openldap/ldap.conf new file mode 100644 index 0000000000000..6fab0c3182141 --- /dev/null +++ b/devenv/docker/blocks/openldap/ldap.conf @@ -0,0 +1,16 @@ +# +# LDAP Defaults +# + +# See ldap.conf(5) for details +# This file should be world readable but not world writable. + +BASE dc=grafana,dc=org +#URI ldap://ldap.example.com ldap://ldap-master.example.com:666 + +SIZELIMIT 1000 +#TIMELIMIT 15 +#DEREF never + +# TLS certificates (needed for GnuTLS) +TLS_CACERT /etc/ssl/certs/ca-certificates.crt diff --git a/devenv/docker/blocks/opentsdb/docker-compose.yaml b/devenv/docker/blocks/opentsdb/docker-compose.yaml index ee064bb107d85..d366c9b1b30cf 100644 --- a/devenv/docker/blocks/opentsdb/docker-compose.yaml +++ b/devenv/docker/blocks/opentsdb/docker-compose.yaml @@ -1,5 +1,5 @@ opentsdb: - image: opower/opentsdb:latest + image: petergrace/opentsdb-docker:latest ports: - "4242:4242" diff --git a/devenv/docker/blocks/saml/users.php b/devenv/docker/blocks/saml/users.php index 498d86f5734e1..8ffa693c3e740 100644 --- a/devenv/docker/blocks/saml/users.php +++ b/devenv/docker/blocks/saml/users.php @@ -3,7 +3,7 @@ 'admin' => array( 'core:AdminPassword', ), - 'grafana-userpass' => array( + 'example-userpass' => array( 'exampleauth:UserPass', 'saml-admin:grafana' => array( 'groups' => array('admins'), diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index df1c909dc0c74..1299b7577522a 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -149,6 +149,8 @@ Since not all datasources have the same configuration settings we only have the | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60/70) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | +| logMessageField | string | Elasticsearch | Which field should be used as the log message | +| logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index b3a7e10b2a4fa..061587688708b 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -167,8 +167,8 @@ Notifications can be sent by setting up an incoming webhook in Google Hangouts c ### All supported notifiers -Name | Type | Supports images |Support alert rule tags ------|------------ | ------ +Name | Type | Supports images | Support alert rule tags +-----|------|---------------- | ----------------------- DingDing | `dingding` | yes, external only | no Discord | `discord` | yes | no Email | `email` | yes | no @@ -199,6 +199,8 @@ Notification services which need public image access are marked as 'external onl # Use alert rule tags in notifications {#alert-rule-tags} +> Only available in Grafana v6.3+. + Grafana can include a list of tags (key/value) in the notification. It's called alert rule tags to contrast with tags parsed from timeseries. It currently supports only the Prometheus Alertmanager notifier. diff --git a/docs/sources/auth/auth-proxy.md b/docs/sources/auth/auth-proxy.md index 2274771b91a4b..1608d0fb17d32 100644 --- a/docs/sources/auth/auth-proxy.md +++ b/docs/sources/auth/auth-proxy.md @@ -34,7 +34,7 @@ ldap_sync_ttl = 60 # Example `whitelist = 192.168.1.1, 192.168.1.0/24, 2001::23, 2001::0/120` whitelist = # Optionally define more headers to sync other user attributes -# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL` +# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL Groups:X-WEBAUTH-GROUPS` headers = ``` diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index fc22d6b2d7fb7..f00438e1c4ab1 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -63,6 +63,9 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 + +# The maximum lifetime (seconds) an api key can be used. If it is set all the api keys should have limited lifetime that is lower than this value. +api_key_max_seconds_to_live = -1 ``` ### Anonymous authentication diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 9d4d65e3699d8..8c07a187a5e8c 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -79,6 +79,18 @@ Identifier | Description `s` | second `ms` | millisecond +### Logs (BETA) + +> Only available in Grafana v6.3+. + +There are two parameters, `Message field name` and `Level field name`, that can optionally be configured from the data source settings page that determine +which fields will be used for log messages and log levels when visualizing logs in [Explore](/features/explore). + +For example, if you're using a default setup of Filebeat for shipping logs to Elasticsearch the following configuration should work: + +- **Message field name:** message +- **Level field name:** fields.level + ## Metric Query editor ![Elasticsearch Query Editor](/img/docs/elasticsearch/query_editor.png) @@ -162,6 +174,28 @@ Time | The name of the time field, needs to be date field. Text | Event description field. Tags | Optional field name to use for event tags (can be an array or a CSV string). +## Querying Logs (BETA) + +> Only available in Grafana v6.3+. + +Querying and displaying log data from Elasticsearch is available via [Explore](/features/explore). + +![](/img/docs/v63/elasticsearch_explore_logs.png) + +Select the Elasticsearch data source, change to Logs using the Metrics/Logs switcher, and then optionally enter a lucene query into the query field to filter the log messages. + +Finally, press the `Enter` key or the `Run Query` button to display your logs. + +### Log Queries + +Once the result is returned, the log panel shows a list of log rows and a bar chart where the x-axis shows the time and the y-axis shows the frequency/count. + +Note that the fields used for log message and level is based on an [optional datasource configuration](#logs-beta). + +### Filter Log Messages + +Optionally enter a lucene query into the query field to filter the log messages. For example, using a default Filebeat setup you should be able to use `fields.level:error` to only show error log messages. + ## Configure the Datasource with Provisioning It's now possible to configure datasources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) @@ -181,3 +215,22 @@ datasources: interval: Daily timeField: "@timestamp" ``` + +or, for logs: + +```yaml +apiVersion: 1 + +datasources: + - name: elasticsearch-v7-filebeat + type: elasticsearch + access: proxy + database: "[filebeat-]YYYY.MM.DD" + url: http://localhost:9200 + jsonData: + interval: Daily + timeField: "@timestamp" + esVersion: 70 + logMessageField: message + logLevelField: fields.level +``` diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index 3d905f75713e9..63ca063d3c229 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -117,6 +117,26 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi You can remove the group by time by clicking on the `time` part and then the `x` icon. You can change the option `Format As` to `Table` if you want to show raw data in the `Table` panel. +## Querying Logs (BETA) + +> Only available in Grafana v6.3+. + +Querying and displaying log data from InfluxDB is available via [Explore](/features/explore). + +![](/img/docs/v63/influxdb_explore_logs.png) + +Select the InfluxDB data source, change to Logs using the Metrics/Logs switcher, +and then use the `Measurements/Fields` button to display your logs. + +### Log Queries + +The Logs Explorer (the `Measurements/Fields` button) next to the query field shows a list of measurements and fields. Choose the desired measurement that contains your log data and then choose which field Explore should use to display the log message. + +Once the result is returned, the log panel shows a list of log rows and a bar chart where the x-axis shows the time and the y-axis shows the frequency/count. + +### Filter search + +To add a filter click the plus icon to the right of the `Measurements/Fields` button or a condition. You can remove tag filters by clicking on the first select and choosing `--remove filter--`. ## Templating diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 95a07da6cc2b1..97a9c7c9e0e1e 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -37,6 +37,8 @@ Repeat a panel for each value of a variable. Repeating panels are described in ### Data link +> Only available in Grafana v6.3+. + Data link in graph settings allows adding dynamic links to the visualization. Those links can link to either other dashboard or to an external URL. {{< docs-imagebox img="/img/docs/data_link.png" max-width= "800px" >}} diff --git a/docs/sources/http_api/auth.md b/docs/sources/http_api/auth.md index e87d3571322ce..fd5007f8af716 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -82,7 +82,8 @@ Content-Type: application/json { "id": 1, "name": "TestAdmin", - "role": "Admin" + "role": "Admin", + "expiration": "2019-06-26T10:52:03+03:00" } ] ``` @@ -101,7 +102,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "name": "mykey", - "role": "Admin" + "role": "Admin", + "secondsToLive": 86400 } ``` @@ -109,6 +111,12 @@ JSON Body schema: - **name** – The key name - **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor` or `Admin`. +- **secondsToLive** – Sets the key expiration in seconds. It is optional. If it is a positive number an expiration date for the key is set. If it is null, zero or is omitted completely (unless `api_key_max_seconds_to_live` configuration option is set) the key will never expire. + +Error statuses: + +- **400** – `api_key_max_seconds_to_live` is set but no `secondsToLive` is specified or `secondsToLive` is greater than this value. +- **500** – The key was unable to be stored in the database. **Example Response**: diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 755b4615a5064..d3a87a979d53c 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -304,7 +304,7 @@ The number of days the keep me logged in / remember me cookie lasts. ### secret_key -Used for signing some datasource settings like secrets and passwords. Cannot be changed without requiring an update +Used for signing some datasource settings like secrets and passwords, the encryption format used is AES-256 in CFB mode. Cannot be changed without requiring an update to datasource settings to re-encode them. ### disable_gravatar diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 22195cb9df503..027af100f7b94 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -154,7 +154,7 @@ The default cookie name for storing the auth token is `grafana_session`. you can ### Ensure encryption of datasource secrets -Datasources store passwords and basic auth passwords in secureJsonData encrypted by default. Existing datasource +Datasources store passwords and basic auth passwords in secureJsonData encrypted (AES-256 in CFB mode) by default. Existing datasource will keep working with unencrypted passwords. If you want to migrate to encrypted storage for your existing datasources you can do that by: @@ -175,3 +175,10 @@ this new setting. In 6.2 we completely removed the backend session storage since we replaced the previous login session implementation with an auth token. If you are using Auth proxy with LDAP an shared cached is used in Grafana so you might want configure [remote_cache] instead. If not Grafana will fallback to using the database as an shared cache. + +### Upgrading Elasticsearch to v7.0+ + +The semantics of `max concurrent shard requests` changed in Elasticsearch v7.0, see [release notes](https://www.elastic.co/guide/en/elasticsearch/reference/7.0/breaking-changes-7.0.html#semantics-changed-max-concurrent-shared-requests) for reference. + +If you upgrade Elasticsearch to v7.0+ you should make sure to update the datasource configuration in Grafana so that version +is `7.0+` and `max concurrent shard requests` properly configured. 256 was the default in pre v7.0 versions. In v7.0 and above 5 is the default. diff --git a/go.mod b/go.mod index 869d9fd70acf8..46dda5b945da6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd // indirect github.com/codegangsta/cli v1.20.0 + github.com/davecgh/go-spew v1.1.1 github.com/denisenkom/go-mssqldb v0.0.0-20190315220205-a8ed825ac853 github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect github.com/facebookgo/inject v0.0.0-20180706035515-f23751cae28b diff --git a/package.json b/package.json index 9d80b5d39a22c..ffe32b1400806 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,11 @@ "@emotion/core": "10.0.10", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@types/angular": "1.6.54", - "@types/chalk": "2.2.0", "@types/classnames": "2.2.7", "@types/clipboard": "2.0.1", - "@types/commander": "2.12.2", "@types/d3": "4.13.1", "@types/enzyme": "3.9.0", "@types/expect-puppeteer": "3.3.1", - "@types/inquirer": "0.0.43", "@types/jest": "24.0.13", "@types/jquery": "1.10.35", "@types/lodash": "4.14.123", @@ -42,7 +39,6 @@ "@types/react-transition-group": "2.0.16", "@types/react-virtualized": "9.18.12", "@types/react-window": "1.7.0", - "@types/remarkable": "1.7.4", "angular-mocks": "1.6.6", "autoprefixer": "9.5.0", "axios": "0.19.0", @@ -50,16 +46,13 @@ "babel-jest": "24.8.0", "babel-loader": "8.0.5", "babel-plugin-angularjs-annotate": "0.10.0", - "chalk": "2.4.2", "clean-webpack-plugin": "2.0.0", - "concurrently": "4.1.0", "css-loader": "2.1.1", "enzyme": "3.9.0", "enzyme-adapter-react-16": "1.11.2", "enzyme-to-json": "3.3.5", "es6-promise": "3.3.1", "es6-shim": "0.35.5", - "execa": "1.0.0", "expect-puppeteer": "4.1.1", "expect.js": "0.2.0", "expose-loader": "0.7.5", @@ -84,7 +77,6 @@ "html-webpack-harddisk-plugin": "1.0.1", "html-webpack-plugin": "3.2.0", "husky": "1.3.1", - "inquirer": "6.2.2", "jest": "24.8.0", "jest-date-mock": "1.0.7", "lint-staged": "8.1.5", @@ -99,7 +91,6 @@ "node-sass": "4.11.0", "npm": "6.9.0", "optimize-css-assets-webpack-plugin": "5.0.1", - "ora": "3.2.0", "phantomjs-prebuilt": "2.1.16", "pixelmatch": "4.0.2", "pngjs": "3.4.0", @@ -116,8 +107,6 @@ "rimraf": "2.6.3", "sass-lint": "1.12.1", "sass-loader": "7.1.0", - "semver": "5.7.0", - "simple-git": "^1.112.0", "sinon": "1.17.6", "style-loader": "0.23.1", "systemjs": "0.20.19", @@ -141,9 +130,9 @@ }, "scripts": { "dev": "webpack --progress --colors --mode development --config scripts/webpack/webpack.dev.js", - "start": "npm run cli -- core:start --watchTheme", - "start:hot": "npm run cli -- core:start --hot --watchTheme", - "start:ignoreTheme": "npm run cli -- core:start --hot", + "start": "grafana-toolkit core:start --watchTheme", + "start:hot": "grafana-toolkit core:start --hot --watchTheme", + "start:ignoreTheme": "grafana-toolkit core:start --hot", "watch": "yarn start -d watch,start core:start --watchTheme ", "build": "grunt build", "test": "grunt test", @@ -154,16 +143,15 @@ "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", "storybook": "cd packages/grafana-ui && yarn storybook", "storybook:build": "cd packages/grafana-ui && yarn storybook:build", - "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", - "cli": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/index.ts", "gui:tslint": "tslint -c ./packages/grafana-ui/tslint.json --project ./packages/grafana-ui/tsconfig.json", - "gui:build": "npm run cli -- gui:build", - "gui:releasePrepare": "npm run cli -- gui:release", + "gui:build": "grafana-toolkit gui:build", + "gui:releasePrepare": "grafana-toolkit gui:release", "gui:publish": "cd packages/grafana-ui/dist && npm publish --access public", - "gui:release": "npm run cli -- gui:release -p --createVersionCommit", - "precommit": "npm run cli -- precommit" + "gui:release": "grafana-toolkit gui:release -p --createVersionCommit", + "precommit": "grafana-toolkit precommit", + "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts" }, "husky": { "hooks": { @@ -192,10 +180,13 @@ "@types/angular-route": "1.7.0", "@types/d3-scale-chromatic": "1.3.1", "@types/enzyme-adapter-react-16": "1.0.5", + "@types/marked": "0.6.5", "@types/react-redux": "^7.0.8", + "@types/react-test-renderer": "16.8.2", "@types/redux-logger": "3.0.7", "@types/reselect": "2.2.0", "@types/slate": "0.44.11", + "@types/tinycolor2": "1.4.2", "angular": "1.6.6", "angular-bindonce": "0.3.1", "angular-native-dragdrop": "1.2.2", @@ -214,6 +205,7 @@ "immutable": "3.8.2", "jquery": "3.4.1", "lodash": "4.17.11", + "marked": "0.6.2", "moment": "2.24.0", "mousetrap": "1.6.3", "mousetrap-global-bind": "1.1.0", @@ -238,7 +230,6 @@ "redux-logger": "3.0.6", "redux-observable": "1.1.0", "redux-thunk": "2.3.0", - "remarkable": "1.7.1", "reselect": "4.0.0", "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "6.4.0", diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 57f9f48d8bdec..0174d82b35b0e 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -1 +1,2 @@ export * from './string'; +export * from './markdown'; diff --git a/packages/grafana-data/src/utils/markdown.ts b/packages/grafana-data/src/utils/markdown.ts new file mode 100644 index 0000000000000..8a0ce6c839f73 --- /dev/null +++ b/packages/grafana-data/src/utils/markdown.ts @@ -0,0 +1,20 @@ +import marked, { MarkedOptions } from 'marked'; + +const defaultMarkedOptions: MarkedOptions = { + renderer: new marked.Renderer(), + pedantic: false, + gfm: true, + tables: true, + sanitize: true, + smartLists: true, + smartypants: false, + xhtml: false, +}; + +export function setMarkdownOptions(optionsOverride?: MarkedOptions) { + marked.setOptions({ ...defaultMarkedOptions, ...optionsOverride }); +} + +export function renderMarkdown(str: string): string { + return marked(str); +} diff --git a/packages/grafana-toolkit/CHANGELOG.md b/packages/grafana-toolkit/CHANGELOG.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md new file mode 100644 index 0000000000000..5655afcb2ad47 --- /dev/null +++ b/packages/grafana-toolkit/README.md @@ -0,0 +1,52 @@ +# Grafana Toolkit + +Make sure to run `yarn install` before trying anything! Otherwise you may see unknown command grafana-toolkit and spend a while tracking that down. + +## Internal development +For development use `yarn link`. First, navigate to `packages/grafana-toolkit` and run `yarn link`. Then, in your project use `yarn link @grafana/toolkit` to use linked version. + +## Grafana extensions development with grafana-toolkit overview + +### Typescript +To configure Typescript create `tsconfig.json` file in the root dir of your app. grafana-toolkit comes with default tsconfig located in `packages/grafana-toolkit/src/config/tsconfig.plugin.ts`. In order for Typescript to be able to pickup your source files you need to extend that config as follows: + +```json +{ + "extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json", + "include": ["src"], + "compilerOptions": { + "rootDir": "./src", + "typeRoots": ["./node_modules/@types"] + } +} +``` + +### TSLint +grafana-toolkit comes with default config for TSLint, that's located in `packages/grafana-toolkit/src/config/tslint.plugin.ts`. As for now there is now way to customise TSLint config. + +### Tests +grafana-toolkit comes with Jest as a test runner. It runs tests according to common config locted in `packages/grafana-toolkit/src/config/jest.plugin.config.ts`. + +For now the config is not extendable, but our goal is to enable custom jest config via jest.config or package.json file. This might be required in the future if you want to use i.e. `enzyme-to-json` snapshots serializer. For that particular serializer we can also utilise it's API and add initialisation in the setup files (https://github.com/adriantoine/enzyme-to-json#serializer-in-unit-tests). We need to test that approach first. + +#### Jest setup +We are not opinionated about tool used for implmenting tests. Internally at Grafana we use Enzyme. If you want to configure Enzyme as a testing utility, you need to configure enzyme-adapter-react. To do so, you need to create `[YOUR_APP]/config/jest-setup.ts` file that will provide React/Enzyme setup. Simply copy 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() }); +``` + +grafana-toolkit will use that file as Jest's setup file. You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `[YOUR_APP]/config/jest-shim.ts` + +Adidtionaly, you can also provide additional Jest config via package.json file. For more details please refer to [Jest docs](https://jest-bot.github.io/jest/docs/configuration.html#verbose-boolean). Currently we support following properties: +- [`snapshotSerializers`](https://jest-bot.github.io/jest/docs/configuration.html#snapshotserializers-array-string) + +## Prettier [todo] + +## Development mode [todo] +TODO +- Enable rollup watch on extension sources + diff --git a/packages/grafana-toolkit/bin/grafana-toolkit.dist.js b/packages/grafana-toolkit/bin/grafana-toolkit.dist.js new file mode 100755 index 0000000000000..d6498f3d3a49d --- /dev/null +++ b/packages/grafana-toolkit/bin/grafana-toolkit.dist.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +// This bin is used for cli installed from npm + +require('../src/cli/index.js').run(); diff --git a/packages/grafana-toolkit/bin/grafana-toolkit.js b/packages/grafana-toolkit/bin/grafana-toolkit.js new file mode 100755 index 0000000000000..6cab0391954df --- /dev/null +++ b/packages/grafana-toolkit/bin/grafana-toolkit.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +var path = require('path') ; + +// This bin is used for cli executed internally + +var tsProjectPath = path.resolve(__dirname, '../tsconfig.json'); + +require('ts-node').register({ + project: tsProjectPath +}); + +require('../src/cli/index.ts').run(true); diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json new file mode 100644 index 0000000000000..e602f60dcf1ae --- /dev/null +++ b/packages/grafana-toolkit/package.json @@ -0,0 +1,71 @@ +{ + "name": "@grafana/toolkit", + "version": "6.3.0-alpha.2", + "description": "Grafana Toolkit", + "keywords": [ + "typescript", + "react", + "react-component" + ], + "bin": { + "grafana-toolkit": "./bin/grafana-toolkit.js" + }, + "scripts": { + "tslint": "tslint -c tslint.json --project tsconfig.json", + "typecheck": "tsc --noEmit", + "precommit": "npm run tslint & npm run typecheck", + "clean": "rimraf ./dist ./compiled" + }, + "author": "Grafana Labs", + "license": "Apache-2.0", + "dependencies": { + "@types/execa": "^0.9.0", + "@types/inquirer": "^6.0.3", + "@types/jest": "24.0.13", + "@types/jest-cli": "^23.6.0", + "@types/node": "^12.0.4", + "@types/prettier": "^1.16.4", + "@types/semver": "^6.0.0", + "axios": "0.19.0", + "chalk": "^2.4.2", + "commander": "^2.20.0", + "concurrently": "4.1.0", + "execa": "^1.0.0", + "glob": "^7.1.4", + "inquirer": "^6.3.1", + "jest-cli": "^24.8.0", + "lodash": "4.17.11", + "ora": "^3.4.0", + "prettier": "^1.17.1", + "replace-in-file": "^4.1.0", + "rollup": "^1.14.2", + "rollup-plugin-commonjs": "^10.0.0", + "rollup-plugin-copy-glob": "^0.3.0", + "rollup-plugin-json": "^4.0.0", + "rollup-plugin-node-builtins": "^2.1.2", + "rollup-plugin-node-globals": "^1.4.0", + "rollup-plugin-node-resolve": "^5.1.0", + "rollup-plugin-sourcemaps": "^0.4.2", + "rollup-plugin-terser": "^5.0.0", + "rollup-plugin-typescript2": "^0.21.1", + "rollup-plugin-visualizer": "^1.1.1", + "semver": "^6.1.1", + "simple-git": "^1.112.0", + "ts-node": "^8.2.0", + "tslint": "5.14.0" + }, + "peerDependencies": { + "jest": "24.8.0", + "ts-jest": "24.0.2", + "tslib": "1.10.0", + "typescript": "3.5.1" + }, + "resolutions": { + "@types/lodash": "4.14.119", + "rollup-plugin-typescript2": "0.21.1" + }, + "devDependencies": { + "@types/glob": "^7.1.1", + "rollup-watch": "^4.3.1" + } +} diff --git a/scripts/cli/index.d.ts b/packages/grafana-toolkit/src/cli/index.d.ts similarity index 100% rename from scripts/cli/index.d.ts rename to packages/grafana-toolkit/src/cli/index.d.ts diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts new file mode 100644 index 0000000000000..c457983ef7aa4 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -0,0 +1,166 @@ +// @ts-ignore +import program from 'commander'; +import { execTask } from './utils/execTask'; +import chalk from 'chalk'; +import { startTask } from './tasks/core.start'; +import { buildTask } from './tasks/grafanaui.build'; +import { releaseTask } from './tasks/grafanaui.release'; +import { changelogTask } from './tasks/changelog'; +import { cherryPickTask } from './tasks/cherrypick'; +import { precommitTask } from './tasks/precommit'; +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'; + +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, --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, + hot: cmd.hot, + }); + }); + + program + .command('gui:build') + .description('Builds @grafana/ui package to packages/grafana-ui/dist') + .action(async cmd => { + // @ts-ignore + await execTask(buildTask)(); + }); + + program + .command('gui:release') + .description('Prepares @grafana/ui release (and publishes to npm on demand)') + .option('-p, --publish', 'Publish @grafana/ui to npm registry') + .option('-u, --usePackageJsonVersion', 'Use version specified in package.json') + .option('--createVersionCommit', 'Create and push version commit') + .action(async cmd => { + await execTask(releaseTask)({ + publishToNpm: !!cmd.publish, + usePackageJsonVersion: !!cmd.usePackageJsonVersion, + createVersionCommit: !!cmd.createVersionCommit, + }); + }); + + 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, + }); + }); + + program + .command('cherrypick') + .description('Helps find commits to cherry pick') + .action(async cmd => { + await execTask(cherryPickTask)({}); + }); + + program + .command('precommit') + .description('Executes checks') + .action(async cmd => { + await execTask(precommitTask)({}); + }); + + 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 => { + // @ts-ignore + 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') + .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, + }); + }); + } + + program + .command('plugin:build') + .description('Prepares plugin dist package') + .action(async cmd => { + await execTask(pluginBuildTask)({}); + }); + + program + .command('plugin:dev') + .description('Starts plugin dev mode') + .action(async cmd => { + await execTask(pluginDevTask)({ + watch: true, + }); + }); + + program + .command('plugin:test') + .option('-u, --updateSnapshot', 'Run snapshots update') + .option('--coverage', 'Run code coverage') + .description('Executes plugin tests') + .action(async cmd => { + await execTask(pluginTestTask)({ + updateSnapshot: !!cmd.updateSnapshot, + coverage: !!cmd.coverage, + }); + }); + + 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/scripts/cli/tasks/changelog.ts b/packages/grafana-toolkit/src/cli/tasks/changelog.ts similarity index 77% rename from scripts/cli/tasks/changelog.ts rename to packages/grafana-toolkit/src/cli/tasks/changelog.ts index e2cb9da7e55d1..004ea46603a10 100644 --- a/scripts/cli/tasks/changelog.ts +++ b/packages/grafana-toolkit/src/cli/tasks/changelog.ts @@ -1,4 +1,6 @@ -import _ from 'lodash'; +import axios from 'axios'; +// @ts-ignore +import * as _ from 'lodash'; import { Task, TaskRunner } from './task'; import GithubClient from '../utils/githubClient'; @@ -27,11 +29,11 @@ const changelogTaskRunner: TaskRunner = async ({ milestone }) const issues = res.data; const bugs = _.sortBy( - issues.filter(item => { + issues.filter((item: any) => { if (item.title.match(/fix|fixes/i)) { return true; } - if (item.labels.find(label => label.name === 'type/bug')) { + if (item.labels.find((label: any) => label.name === 'type/bug')) { return true; } return false; @@ -39,7 +41,7 @@ const changelogTaskRunner: TaskRunner = async ({ milestone }) 'title' ); - const notBugs = _.sortBy(issues.filter(item => !bugs.find(bug => bug === item)), 'title'); + const notBugs = _.sortBy(issues.filter((item: any) => !bugs.find((bug: any) => bug === item)), 'title'); let markdown = ''; @@ -65,7 +67,7 @@ const changelogTaskRunner: TaskRunner = async ({ milestone }) function getMarkdownLineForIssue(item: any) { const githubGrafanaUrl = 'https://github.com/grafana/grafana'; let markdown = ''; - const title = item.title.replace(/^([^:]*)/, (match, g1) => { + const title = item.title.replace(/^([^:]*)/, (_match: any, g1: any) => { return `**${g1}**`; }); @@ -78,6 +80,4 @@ function getMarkdownLineForIssue(item: any) { return markdown; } -export const changelogTask = new Task(); -changelogTask.setName('Changelog generator task'); -changelogTask.setRunner(changelogTaskRunner); +export const changelogTask = new Task('Changelog generator task', changelogTaskRunner); diff --git a/scripts/cli/tasks/cherrypick.ts b/packages/grafana-toolkit/src/cli/tasks/cherrypick.ts similarity index 88% rename from scripts/cli/tasks/cherrypick.ts rename to packages/grafana-toolkit/src/cli/tasks/cherrypick.ts index 3e5a7addf4259..38ce9b4a5891e 100644 --- a/scripts/cli/tasks/cherrypick.ts +++ b/packages/grafana-toolkit/src/cli/tasks/cherrypick.ts @@ -15,7 +15,7 @@ const cherryPickRunner: TaskRunner = async () => { }); // sort by closed date ASC - res.data.sort(function(a, b) { + res.data.sort((a: any, b: any) => { return new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime(); }); @@ -42,6 +42,4 @@ const cherryPickRunner: TaskRunner = async () => { console.log(commands); }; -export const cherryPickTask = new Task(); -cherryPickTask.setName('Cherry pick task'); -cherryPickTask.setRunner(cherryPickRunner); +export const cherryPickTask = new Task('Cherry pick task', cherryPickRunner); diff --git a/scripts/cli/tasks/closeMilestone.ts b/packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts similarity index 94% rename from scripts/cli/tasks/closeMilestone.ts rename to packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts index 9873863dc253c..3bdbbd46a21dd 100644 --- a/scripts/cli/tasks/closeMilestone.ts +++ b/packages/grafana-toolkit/src/cli/tasks/closeMilestone.ts @@ -70,6 +70,7 @@ const closeMilestoneTaskRunner: TaskRunner = async ({ mil } }; -export const closeMilestoneTask = new Task(); -closeMilestoneTask.setName('Close Milestone generator task'); -closeMilestoneTask.setRunner(closeMilestoneTaskRunner); +export const closeMilestoneTask = new Task( + 'Close Milestone generator task', + closeMilestoneTaskRunner +); diff --git a/scripts/cli/tasks/core.start.ts b/packages/grafana-toolkit/src/cli/tasks/core.start.ts similarity index 87% rename from scripts/cli/tasks/core.start.ts rename to packages/grafana-toolkit/src/cli/tasks/core.start.ts index dbd2dfe711917..2132ea33a8e46 100644 --- a/scripts/cli/tasks/core.start.ts +++ b/packages/grafana-toolkit/src/cli/tasks/core.start.ts @@ -1,3 +1,4 @@ +//@ts-ignore import concurrently from 'concurrently'; import { Task, TaskRunner } from './task'; @@ -33,6 +34,4 @@ const startTaskRunner: TaskRunner = async ({ watchThemes, hot } }; -export const startTask = new Task(); -startTask.setName('Core startTask'); -startTask.setRunner(startTaskRunner); +export const startTask = new Task('Core startTask', startTaskRunner); diff --git a/scripts/cli/tasks/grafanaui.build.ts b/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts similarity index 68% rename from scripts/cli/tasks/grafanaui.build.ts rename to packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts index 1a48bb1a7243b..6dbbc7b1d3609 100644 --- a/scripts/cli/tasks/grafanaui.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts @@ -1,34 +1,45 @@ -import execa from 'execa'; -import fs from 'fs'; +import execa = require('execa'); +// @ts-ignore +import * as fs from 'fs'; import { changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; import chalk from 'chalk'; import { useSpinner } from '../utils/useSpinner'; import { Task, TaskRunner } from './task'; -let distDir, cwd; +let distDir: string, cwd: string; +// @ts-ignore export const clean = useSpinner('Cleaning', async () => await execa('npm', ['run', 'clean'])); +// @ts-ignore const compile = useSpinner('Compiling sources', () => execa('tsc', ['-p', './tsconfig.build.json'])); +// @ts-ignore const rollup = useSpinner('Bundling', () => execa('npm', ['run', 'build'])); -export const savePackage = useSpinner<{ +interface SavePackageOptions { path: string; pkg: {}; -}>('Updating package.json', async ({ path, pkg }) => { - return new Promise((resolve, reject) => { - fs.writeFile(path, JSON.stringify(pkg, null, 2), err => { - if (err) { - reject(err); - return; - } - resolve(); +} + +// @ts-ignore +export const savePackage = useSpinner( + 'Updating package.json', + // @ts-ignore + async ({ path, pkg }: SavePackageOptions) => { + return new Promise((resolve, reject) => { + fs.writeFile(path, JSON.stringify(pkg, null, 2), err => { + if (err) { + reject(err); + return; + } + resolve(); + }); }); - }); -}); + } +); -const preparePackage = async pkg => { +const preparePackage = async (pkg: any) => { pkg.main = 'index.js'; pkg.types = 'index.d.ts'; await savePackage({ @@ -39,6 +50,7 @@ const preparePackage = async pkg => { const moveFiles = () => { const files = ['README.md', 'CHANGELOG.md', 'index.js']; + // @ts-ignore return useSpinner(`Moving ${files.join(', ')} files`, async () => { const promises = files.map(file => { return new Promise((resolve, reject) => { @@ -71,6 +83,4 @@ const buildTaskRunner: TaskRunner = async () => { restoreCwd(); }; -export const buildTask = new Task(); -buildTask.setName('@grafana/ui build'); -buildTask.setRunner(buildTaskRunner); +export const buildTask = new Task('@grafana/ui build', buildTaskRunner); diff --git a/scripts/cli/tasks/grafanaui.release.ts b/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts similarity index 84% rename from scripts/cli/tasks/grafanaui.release.ts rename to packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts index dd7c441a53bfc..eb8da0da1c28c 100644 --- a/scripts/cli/tasks/grafanaui.release.ts +++ b/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts @@ -1,14 +1,15 @@ -import execa from 'execa'; +import execa = require('execa'); import { execTask } from '../utils/execTask'; import { changeCwdToGrafanaUiDist, changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; -import semver from 'semver'; -import inquirer from 'inquirer'; +import { ReleaseType, inc } from 'semver'; +import { prompt } from 'inquirer'; import chalk from 'chalk'; import { useSpinner } from '../utils/useSpinner'; import { savePackage, buildTask, clean } from './grafanaui.build'; import { TaskRunner, Task } from './task'; type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major'; + interface ReleaseTaskOptions { publishToNpm: boolean; usePackageJsonVersion: boolean; @@ -16,43 +17,29 @@ interface ReleaseTaskOptions { } const promptBumpType = async () => { - return inquirer.prompt<{ type: VersionBumpType }>([ + return prompt<{ type: VersionBumpType }>([ { type: 'list', message: 'Select version bump', name: 'type', choices: ['prerelease', 'patch', 'minor', 'major'], - validate: answer => { - if (answer.length < 1) { - return 'You must choose something'; - } - - return true; - }, }, ]); }; const promptPrereleaseId = async (message = 'Is this a prerelease?', allowNo = true) => { - return inquirer.prompt<{ id: string }>([ + return prompt<{ id: string }>([ { type: 'list', message: message, name: 'id', choices: allowNo ? ['no', 'alpha', 'beta'] : ['alpha', 'beta'], - validate: answer => { - if (answer.length < 1) { - return 'You must choose something'; - } - - return true; - }, }, ]); }; const promptConfirm = async (message?: string) => { - return inquirer.prompt<{ confirmed: boolean }>([ + return prompt<{ confirmed: boolean }>([ { type: 'confirm', message: message || 'Is that correct?', @@ -64,11 +51,18 @@ const promptConfirm = async (message?: string) => { // Since Grafana core depends on @grafana/ui highly, we run full check before release const runChecksAndTests = async () => + // @ts-ignore useSpinner(`Running checks and tests`, async () => { - await execa('npm', ['run', 'test']); + try { + await execa('npm', ['run', 'test']); + } catch (e) { + console.log(e); + throw e; + } })(); const bumpVersion = (version: string) => + // @ts-ignore useSpinner(`Saving version ${version} to package.json`, async () => { changeCwdToGrafanaUi(); await execa('npm', ['version', version]); @@ -79,6 +73,7 @@ const bumpVersion = (version: string) => })(); const publishPackage = (name: string, version: string) => + // @ts-ignore useSpinner(`Publishing ${name} @ ${version} to npm registry...`, async () => { changeCwdToGrafanaUiDist(); await execa('npm', ['publish', '--access', 'public']); @@ -95,6 +90,7 @@ const ensureMasterBranch = async () => { }; const prepareVersionCommitAndPush = async (version: string) => + // @ts-ignore useSpinner('Commiting and pushing @grafana/ui version update', async () => { await execa.stdout('git', ['commit', '-a', '-m', `Upgrade @grafana/ui version to v${version}`]); await execa.stdout('git', ['push']); @@ -106,6 +102,7 @@ const releaseTaskRunner: TaskRunner = async ({ createVersionCommit, }) => { changeCwdToGrafanaUi(); + // @ts-ignore await clean(); // Clean previous build if exists restoreCwd(); @@ -117,7 +114,7 @@ const releaseTaskRunner: TaskRunner = async ({ await runChecksAndTests(); - await execTask(buildTask)(); + await execTask(buildTask)({} as any); let releaseConfirmed = false; let nextVersion; @@ -133,13 +130,13 @@ const releaseTaskRunner: TaskRunner = async ({ console.log(type); if (type === 'prerelease') { const { id } = await promptPrereleaseId('What kind of prerelease?', false); - nextVersion = semver.inc(pkg.version, type, id); + nextVersion = inc(pkg.version, type, id as any); } else { const { id } = await promptPrereleaseId(); if (id !== 'no') { - nextVersion = semver.inc(pkg.version, `pre${type}`, id); + nextVersion = inc(pkg.version, `pre${type}` as ReleaseType, id as any); } else { - nextVersion = semver.inc(pkg.version, type); + nextVersion = inc(pkg.version, type as ReleaseType); } } } else { @@ -190,6 +187,4 @@ const releaseTaskRunner: TaskRunner = async ({ } }; -export const releaseTask = new Task(); -releaseTask.setName('@grafana/ui release'); -releaseTask.setRunner(releaseTaskRunner); +export const releaseTask = new Task('@grafana/ui release', releaseTaskRunner); 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 0000000000000..eeccb7d10f997 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts @@ -0,0 +1,84 @@ +import { Task, TaskRunner } from './task'; +// @ts-ignore +import execa = require('execa'); +import path = require('path'); +import fs = require('fs'); +import glob = require('glob'); +import * as rollup from 'rollup'; +import { inputOptions, outputOptions } from '../../config/rollup.plugin.config'; + +import { useSpinner } from '../utils/useSpinner'; +import { Linter, Configuration, RuleFailure } from 'tslint'; +import { testPlugin } from './plugin/tests'; +interface PrecommitOptions {} + +// @ts-ignore +export const clean = useSpinner('Cleaning', async () => await execa('rimraf', ['./dist'])); + +// @ts-ignore +const typecheckPlugin = useSpinner('Typechecking', async () => { + await execa('tsc', ['--noEmit']); +}); + +// @ts-ignore +const lintPlugin = useSpinner('Linting', async () => { + let tsLintConfigPath = path.resolve(process.cwd(), 'tslint.json'); + if (!fs.existsSync(tsLintConfigPath)) { + tsLintConfigPath = path.resolve(__dirname, '../../config/tslint.plugin.json'); + } + const globPattern = path.resolve(process.cwd(), 'src/**/*.+(ts|tsx)'); + const sourcesToLint = glob.sync(globPattern); + const options = { + fix: true, // or fail + formatter: 'json', + }; + + const configuration = Configuration.findConfiguration(tsLintConfigPath).results; + + const lintResults = sourcesToLint + .map(fileName => { + const linter = new Linter(options); + const fileContents = fs.readFileSync(fileName, 'utf8'); + linter.lint(fileName, fileContents, configuration); + return linter.getResult(); + }) + .filter(result => { + return result.errorCount > 0 || result.warningCount > 0; + }); + + if (lintResults.length > 0) { + console.log('\n'); + const failures = lintResults.reduce((failures, result) => { + return [...failures, ...result.failures]; + }, []); + failures.forEach(f => { + // tslint:disable-next-line + console.log( + `${f.getRuleSeverity() === 'warning' ? 'WARNING' : 'ERROR'}: ${f.getFileName().split('src')[1]}[${ + f.getStartPosition().getLineAndCharacter().line + }:${f.getStartPosition().getLineAndCharacter().character}]: ${f.getFailure()}` + ); + }); + console.log('\n'); + throw new Error(`${failures.length} linting errors found in ${lintResults.length} files`); + } +}); + +const bundlePlugin = useSpinner('Bundling plugin', async () => { + // @ts-ignore + const bundle = await rollup.rollup(inputOptions()); + // TODO: we can work on more verbose output + await bundle.generate(outputOptions); + await bundle.write(outputOptions); +}); + +const pluginBuildRunner: TaskRunner = async () => { + await clean(); + // @ts-ignore + await lintPlugin(); + await testPlugin({ updateSnapshot: false, coverage: false }); + // @ts-ignore + await bundlePlugin(); +}; + +export const pluginBuildTask = new Task('Build plugin', pluginBuildRunner); 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 0000000000000..1793d7ddbc073 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.dev.ts @@ -0,0 +1,9 @@ +import { Task, TaskRunner } from './task'; +import { bundlePlugin, PluginBundleOptions } from './plugin/bundle'; + +const pluginDevRunner: TaskRunner = async options => { + 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.tests.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.tests.ts new file mode 100644 index 0000000000000..7840512892268 --- /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/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts new file mode 100644 index 0000000000000..4c905764b4710 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -0,0 +1,29 @@ +import path = require('path'); +import * as jestCLI from 'jest-cli'; +import * as rollup from 'rollup'; +import { inputOptions, outputOptions } from '../../../config/rollup.plugin.config'; + +export interface PluginBundleOptions { + watch: boolean; +} + +export const bundlePlugin = async ({ watch }: PluginBundleOptions) => { + if (watch) { + const watcher = rollup.watch([ + { + ...inputOptions(), + output: outputOptions, + watch: { + chokidar: true, + clearScreen: true, + }, + }, + ]); + } else { + // @ts-ignore + const bundle = await rollup.rollup(inputOptions()); + // TODO: we can work on more verbose output + await bundle.generate(outputOptions); + await bundle.write(outputOptions); + } +}; 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 0000000000000..eba45d6c24afd --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts @@ -0,0 +1,22 @@ +import path = require('path'); +import * as jestCLI from 'jest-cli'; +import { useSpinner } from '../../utils/useSpinner'; +import { jestConfig } from '../../../config/jest.plugin.config'; + +export interface PluginTestOptions { + updateSnapshot: boolean; + coverage: boolean; +} + +export const testPlugin = useSpinner('Running tests', async ({ updateSnapshot, coverage }) => { + const testConfig = jestConfig(); + + testConfig.updateSnapshot = updateSnapshot; + testConfig.coverage = coverage; + + const results = await jestCLI.runCLI(testConfig as any, [process.cwd()]); + + if (results.results.numFailedTests > 0 || results.results.numFailedTestSuites > 0) { + throw new Error('Tests failed'); + } +}); diff --git a/scripts/cli/tasks/precommit.ts b/packages/grafana-toolkit/src/cli/tasks/precommit.ts similarity index 70% rename from scripts/cli/tasks/precommit.ts rename to packages/grafana-toolkit/src/cli/tasks/precommit.ts index b759aa414f20c..6431b486451f8 100644 --- a/scripts/cli/tasks/precommit.ts +++ b/packages/grafana-toolkit/src/cli/tasks/precommit.ts @@ -1,6 +1,8 @@ import { Task, TaskRunner } from './task'; import chalk from 'chalk'; +// @ts-ignore import get from 'lodash/get'; +// @ts-ignore import flatten from 'lodash/flatten'; import execa = require('execa'); const simpleGit = require('simple-git/promise')(process.cwd()); @@ -28,13 +30,18 @@ const tasks = { const precommitRunner: TaskRunner = async () => { const status = await simpleGit.status(); const sassFiles = status.files.filter( - file => (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.scss)$/g) || file.path.indexOf('.sass-lint.yml') > -1 + (file: any) => + (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.scss)$/g) || file.path.indexOf('.sass-lint.yml') > -1 ); - const tsFiles = status.files.filter(file => (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.(ts|tsx))$/g)); - const testFiles = status.files.filter(file => (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.test.(ts|tsx))$/g)); - const goTestFiles = status.files.filter(file => (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\_test.go)$/g)); - const grafanaUiFiles = tsFiles.filter(file => (file.path as string).indexOf('grafana-ui') > -1); + const tsFiles = status.files.filter((file: any) => (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.(ts|tsx))$/g)); + const testFiles = status.files.filter((file: any) => + (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\.test.(ts|tsx))$/g) + ); + const goTestFiles = status.files.filter((file: any) => + (file.path as string).match(/^[a-zA-Z0-9\_\-\/]+(\_test.go)$/g) + ); + const grafanaUiFiles = tsFiles.filter((file: any) => (file.path as string).indexOf('grafana-ui') > -1); const grafanaUIFilesChangedOnly = tsFiles.length > 0 && tsFiles.length - grafanaUiFiles.length === 0; const coreFilesChangedOnly = tsFiles.length > 0 && grafanaUiFiles.length === 0; @@ -69,13 +76,13 @@ const precommitRunner: TaskRunner = async () => { const task = execa('grunt', gruntTasks); // @ts-ignore const stream = task.stdout; - stream.pipe(process.stdout); + if (stream) { + stream.pipe(process.stdout); + } return task; } console.log(chalk.yellow('Skipping precommit checks, not front-end changes detected')); return; }; -export const precommitTask = new Task(); -precommitTask.setName('Precommit task'); -precommitTask.setRunner(precommitRunner); +export const precommitTask = new Task('Precommit task', precommitRunner); diff --git a/scripts/cli/tasks/searchTestDataSetup.ts b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts similarity index 93% rename from scripts/cli/tasks/searchTestDataSetup.ts rename to packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts index 2bac9b6c903c1..915f431a9578c 100644 --- a/scripts/cli/tasks/searchTestDataSetup.ts +++ b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import _ from 'lodash'; import { Task, TaskRunner } from './task'; interface SearchTestDataSetupOptions { @@ -14,7 +13,7 @@ const client = axios.create({ }, }); -export async function getUser(user): Promise { +export async function getUser(user: any): Promise { console.log('Creating user ' + user.name); const search = await client.get('/users/search', { params: { query: user.login }, @@ -112,6 +111,7 @@ const searchTestDataSetupRunnner: TaskRunner = async } }; -export const searchTestDataSetupTask = new Task(); -searchTestDataSetupTask.setName('Search test data setup'); -searchTestDataSetupTask.setRunner(searchTestDataSetupRunnner); +export const searchTestDataSetupTask = new Task( + 'Search test data setup', + searchTestDataSetupRunnner +); diff --git a/scripts/cli/tasks/task.ts b/packages/grafana-toolkit/src/cli/tasks/task.ts similarity index 62% rename from scripts/cli/tasks/task.ts rename to packages/grafana-toolkit/src/cli/tasks/task.ts index cc7d77a0664e9..4524508bd83c3 100644 --- a/scripts/cli/tasks/task.ts +++ b/packages/grafana-toolkit/src/cli/tasks/task.ts @@ -1,11 +1,10 @@ export type TaskRunner = (options: T) => Promise; export class Task { - name: string; - runner: (options: TOptions) => Promise; - options: TOptions; + options: TOptions = {} as any; - setName = name => { + constructor(public name: string, public runner: TaskRunner) {} + setName = (name: string) => { this.name = name; }; @@ -13,7 +12,7 @@ export class Task { this.runner = runner; }; - setOptions = options => { + setOptions = (options: TOptions) => { this.options = 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 0000000000000..4a965e05f9721 --- /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 0000000000000..975b1d6fa7920 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts @@ -0,0 +1,91 @@ +import execa = require('execa'); +import * as fs from 'fs'; +import { changeCwdToGrafanaUi, restoreCwd, changeCwdToGrafanaToolkit } from '../utils/cwd'; +import chalk from 'chalk'; +import { useSpinner } from '../utils/useSpinner'; +import { Task, TaskRunner } from './task'; + +let distDir: string, cwd: string; + +// @ts-ignore +export const clean = useSpinner('Cleaning', async () => await execa('npm', ['run', 'clean'])); + +// @ts-ignore +const compile = useSpinner('Compiling sources', async () => { + try { + await execa('tsc', ['-p', './tsconfig.json']); + } catch (e) { + console.log(e); + throw e; + } +}); + +// @ts-ignore +export const savePackage = useSpinner<{ + path: string; + pkg: {}; + // @ts-ignore +}>('Updating package.json', async ({ path, pkg }) => { + return 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.dist.js', + }; + + await savePackage({ + path: `${cwd}/dist/package.json`, + pkg, + }); +}; + +const moveFiles = () => { + const files = [ + 'README.md', + 'CHANGELOG.md', + 'bin/grafana-toolkit.dist.js', + 'src/config/tsconfig.plugin.json', + 'src/config/tslint.plugin.json', + ]; + // @ts-ignore + return useSpinner(`Moving ${files.join(', ')} files`, async () => { + const promises = files.map(file => { + return new Promise((resolve, reject) => { + fs.copyFile(`${cwd}/${file}`, `${distDir}/${file}`, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }); + + await Promise.all(promises); + })(); +}; + +const toolkitBuildTaskRunner: TaskRunner = async () => { + cwd = changeCwdToGrafanaToolkit(); + 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'); + await moveFiles(); + restoreCwd(); +}; + +export const toolkitBuildTask = new Task('@grafana/toolkit build', toolkitBuildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tsconfig.json b/packages/grafana-toolkit/src/cli/tsconfig.json new file mode 100644 index 0000000000000..475f3aa406f83 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "commonjs" + } +} diff --git a/scripts/cli/utils/cwd.ts b/packages/grafana-toolkit/src/cli/utils/cwd.ts similarity index 70% rename from scripts/cli/utils/cwd.ts rename to packages/grafana-toolkit/src/cli/utils/cwd.ts index 9b4241b136903..48ca39e5da2ba 100644 --- a/scripts/cli/utils/cwd.ts +++ b/packages/grafana-toolkit/src/cli/utils/cwd.ts @@ -5,6 +5,11 @@ export const changeCwdToGrafanaUi = () => { return process.cwd(); }; +export const changeCwdToGrafanaToolkit = () => { + process.chdir(`${cwd}/packages/grafana-toolkit`); + return process.cwd(); +}; + export const changeCwdToGrafanaUiDist = () => { process.chdir(`${cwd}/packages/grafana-ui/dist`); }; diff --git a/scripts/cli/utils/execTask.ts b/packages/grafana-toolkit/src/cli/utils/execTask.ts similarity index 100% rename from scripts/cli/utils/execTask.ts rename to packages/grafana-toolkit/src/cli/utils/execTask.ts diff --git a/scripts/cli/utils/githubClient.test.ts b/packages/grafana-toolkit/src/cli/utils/githubClient.test.ts similarity index 96% rename from scripts/cli/utils/githubClient.test.ts rename to packages/grafana-toolkit/src/cli/utils/githubClient.test.ts index 95c67cb3111c4..dbdadda0d92c6 100644 --- a/scripts/cli/utils/githubClient.test.ts +++ b/packages/grafana-toolkit/src/cli/utils/githubClient.test.ts @@ -20,6 +20,7 @@ describe('GithubClient', () => { describe('#client', () => { it('it should contain a client', () => { + // @ts-ignore const spy = jest.spyOn(GithubClient.prototype, 'createClient').mockImplementation(() => fakeClient); const github = new GithubClient(); @@ -40,6 +41,7 @@ describe('GithubClient', () => { 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(true); @@ -57,6 +59,7 @@ describe('GithubClient', () => { describe('when the credentials are not defined', () => { it('should throw an error', () => { expect(() => { + // tslint:disable-next-line new GithubClient(true); }).toThrow(/operation needs a GITHUB_USERNAME and GITHUB_ACCESS_TOKEN environment variables/); }); diff --git a/scripts/cli/utils/githubClient.ts b/packages/grafana-toolkit/src/cli/utils/githubClient.ts similarity index 100% rename from scripts/cli/utils/githubClient.ts rename to packages/grafana-toolkit/src/cli/utils/githubClient.ts diff --git a/scripts/cli/utils/useSpinner.ts b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts similarity index 91% rename from scripts/cli/utils/useSpinner.ts rename to packages/grafana-toolkit/src/cli/utils/useSpinner.ts index 298a6516689d6..b901ed96d9c12 100644 --- a/scripts/cli/utils/useSpinner.ts +++ b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts @@ -10,7 +10,7 @@ export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess await fn(options); spinner.succeed(); } catch (e) { - spinner.fail(e); + spinner.fail(e.message || e); if (killProcess) { process.exit(1); } 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 0000000000000..685791bd984ec --- /dev/null +++ b/packages/grafana-toolkit/src/config/jest.plugin.config.ts @@ -0,0 +1,45 @@ +import path = require('path'); +import fs = require('fs'); + +const whitelistedJestConfigOverrides = ['snapshotSerializers']; + +export const jestConfig = () => { + const jestConfigOverrides = require(path.resolve(process.cwd(), 'package.json')).jest; + const blacklistedOverrides = jestConfigOverrides + ? Object.keys(jestConfigOverrides).filter(override => whitelistedJestConfigOverrides.indexOf(override) === -1) + : []; + if (blacklistedOverrides.length > 0) { + console.error("\ngrafana-toolkit doesn't support following Jest options: ", blacklistedOverrides); + console.log('Supported Jest options are: ', JSON.stringify(whitelistedJestConfigOverrides)); + throw new Error('Provided Jest config is not supported'); + } + + const shimsFilePath = path.resolve(process.cwd(), 'config/jest-shim.ts'); + const setupFilePath = path.resolve(process.cwd(), 'config/jest-setup.ts'); + + const setupFile = fs.existsSync(setupFilePath) ? setupFilePath : undefined; + const shimsFile = fs.existsSync(shimsFilePath) ? shimsFilePath : undefined; + const setupFiles = [setupFile, shimsFile].filter(f => f); + const defaultJestConfig = { + preset: 'ts-jest', + verbose: false, + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, + moduleDirectories: ['node_modules', 'src'], + rootDir: process.cwd(), + roots: ['/src'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + setupFiles, + globals: { 'ts-jest': { isolatedModules: true } }, + coverageReporters: ['json-summary', 'text', 'lcov'], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!**/node_modules/**', '!**/vendor/**'], + updateSnapshot: false, + passWithNoTests: true, + }; + + return { + ...defaultJestConfig, + ...jestConfigOverrides, + }; +}; diff --git a/packages/grafana-toolkit/src/config/rollup.plugin.config.ts b/packages/grafana-toolkit/src/config/rollup.plugin.config.ts new file mode 100644 index 0000000000000..2636ed6b685e1 --- /dev/null +++ b/packages/grafana-toolkit/src/config/rollup.plugin.config.ts @@ -0,0 +1,160 @@ +// @ts-ignore +import resolve from 'rollup-plugin-node-resolve'; +// @ts-ignore +import commonjs from 'rollup-plugin-commonjs'; +// @ts-ignore +import sourceMaps from 'rollup-plugin-sourcemaps'; +// @ts-ignore +import typescript from 'rollup-plugin-typescript2'; +// @ts-ignore +import json from 'rollup-plugin-json'; +// @ts-ignore +import copy from 'rollup-plugin-copy-glob'; +// @ts-ignore +import { terser } from 'rollup-plugin-terser'; +// @ts-ignore +import visualizer from 'rollup-plugin-visualizer'; + +// @ts-ignore +const replace = require('replace-in-file'); +const pkg = require(`${process.cwd()}/package.json`); +const path = require('path'); +const fs = require('fs'); +const tsConfig = require(`${__dirname}/tsconfig.plugin.json`); +import { OutputOptions, InputOptions, GetManualChunk } from 'rollup'; +const { PRODUCTION } = process.env; + +export const outputOptions: OutputOptions = { + dir: 'dist', + format: 'amd', + sourcemap: true, + chunkFileNames: '[name].js', +}; + +const findModuleTs = (base: string, files?: string[], result?: string[]) => { + files = files || fs.readdirSync(base); + result = result || []; + + if (files) { + files.forEach(file => { + const newbase = path.join(base, file); + if (fs.statSync(newbase).isDirectory()) { + result = findModuleTs(newbase, fs.readdirSync(newbase), result); + } else { + if (file.indexOf('module.ts') > -1) { + // @ts-ignore + result.push(newbase); + } + } + }); + } + return result; +}; + +const getModuleFiles = () => { + return findModuleTs(path.resolve(process.cwd(), 'src')); +}; + +const getManualChunk: GetManualChunk = (id: string) => { + // id == absolute path + if (id.endsWith('module.ts')) { + const idx = id.indexOf('/src/'); + if (idx > 0) { + const p = id.substring(idx + 5, id.lastIndexOf('.')); + console.log('MODULE:', id, p); + return p; + } + } + console.log('shared:', id); + return 'shared'; +}; + +const getExternals = () => { + // Those are by default exported by Grafana + const defaultExternals = [ + 'jquery', + 'lodash', + 'moment', + 'rxjs', + 'd3', + 'react', + 'react-dom', + '@grafana/ui', + '@grafana/runtime', + '@grafana/data', + ]; + const toolkitConfig = require(path.resolve(process.cwd(), 'package.json')).grafanaToolkit; + const userDefinedExternals = (toolkitConfig && toolkitConfig.externals) || []; + return [...defaultExternals, ...userDefinedExternals]; +}; + +export const inputOptions = (): InputOptions => { + const inputFiles = getModuleFiles(); + return { + input: inputFiles, + manualChunks: inputFiles.length > 1 ? getManualChunk : undefined, + external: getExternals(), + plugins: [ + // Allow json resolution + json(), + // globals(), + // builtins(), + + // Compile TypeScript files + typescript({ + typescript: require('typescript'), + objectHashIgnoreUnknownHack: true, + tsconfigDefaults: tsConfig, + }), + + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve(), + + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + + // Resolve source maps to the original source + sourceMaps(), + + // Minify + PRODUCTION && terser(), + + // Copy files + copy([{ files: 'src/**/*.{json,svg,png,html}', dest: 'dist' }], { verbose: true }), + + // Help avoid including things accidentally + visualizer({ + filename: 'dist/stats.html', + title: 'Plugin Stats', + }), + + // Custom callback when we are done + finish(), + ], + }; +}; + +function finish() { + return { + name: 'finish', + buildEnd() { + const files = 'dist/plugin.json'; + replace.sync({ + files: files, + from: /%VERSION%/g, + to: pkg.version, + }); + replace.sync({ + files: files, + from: /%TODAY%/g, + to: new Date().toISOString().substring(0, 10), + }); + + if (PRODUCTION) { + console.log('*minified*'); + } + }, + }; +} 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 0000000000000..0e565014b3f8f --- /dev/null +++ b/packages/grafana-toolkit/src/config/tsconfig.plugin.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es5", + "lib": ["es6", "dom"], + "module": "esnext", + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitUseStrict": false, + "noUnusedLocals": true, + "strictNullChecks": true, + "skipLibCheck": true, + "removeComments": false, + "jsx": "react", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "noEmitHelpers": true, + "inlineSourceMap": false, + "sourceMap": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "downlevelIteration": true, + "pretty": true + } +} diff --git a/packages/grafana-toolkit/src/config/tslint.plugin.json b/packages/grafana-toolkit/src/config/tslint.plugin.json new file mode 100644 index 0000000000000..3f0a5b6dc52c0 --- /dev/null +++ b/packages/grafana-toolkit/src/config/tslint.plugin.json @@ -0,0 +1,75 @@ +{ + "rules": { + "array-type": [true, "array-simple"], + "arrow-return-shorthand": true, + "ban": [true, { "name": "Array", "message": "tsstyle#array-constructor" }], + "ban-types": [ + true, + ["Object", "Use {} instead."], + ["String", "Use 'string' instead."], + ["Number", "Use 'number' instead."], + ["Boolean", "Use 'boolean' instead."] + ], + "interface-name": [true, "never-prefix"], + "no-string-throw": true, + "no-unused-expression": true, + "no-unused-variable": false, + "no-use-before-declare": false, + "no-duplicate-variable": true, + "curly": true, + "class-name": true, + "semicolon": [true, "always", "ignore-bound-class-methods"], + "triple-equals": [true, "allow-null-check"], + "comment-format": [false, "check-space"], + "eofline": true, + "forin": false, + "indent": [true, "spaces", 2], + "jsdoc-format": true, + "label-position": true, + "max-line-length": [true, 150], + "member-access": [true, "no-public"], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-arg": true, + "no-bitwise": false, + "no-conditional-assignment": true, + "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], + "no-construct": true, + "no-debugger": true, + "no-empty": false, + "no-eval": true, + "no-inferrable-types": true, + "no-namespace": [true, "allow-declarations"], + "no-reference": true, + "no-shadowed-variable": false, + "no-string-literal": false, + "no-switch-case-fall-through": false, + "no-trailing-whitespace": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [true, "check-open-brace", "check-catch", "check-else"], + "only-arrow-functions": [true, "allow-declarations", "allow-named-functions"], + "prefer-const": true, + "radix": true, + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": [ + true, + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore", + "allow-pascal-case" + ], + "use-isnan": true, + "whitespace": [true, "check-branch", "check-decl", "check-type", "check-preblock"] + } +} diff --git a/packages/grafana-toolkit/tsconfig.json b/packages/grafana-toolkit/tsconfig.json new file mode 100644 index 0000000000000..28b734aa9cd48 --- /dev/null +++ b/packages/grafana-toolkit/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { + "module": "commonjs", + "rootDirs": ["."], + "outDir": "dist/src", + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "typeRoots": ["./node_modules/@types"], + "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, + "removeComments": false, + "esModuleInterop": true, + "lib": ["es2015", "es2017.string"] + } +} diff --git a/packages/grafana-toolkit/tslint.json b/packages/grafana-toolkit/tslint.json new file mode 100644 index 0000000000000..f512937362444 --- /dev/null +++ b/packages/grafana-toolkit/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tslint.json", + "rules": { + "import-blacklist": [true, ["^@grafana/runtime.*"]] + } +} diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index 935124e99ba04..f897127b6605a 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -17,31 +17,35 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra 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. ## Building @grafana/ui -To build @grafana/ui run `npm run gui:build` script *from Grafana repository root*. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package. + +To build @grafana/ui run `npm run gui:build` script _from Grafana repository root_. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package. ## Releasing new version -To release new version run `npm run gui:release` script *from Grafana repository root*. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. + +To release new version run `npm run gui:release` script _from Grafana repository root_. This has to be done on the master branch. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. When the new package is published, create a PR with the bumped version in package.json. ### Automatic version bump + When running `npm run gui:release` package.json file will be automatically updated. Also, package.json file will be commited and pushed to upstream branch. ### Manual version bump -To use `package.json` defined version run `npm run gui:release --usePackageJsonVersion` *from Grafana repository root*. + +Manually update the version in `package.json` and then run `npm run gui:release --usePackageJsonVersion` _from Grafana repository root_. ### Preparing release package without publishing to NPM registry + For testing purposes there is `npm run gui:releasePrepare` task that prepares distribution package without publishing it to the NPM registry. ### V1 release process overview + 1. Package is compiled with TSC. Typings are created in `/dist` directory, and the compiled js lands in `/compiled` dir 2. Rollup creates a CommonJS package based on compiled sources, and outputs it to `/dist` directory 3. Readme, changelog and index.js files are moved to `/dist` directory 4. Package version is bumped in both `@grafana/ui` package dir and in dist directory. 5. Version commit is created and pushed to master branch -5. Package is published to npm - +6. Package is published to npm ## Versioning + To limit the confusion related to @grafana/ui and Grafana versioning we decided to keep the major version in sync between those two. This means, that first version of @grafana/ui is taged with 6.0.0-alpha.0 to keep version in sync with Grafana 6.0 release. - - diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 32a41d2409161..85564fa54e02d 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -27,6 +27,8 @@ const buildCjsPackage = ({ env }) => { plugins: [ commonjs({ include: /node_modules/, + // When 'rollup-plugin-commonjs' fails to properly convert the CommonJS modules to ES6 one has to manually name the exports + // https://github.com/rollup/rollup-plugin-commonjs#custom-named-exports namedExports: { '../../node_modules/lodash/lodash.js': [ 'flatten', diff --git a/packages/grafana-ui/src/components/Select/_Select.scss b/packages/grafana-ui/src/components/Select/_Select.scss index e4b493055a165..f9c635053dda7 100644 --- a/packages/grafana-ui/src/components/Select/_Select.scss +++ b/packages/grafana-ui/src/components/Select/_Select.scss @@ -193,3 +193,7 @@ $select-input-bg-disabled: $input-bg-disabled; .gf-form-select-box-button-select { height: auto; } + +.select-button { + display: flex; +} diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index 1fea903adbd9d..e2042fa8176f1 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -4,7 +4,7 @@ import { Table } from './Table'; import { getTheme } from '../../themes'; import { migratedTestTable, migratedTestStyles, simpleTable } from './examples'; -import { ScopedVars, SeriesData, GrafanaThemeType } from '../../types/index'; +import { ScopedVars, DataFrame, GrafanaThemeType } from '../../types/index'; import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory'; import { number, boolean } from '@storybook/addon-knobs'; @@ -29,7 +29,7 @@ export function columnIndexToLeter(column: number) { return String.fromCharCode(A + c2); } -export function makeDummyTable(columnCount: number, rowCount: number): SeriesData { +export function makeDummyTable(columnCount: number, rowCount: number): DataFrame { return { fields: Array.from(new Array(columnCount), (x, i) => { return { diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 6ed5f98450d2e..969fe6b69f9b4 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -12,7 +12,7 @@ import { } from 'react-virtualized'; import { Themeable } from '../../types/theme'; -import { sortSeriesData } from '../../utils/processSeriesData'; +import { sortDataFrame } from '../../utils/processDataFrame'; import { TableCellBuilder, @@ -22,11 +22,11 @@ import { simpleCellBuilder, } from './TableCellBuilder'; import { stringToJsRegex } from '@grafana/data'; -import { SeriesData } from '../../types/data'; +import { DataFrame } from '../../types/data'; import { InterpolateFunction } from '../../types/panel'; export interface Props extends Themeable { - data: SeriesData; + data: DataFrame; minColumnWidth: number; showHeader: boolean; @@ -44,7 +44,7 @@ export interface Props extends Themeable { interface State { sortBy?: number; sortDirection?: SortDirectionType; - data: SeriesData; + data: DataFrame; } interface ColumnRenderInfo { @@ -115,7 +115,7 @@ export class Table extends Component { // Update the data when data or sort changes if (dataChanged || sortBy !== prevState.sortBy || sortDirection !== prevState.sortDirection) { this.scrollToTop = true; - this.setState({ data: sortSeriesData(data, sortBy, sortDirection === 'DESC') }); + this.setState({ data: sortDataFrame(data, sortBy, sortDirection === 'DESC') }); } } @@ -170,7 +170,7 @@ export class Table extends Component { this.setState({ sortBy: sort, sortDirection: dir }); }; - /** Converts the grid coordinates to SeriesData coordinates */ + /** Converts the grid coordinates to DataFrame coordinates */ getCellRef = (rowIndex: number, columnIndex: number): DataIndex => { const { showHeader, rotate } = this.props; const rowOffset = showHeader ? -1 : 0; diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx index 4eaa1dca9ff3a..df1b99cacefef 100644 --- a/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx +++ b/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import TableInputCSV from './TableInputCSV'; import { action } from '@storybook/addon-actions'; -import { SeriesData } from '../../types/data'; +import { DataFrame } from '../../types/data'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; const TableInputStories = storiesOf('UI/Table/Input', module); @@ -16,7 +16,7 @@ TableInputStories.add('default', () => { width={400} height={'90vh'} text={'a,b,c\n1,2,3'} - onSeriesParsed={(data: SeriesData[], text: string) => { + onSeriesParsed={(data: DataFrame[], text: string) => { console.log('Data', data, text); action('Data')(data, text); }} diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx index 7052c69fa4b2d..29ba77d3a4921 100644 --- a/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import renderer from 'react-test-renderer'; import TableInputCSV from './TableInputCSV'; -import { SeriesData } from '../../types/data'; +import { DataFrame } from '../../types/data'; describe('TableInputCSV', () => { it('renders correctly', () => { @@ -12,7 +12,7 @@ describe('TableInputCSV', () => { width={'100%'} height={200} text={'a,b,c\n1,2,3'} - onSeriesParsed={(data: SeriesData[], text: string) => { + onSeriesParsed={(data: DataFrame[], text: string) => { // console.log('Table:', table, 'from:', text); }} /> diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.tsx index b45a8fba4c092..6e3644c88407b 100644 --- a/packages/grafana-ui/src/components/Table/TableInputCSV.tsx +++ b/packages/grafana-ui/src/components/Table/TableInputCSV.tsx @@ -1,6 +1,6 @@ import React from 'react'; import debounce from 'lodash/debounce'; -import { SeriesData } from '../../types/data'; +import { DataFrame } from '../../types/data'; import { CSVConfig, readCSV } from '../../utils/csv'; interface Props { @@ -8,12 +8,12 @@ interface Props { text: string; width: string | number; height: string | number; - onSeriesParsed: (data: SeriesData[], text: string) => void; + onSeriesParsed: (data: DataFrame[], text: string) => void; } interface State { text: string; - data: SeriesData[]; + data: DataFrame[]; } /** diff --git a/packages/grafana-ui/src/components/Table/examples.ts b/packages/grafana-ui/src/components/Table/examples.ts index 9fce97f49d479..3edba42732d6e 100644 --- a/packages/grafana-ui/src/components/Table/examples.ts +++ b/packages/grafana-ui/src/components/Table/examples.ts @@ -1,4 +1,4 @@ -import { SeriesData } from '../../types/data'; +import { DataFrame } from '../../types/data'; import { ColumnStyle } from './TableCellBuilder'; import { getColorDefinitionByName } from '../../utils/namedColorsPalette'; @@ -22,7 +22,7 @@ export const migratedTestTable = { { name: 'RangeMappingColored' }, ], rows: [[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2]], -} as SeriesData; +} as DataFrame; export const migratedTestStyles: ColumnStyle[] = [ { diff --git a/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx index 454abfe4e0dd7..9195e2c824f9b 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx @@ -178,9 +178,7 @@ export class TimePicker extends PureComponent { const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => ( <> {timeRange.from.format(TIME_FORMAT)} -
- to -
+
to
{timeRange.to.format(TIME_FORMAT)} ); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx index 0f255df51b0e1..2d94d4feab464 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx @@ -50,6 +50,9 @@ export class TimePickerPopover extends Component { }; onToCalendarChanged = (value: DateTime) => { + value.set('h', 23); + value.set('m', 59); + value.set('s', 0); this.setState({ to: value }); }; diff --git a/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss b/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss index cc63a2cd19683..414f996855729 100644 --- a/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss +++ b/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss @@ -7,10 +7,6 @@ } } -.time-picker-popover-popper { - z-index: $zindex-timepicker-popover; -} - .time-picker-utc { color: $orange; font-size: 75%; @@ -30,10 +26,11 @@ color: $popover-color; box-shadow: $popover-shadow; position: absolute; + z-index: $zindex-dropdown; flex-direction: column; max-width: 600px; - top: 48px; - right: 20px; + top: 50px; + right: 0px; .time-picker-popover-body { display: flex; @@ -188,11 +185,6 @@ $arrowPadding: $arrowPaddingToBorder * 3; @include media-breakpoint-down(md) { .time-picker-popover { - margin-left: $spacer; - display: flex; - flex-flow: column nowrap; - max-width: 400px; - .time-picker-popover-title { font-size: $font-size-md; } @@ -217,6 +209,56 @@ $arrowPadding: $arrowPaddingToBorder * 3; } .time-picker-calendar { - width: 100%; + width: 320px; + } +} + +.time-picker-button-select { + .select-button-value { + display: none; + + @include media-breakpoint-up(sm) { + display: inline-block; + max-width: 100px; + overflow: hidden; + } + + @include media-breakpoint-up(md) { + display: inline-block; + max-width: 150px; + overflow: hidden; + } + + @include media-breakpoint-up(lg) { + max-width: 300px; + } + } +} + +// special rules for when within explore toolbar in split +.explore-toolbar.splitted { + .time-picker-button-select { + .select-button-value { + display: none; + + @include media-breakpoint-up(xl) { + display: inline-block; + max-width: 100px; + } + + @media only screen and (max-width: 1545px) { + max-width: 300px; + } + } + } +} + +.dashboard-timepicker-wrapper { + position: relative; + display: flex; + + .gf-form-select-box__menu { + right: 0; + left: unset; } } diff --git a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index d4a4dd6f016f2..f8e597ae4e836 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -171,7 +171,6 @@ $zindex-tooltip: ${theme.zIndex.tooltip}; $zindex-modal-backdrop: ${theme.zIndex.modalBackdrop}; $zindex-modal: ${theme.zIndex.modal}; $zindex-typeahead: ${theme.zIndex.typeahead}; -$zindex-timepicker-popover: 1070; // Buttons // diff --git a/packages/grafana-ui/src/types/data.ts b/packages/grafana-ui/src/types/data.ts index d1a1a78577761..d7a39d1db212b 100644 --- a/packages/grafana-ui/src/types/data.ts +++ b/packages/grafana-ui/src/types/data.ts @@ -55,7 +55,7 @@ export interface Labels { [key: string]: string; } -export interface SeriesData extends QueryResultBase { +export interface DataFrame extends QueryResultBase { name?: string; fields: Field[]; rows: any[][]; diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index d6d98639cba5a..976b5e06a9a90 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -1,7 +1,7 @@ import { ComponentType, ComponentClass } from 'react'; -import { TimeRange } from './time'; +import { TimeRange, RawTimeRange } from './time'; import { PluginMeta, GrafanaPlugin } from './plugin'; -import { TableData, TimeSeries, SeriesData, LoadingState } from './data'; +import { TableData, TimeSeries, DataFrame, LoadingState } from './data'; import { PanelData } from './panel'; import { LogRowModel } from './logs'; @@ -284,11 +284,11 @@ export interface ExploreStartPageProps { } /** - * Starting in v6.2 SeriesData can represent both TimeSeries and TableData + * Starting in v6.2 DataFrame can represent both TimeSeries and TableData */ export type LegacyResponseData = TimeSeries | TableData | any; -export type DataQueryResponseData = SeriesData | LegacyResponseData; +export type DataQueryResponseData = DataFrame | LegacyResponseData; export type DataStreamObserver = (event: DataStreamState) => void; @@ -313,7 +313,7 @@ export interface DataStreamState { /** * Series data may not be known yet */ - series?: SeriesData[]; + series?: DataFrame[]; /** * Error in stream (but may still be running) @@ -323,7 +323,7 @@ export interface DataStreamState { /** * Optionally return only the rows that changed in this event */ - delta?: SeriesData[]; + delta?: DataFrame[]; /** * Stop listening to this stream @@ -356,6 +356,8 @@ export interface DataQuery { * For non mixed scenarios this is undefined. */ datasource?: string | null; + + metric?: any; } export interface DataQueryError { @@ -383,6 +385,7 @@ export interface DataQueryRequest { requestId: string; // Used to identify results and optionally cancel the request in backendSrv timezone: string; range: TimeRange; + rangeRaw?: RawTimeRange; timeInfo?: string; // The query time description (blue text in the upper right) targets: TQuery[]; panelId: number; diff --git a/packages/grafana-ui/src/types/graph.ts b/packages/grafana-ui/src/types/graph.ts index e8b2d4e1141c7..9ea6650e34946 100644 --- a/packages/grafana-ui/src/types/graph.ts +++ b/packages/grafana-ui/src/types/graph.ts @@ -11,3 +11,7 @@ export interface GraphSeriesXY { isVisible: boolean; yAxis: number; } + +export interface CreatePlotOverlay { + (element: JQuery, event: any, plot: { getOptions: () => { events: { manager: any } } }): any; +} diff --git a/packages/grafana-ui/src/types/logs.ts b/packages/grafana-ui/src/types/logs.ts index 3d8cc0951b834..c0b295c55aaf5 100644 --- a/packages/grafana-ui/src/types/logs.ts +++ b/packages/grafana-ui/src/types/logs.ts @@ -46,6 +46,7 @@ export interface LogRowModel { timeFromNow: string; timeEpochMs: number; timeLocal: string; + timeUtc: string; uniqueLabels?: Labels; } diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 25f30ea29677c..5b62c34924219 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,5 +1,5 @@ import { ComponentClass, ComponentType } from 'react'; -import { LoadingState, SeriesData } from './data'; +import { LoadingState, DataFrame } from './data'; import { TimeRange } from './time'; import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource'; import { PluginMeta, GrafanaPlugin } from './plugin'; @@ -14,7 +14,7 @@ export interface PanelPluginMeta extends PluginMeta { export interface PanelData { state: LoadingState; - series: SeriesData[]; + series: DataFrame[]; request?: DataQueryRequest; error?: DataQueryError; diff --git a/packages/grafana-ui/src/utils/csv.ts b/packages/grafana-ui/src/utils/csv.ts index 60acadc4f21e0..f1f4663d1c26e 100644 --- a/packages/grafana-ui/src/utils/csv.ts +++ b/packages/grafana-ui/src/utils/csv.ts @@ -4,8 +4,8 @@ import defaults from 'lodash/defaults'; import isNumber from 'lodash/isNumber'; // Types -import { SeriesData, Field, FieldType } from '../types/index'; -import { guessFieldTypeFromValue } from './processSeriesData'; +import { DataFrame, Field, FieldType } from '../types/index'; +import { guessFieldTypeFromValue } from './processDataFrame'; export enum CSVHeaderStyle { full, @@ -28,7 +28,7 @@ export interface CSVParseCallbacks { * This can return a modified table to force any * Column configurations */ - onHeader: (table: SeriesData) => void; + onHeader: (table: DataFrame) => void; // Called after each row is read and onRow: (row: any[]) => void; @@ -39,7 +39,7 @@ export interface CSVOptions { callback?: CSVParseCallbacks; } -export function readCSV(csv: string, options?: CSVOptions): SeriesData[] { +export function readCSV(csv: string, options?: CSVOptions): DataFrame[] { return new CSVReader(options).readCSV(csv); } @@ -56,9 +56,9 @@ export class CSVReader { callback?: CSVParseCallbacks; field: FieldParser[]; - series: SeriesData; + series: DataFrame; state: ParseState; - data: SeriesData[]; + data: DataFrame[]; constructor(options?: CSVOptions) { if (!options) { @@ -193,7 +193,7 @@ export class CSVReader { } }; - readCSV(text: string): SeriesData[] { + readCSV(text: string): DataFrame[] { this.data = [this.series]; const papacfg = { @@ -315,7 +315,7 @@ function getHeaderLine(key: string, fields: Field[], config: CSVConfig): string return ''; } -export function toCSV(data: SeriesData[], config?: CSVConfig): string { +export function toCSV(data: DataFrame[], config?: CSVConfig): string { if (!data) { return ''; } diff --git a/packages/grafana-ui/src/utils/fieldDisplay.ts b/packages/grafana-ui/src/utils/fieldDisplay.ts index b6f021bf2df85..4948df3240bb6 100644 --- a/packages/grafana-ui/src/utils/fieldDisplay.ts +++ b/packages/grafana-ui/src/utils/fieldDisplay.ts @@ -8,7 +8,7 @@ import { FieldType, NullValueMode, GrafanaTheme, - SeriesData, + DataFrame, InterpolateFunction, Field, ScopedVars, @@ -36,7 +36,7 @@ export const VAR_FIELD_NAME = '__field_name'; export const VAR_CALC = '__calc'; export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates -function getTitleTemplate(title: string | undefined, stats: string[], data?: SeriesData[]): string { +function getTitleTemplate(title: string | undefined, stats: string[], data?: DataFrame[]): string { // If the title exists, use it as a template variable if (title) { return title; @@ -72,7 +72,7 @@ export interface FieldDisplay { } export interface GetFieldDisplayValuesOptions { - data?: SeriesData[]; + data?: DataFrame[]; fieldOptions: FieldDisplayOptions; replaceVariables: InterpolateFunction; sparkline?: boolean; // Calculate the sparkline diff --git a/packages/grafana-ui/src/utils/fieldReducer.ts b/packages/grafana-ui/src/utils/fieldReducer.ts index 0b2e28fbb4cbc..dc0ef596e25e4 100644 --- a/packages/grafana-ui/src/utils/fieldReducer.ts +++ b/packages/grafana-ui/src/utils/fieldReducer.ts @@ -1,7 +1,7 @@ // Libraries import isNumber from 'lodash/isNumber'; -import { SeriesData, NullValueMode } from '../types/index'; +import { DataFrame, NullValueMode } from '../types/index'; export enum ReducerID { sum = 'sum', @@ -29,7 +29,7 @@ export interface FieldCalcs { } // Internal function -type FieldReducer = (data: SeriesData, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs; +type FieldReducer = (data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs; export interface FieldReducerInfo { id: string; @@ -64,7 +64,7 @@ export function getFieldReducers(ids?: string[]): FieldReducerInfo[] { } interface ReduceFieldOptions { - series: SeriesData; + series: DataFrame; fieldIndex: number; reducers: string[]; // The stats to calculate nullValueMode?: NullValueMode; @@ -222,7 +222,7 @@ function getById(id: string): FieldReducerInfo | undefined { return index[id]; } -function doStandardCalcs(data: SeriesData, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { +function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { const calcs = { sum: 0, max: -Number.MAX_VALUE, @@ -340,16 +340,16 @@ function doStandardCalcs(data: SeriesData, fieldIndex: number, ignoreNulls: bool return calcs; } -function calculateFirst(data: SeriesData, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { +function calculateFirst(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { return { first: data.rows[0][fieldIndex] }; } -function calculateLast(data: SeriesData, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { +function calculateLast(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { return { last: data.rows[data.rows.length - 1][fieldIndex] }; } function calculateChangeCount( - data: SeriesData, + data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean @@ -378,7 +378,7 @@ function calculateChangeCount( } function calculateDistinctCount( - data: SeriesData, + data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean diff --git a/packages/grafana-ui/src/utils/flotPairs.ts b/packages/grafana-ui/src/utils/flotPairs.ts index 9eb50266f4e19..72792a94fd417 100644 --- a/packages/grafana-ui/src/utils/flotPairs.ts +++ b/packages/grafana-ui/src/utils/flotPairs.ts @@ -1,8 +1,8 @@ // Types -import { NullValueMode, GraphSeriesValue, SeriesData } from '../types/index'; +import { NullValueMode, GraphSeriesValue, DataFrame } from '../types/index'; export interface FlotPairsOptions { - series: SeriesData; + series: DataFrame; xIndex: number; yIndex: number; nullValueMode?: NullValueMode; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index fafbfd882f7f8..13542b1985ec2 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -1,4 +1,4 @@ -export * from './processSeriesData'; +export * from './processDataFrame'; export * from './valueFormats/valueFormats'; export * from './colors'; export * from './namedColorsPalette'; diff --git a/packages/grafana-ui/src/utils/logs.ts b/packages/grafana-ui/src/utils/logs.ts index b5c45b635daf1..e73ca589e7056 100644 --- a/packages/grafana-ui/src/utils/logs.ts +++ b/packages/grafana-ui/src/utils/logs.ts @@ -1,5 +1,5 @@ import { LogLevel } from '../types/logs'; -import { SeriesData, FieldType } from '../types/data'; +import { DataFrame, FieldType } from '../types/data'; /** * Returns the log level of a log line. @@ -32,7 +32,7 @@ export function getLogLevelFromKey(key: string): LogLevel { return LogLevel.unknown; } -export function addLogLevelToSeries(series: SeriesData, lineIndex: number): SeriesData { +export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataFrame { return { ...series, // Keeps Tags, RefID etc fields: [...series.fields, { name: 'LogLevel', type: FieldType.string }], diff --git a/packages/grafana-ui/src/utils/moment_wrapper.ts b/packages/grafana-ui/src/utils/moment_wrapper.ts index 014a6f06e616e..e77c2f41196fd 100644 --- a/packages/grafana-ui/src/utils/moment_wrapper.ts +++ b/packages/grafana-ui/src/utils/moment_wrapper.ts @@ -46,6 +46,7 @@ export interface DateTimeDuration { hours: () => number; minutes: () => number; seconds: () => number; + asSeconds: () => number; } export interface DateTime extends Object { @@ -68,6 +69,7 @@ export interface DateTime extends Object { valueOf: () => number; unix: () => number; utc: () => DateTime; + utcOffset: () => number; hour?: () => number; } diff --git a/packages/grafana-ui/src/utils/processSeriesData.test.ts b/packages/grafana-ui/src/utils/processDataFrame.test.ts similarity index 84% rename from packages/grafana-ui/src/utils/processSeriesData.test.ts rename to packages/grafana-ui/src/utils/processDataFrame.test.ts index ea582e89b3a80..d4885daa8fb5c 100644 --- a/packages/grafana-ui/src/utils/processSeriesData.test.ts +++ b/packages/grafana-ui/src/utils/processDataFrame.test.ts @@ -1,21 +1,21 @@ import { - isSeriesData, + isDataFrame, toLegacyResponseData, isTableData, - toSeriesData, + toDataFrame, guessFieldTypes, guessFieldTypeFromValue, -} from './processSeriesData'; -import { FieldType, TimeSeries, SeriesData, TableData } from '../types/data'; +} from './processDataFrame'; +import { FieldType, TimeSeries, DataFrame, TableData } from '../types/data'; import { dateTime } from './moment_wrapper'; -describe('toSeriesData', () => { +describe('toDataFrame', () => { it('converts timeseries to series', () => { const input1 = { target: 'Field Name', datapoints: [[100, 1], [200, 2]], }; - let series = toSeriesData(input1); + let series = toDataFrame(input1); expect(series.fields[0].name).toBe(input1.target); expect(series.rows).toBe(input1.datapoints); @@ -25,16 +25,16 @@ describe('toSeriesData', () => { target: '', datapoints: [[100, 1], [200, 2]], }; - series = toSeriesData(input2); + series = toDataFrame(input2); expect(series.fields[0].name).toEqual('Value'); }); - it('keeps seriesData unchanged', () => { + it('keeps dataFrame unchanged', () => { const input = { fields: [{ text: 'A' }, { text: 'B' }, { text: 'C' }], rows: [[100, 'A', 1], [200, 'B', 2], [300, 'C', 3]], }; - const series = toSeriesData(input); + const series = toDataFrame(input); expect(series).toBe(input); }); @@ -77,12 +77,12 @@ describe('SerisData backwards compatibility', () => { target: 'Field Name', datapoints: [[100, 1], [200, 2]], }; - const series = toSeriesData(timeseries); - expect(isSeriesData(timeseries)).toBeFalsy(); - expect(isSeriesData(series)).toBeTruthy(); + const series = toDataFrame(timeseries); + expect(isDataFrame(timeseries)).toBeFalsy(); + expect(isDataFrame(series)).toBeTruthy(); const roundtrip = toLegacyResponseData(series) as TimeSeries; - expect(isSeriesData(roundtrip)).toBeFalsy(); + expect(isDataFrame(roundtrip)).toBeFalsy(); expect(roundtrip.target).toBe(timeseries.target); }); @@ -91,17 +91,17 @@ describe('SerisData backwards compatibility', () => { columns: [{ text: 'a', unit: 'ms' }, { text: 'b', unit: 'zz' }, { text: 'c', unit: 'yy' }], rows: [[100, 1, 'a'], [200, 2, 'a']], }; - const series = toSeriesData(table); + const series = toDataFrame(table); expect(isTableData(table)).toBeTruthy(); - expect(isSeriesData(series)).toBeTruthy(); + expect(isDataFrame(series)).toBeTruthy(); const roundtrip = toLegacyResponseData(series) as TimeSeries; expect(isTableData(roundtrip)).toBeTruthy(); expect(roundtrip).toMatchObject(table); }); - it('converts SeriesData to TableData to series and back again', () => { - const series: SeriesData = { + it('converts DataFrame to TableData to series and back again', () => { + const series: DataFrame = { refId: 'Z', meta: { somethign: 8, diff --git a/packages/grafana-ui/src/utils/processSeriesData.ts b/packages/grafana-ui/src/utils/processDataFrame.ts similarity index 81% rename from packages/grafana-ui/src/utils/processSeriesData.ts rename to packages/grafana-ui/src/utils/processDataFrame.ts index 38e9abf913581..3772e0d33b6b2 100644 --- a/packages/grafana-ui/src/utils/processSeriesData.ts +++ b/packages/grafana-ui/src/utils/processDataFrame.ts @@ -4,10 +4,10 @@ import isString from 'lodash/isString'; import isBoolean from 'lodash/isBoolean'; // Types -import { SeriesData, Field, TimeSeries, FieldType, TableData, Column } from '../types/index'; +import { DataFrame, Field, TimeSeries, FieldType, TableData, Column } from '../types/index'; import { isDateTime } from './moment_wrapper'; -function convertTableToSeriesData(table: TableData): SeriesData { +function convertTableToDataFrame(table: TableData): DataFrame { return { // rename the 'text' to 'name' field fields: table.columns.map(c => { @@ -23,7 +23,7 @@ function convertTableToSeriesData(table: TableData): SeriesData { }; } -function convertTimeSeriesToSeriesData(timeSeries: TimeSeries): SeriesData { +function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame { return { name: timeSeries.target, fields: [ @@ -84,7 +84,7 @@ export function guessFieldTypeFromValue(v: any): FieldType { /** * Looks at the data to guess the column type. This ignores any existing setting */ -export function guessFieldTypeFromSeries(series: SeriesData, index: number): FieldType | undefined { +export function guessFieldTypeFromSeries(series: DataFrame, index: number): FieldType | undefined { const column = series.fields[index]; // 1. Use the column name to guess @@ -111,7 +111,7 @@ export function guessFieldTypeFromSeries(series: SeriesData, index: number): Fie * @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 */ -export const guessFieldTypes = (series: SeriesData): SeriesData => { +export const guessFieldTypes = (series: DataFrame): DataFrame => { for (let i = 0; i < series.fields.length; i++) { if (!series.fields[i].type) { // Somethign is missing a type return a modified copy @@ -134,26 +134,26 @@ export const guessFieldTypes = (series: SeriesData): SeriesData => { return series; }; -export const isTableData = (data: any): data is SeriesData => data && data.hasOwnProperty('columns'); +export const isTableData = (data: any): data is DataFrame => data && data.hasOwnProperty('columns'); -export const isSeriesData = (data: any): data is SeriesData => data && data.hasOwnProperty('fields'); +export const isDataFrame = (data: any): data is DataFrame => data && data.hasOwnProperty('fields'); -export const toSeriesData = (data: any): SeriesData => { +export const toDataFrame = (data: any): DataFrame => { if (data.hasOwnProperty('fields')) { - return data as SeriesData; + return data as DataFrame; } if (data.hasOwnProperty('datapoints')) { - return convertTimeSeriesToSeriesData(data); + return convertTimeSeriesToDataFrame(data); } if (data.hasOwnProperty('columns')) { - return convertTableToSeriesData(data); + return convertTableToDataFrame(data); } // TODO, try to convert JSON/Array to seriesta? console.warn('Can not convert', data); throw new Error('Unsupported data format'); }; -export const toLegacyResponseData = (series: SeriesData): TimeSeries | TableData => { +export const toLegacyResponseData = (series: DataFrame): TimeSeries | TableData => { const { fields, rows } = series; if (fields.length === 2) { @@ -182,7 +182,7 @@ export const toLegacyResponseData = (series: SeriesData): TimeSeries | TableData }; }; -export function sortSeriesData(data: SeriesData, sortIndex?: number, reverse = false): SeriesData { +export function sortDataFrame(data: DataFrame, sortIndex?: number, reverse = false): DataFrame { if (isNumber(sortIndex)) { const copy = { ...data, diff --git a/pkg/api/api.go b/pkg/api/api.go index 9f80f1cb4fbaa..27f68f90f0100 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" ) func (hs *HTTPServer) registerRoutes() { @@ -105,7 +105,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api for dashboard snapshots - r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) + r.Post("/api/snapshots/", bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", GetSharingOptions) r.Get("/api/snapshots/:key", GetDashboardSnapshot) r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey)) @@ -120,7 +120,7 @@ func (hs *HTTPServer) registerRoutes() { // user (signed in) apiRoute.Group("/user", func(userRoute routing.RouteRegister) { userRoute.Get("/", Wrap(GetSignedInUser)) - userRoute.Put("/", bind(m.UpdateUserCommand{}), Wrap(UpdateSignedInUser)) + userRoute.Put("/", bind(models.UpdateUserCommand{}), Wrap(UpdateSignedInUser)) userRoute.Post("/using/:id", Wrap(UserSetUsingOrg)) userRoute.Get("/orgs", Wrap(GetSignedInUserOrgList)) userRoute.Get("/teams", Wrap(GetSignedInUserTeamList)) @@ -128,7 +128,7 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Post("/stars/dashboard/:id", Wrap(StarDashboard)) userRoute.Delete("/stars/dashboard/:id", Wrap(UnstarDashboard)) - userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword)) + userRoute.Put("/password", bind(models.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword)) userRoute.Get("/quotas", Wrap(GetUserQuotas)) userRoute.Put("/helpflags/:id", Wrap(SetHelpFlag)) // For dev purpose @@ -138,7 +138,7 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens)) - userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) + userRoute.Post("/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) }) // users (admin permission required) @@ -150,18 +150,18 @@ func (hs *HTTPServer) registerRoutes() { usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) - usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), Wrap(UpdateUser)) + usersRoute.Put("/:id", bind(models.UpdateUserCommand{}), Wrap(UpdateUser)) usersRoute.Post("/:id/using/:orgId", Wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) + teamsRoute.Post("/", bind(models.CreateTeamCommand{}), Wrap(hs.CreateTeam)) + teamsRoute.Put("/:teamId", bind(models.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) - teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) + teamsRoute.Post("/:teamId/members", bind(models.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) + teamsRoute.Put("/:teamId/members/:userId", bind(models.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences)) @@ -183,8 +183,8 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrgCurrent)) orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddressCurrent)) - orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg)) - orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg)) + orgRoute.Post("/users", quota("user"), bind(models.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg)) + orgRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", Wrap(RemoveOrgUserForCurrentOrg)) // invites @@ -203,7 +203,7 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), Wrap(CreateOrg)) + apiRoute.Post("/orgs", quota("org"), bind(models.CreateOrgCommand{}), Wrap(CreateOrg)) // search all orgs apiRoute.Get("/orgs", reqGrafanaAdmin, Wrap(SearchOrgs)) @@ -215,11 +215,11 @@ func (hs *HTTPServer) registerRoutes() { orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddress)) orgsRoute.Delete("/", Wrap(DeleteOrgByID)) orgsRoute.Get("/users", Wrap(GetOrgUsers)) - orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), Wrap(AddOrgUser)) - orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser)) + orgsRoute.Post("/users", bind(models.AddOrgUserCommand{}), Wrap(AddOrgUser)) + orgsRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser)) orgsRoute.Delete("/users/:userId", Wrap(RemoveOrgUser)) orgsRoute.Get("/quotas", Wrap(GetOrgQuotas)) - orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota)) + orgsRoute.Put("/quotas/:target", bind(models.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota)) }, reqGrafanaAdmin) // orgs (admin routes) @@ -230,20 +230,20 @@ func (hs *HTTPServer) registerRoutes() { // auth api keys apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { keysRoute.Get("/", Wrap(GetAPIKeys)) - keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), Wrap(AddAPIKey)) + keysRoute.Post("/", quota("api_key"), bind(models.AddApiKeyCommand{}), Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", Wrap(DeleteAPIKey)) }, reqOrgAdmin) // Preferences apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { - prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), Wrap(SetHomeDashboard)) + prefRoute.Post("/set-home-dash", bind(models.SavePreferencesCommand{}), Wrap(SetHomeDashboard)) }) // Data sources apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { datasourceRoute.Get("/", Wrap(GetDataSources)) - datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource)) - datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) + datasourceRoute.Post("/", quota("data_source"), bind(models.AddDataSourceCommand{}), Wrap(AddDataSource)) + datasourceRoute.Put("/:id", bind(models.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById)) datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName)) datasourceRoute.Get("/:id", Wrap(GetDataSourceById)) @@ -258,7 +258,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards)) - pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) + pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) }, reqOrgAdmin) apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) @@ -269,11 +269,11 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { folderRoute.Get("/", Wrap(GetFolders)) folderRoute.Get("/id/:id", Wrap(GetFolderByID)) - folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder)) + folderRoute.Post("/", bind(models.CreateFolderCommand{}), Wrap(hs.CreateFolder)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", Wrap(GetFolderByUID)) - folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), Wrap(UpdateFolder)) + folderUidRoute.Put("/", bind(models.UpdateFolderCommand{}), Wrap(UpdateFolder)) folderUidRoute.Delete("/", Wrap(DeleteFolder)) folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { @@ -293,7 +293,7 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(hs.PostDashboard)) + dashboardRoute.Post("/db", bind(models.SaveDashboardCommand{}), Wrap(hs.PostDashboard)) dashboardRoute.Get("/home", Wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard)) @@ -322,8 +322,8 @@ func (hs *HTTPServer) registerRoutes() { playlistRoute.Get("/:id/items", ValidateOrgPlaylist, Wrap(GetPlaylistItems)) playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, Wrap(GetPlaylistDashboards)) playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, Wrap(DeletePlaylist)) - playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist)) - playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), Wrap(CreatePlaylist)) + playlistRoute.Put("/:id", reqEditorRole, bind(models.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist)) + playlistRoute.Post("/", reqEditorRole, bind(models.CreatePlaylistCommand{}), Wrap(CreatePlaylist)) }) // Search @@ -348,12 +348,12 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), Wrap(NotificationTest)) - alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification)) - alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification)) + alertNotifications.Post("/", bind(models.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification)) + alertNotifications.Put("/:notificationId", bind(models.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification)) alertNotifications.Get("/:notificationId", Wrap(GetAlertNotificationByID)) alertNotifications.Delete("/:notificationId", Wrap(DeleteAlertNotification)) alertNotifications.Get("/uid/:uid", Wrap(GetAlertNotificationByUID)) - alertNotifications.Put("/uid/:uid", bind(m.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID)) + alertNotifications.Put("/uid/:uid", bind(models.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID)) alertNotifications.Delete("/uid/:uid", Wrap(DeleteAlertNotificationByUID)) }, reqEditorRole) @@ -384,13 +384,13 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/users/:id/disable", Wrap(hs.AdminDisableUser)) adminRoute.Post("/users/:id/enable", Wrap(AdminEnableUser)) adminRoute.Get("/users/:id/quotas", Wrap(GetUserQuotas)) - adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) + adminRoute.Put("/users/:id/quotas/:target", bind(models.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser)) adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens)) - adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) + adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) adminRoute.Post("/provisioning/dashboards/reload", Wrap(hs.AdminProvisioningReloadDasboards)) adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources)) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 7fda738f1cd51..d194429906f60 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -4,32 +4,39 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" + "time" ) -func GetAPIKeys(c *m.ReqContext) Response { - query := m.GetApiKeysQuery{OrgId: c.OrgId} +func GetAPIKeys(c *models.ReqContext) Response { + query := models.GetApiKeysQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { return Error(500, "Failed to list api keys", err) } - result := make([]*m.ApiKeyDTO, len(query.Result)) + result := make([]*models.ApiKeyDTO, len(query.Result)) for i, t := range query.Result { - result[i] = &m.ApiKeyDTO{ - Id: t.Id, - Name: t.Name, - Role: t.Role, + var expiration *time.Time = nil + if t.Expires != nil { + v := time.Unix(*t.Expires, 0) + expiration = &v + } + result[i] = &models.ApiKeyDTO{ + Id: t.Id, + Name: t.Name, + Role: t.Role, + Expiration: expiration, } } return JSON(200, result) } -func DeleteAPIKey(c *m.ReqContext) Response { +func DeleteAPIKey(c *models.ReqContext) Response { id := c.ParamsInt64(":id") - cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} + cmd := &models.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { @@ -39,17 +46,28 @@ func DeleteAPIKey(c *m.ReqContext) Response { return Success("API key deleted") } -func AddAPIKey(c *m.ReqContext, cmd m.AddApiKeyCommand) Response { +func (hs *HTTPServer) AddAPIKey(c *models.ReqContext, cmd models.AddApiKeyCommand) Response { if !cmd.Role.IsValid() { return Error(400, "Invalid role specified", nil) } + if hs.Cfg.ApiKeyMaxSecondsToLive != -1 { + if cmd.SecondsToLive == 0 { + return Error(400, "Number of seconds before expiration should be set", nil) + } + if cmd.SecondsToLive > hs.Cfg.ApiKeyMaxSecondsToLive { + return Error(400, "Number of seconds before expiration is greater than the global limit", nil) + } + } cmd.OrgId = c.OrgId newKeyInfo := apikeygen.New(cmd.OrgId, cmd.Name) cmd.Key = newKeyInfo.HashedKey if err := bus.Dispatch(&cmd); err != nil { + if err == models.ErrInvalidApiKeyExpiration { + return Error(400, err.Error(), nil) + } return Error(500, "Failed to add API key", err) } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 8c5ca6fc7d1d2..9c4bdf5d85e3d 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -171,6 +171,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { Login: userInfo.Login, Email: userInfo.Email, OrgRoles: map[int64]m.RoleType{}, + Groups: userInfo.Groups, } if userInfo.Role != "" { diff --git a/pkg/login/auth.go b/pkg/login/auth.go index ca5b572deb835..19232e5f4410b 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -10,15 +10,12 @@ import ( var ( ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") - ErrNoLDAPServers = errors.New("No LDAP servers are configured") ErrInvalidCredentials = errors.New("Invalid Username or Password") ErrNoEmail = errors.New("Login provider didn't return an email address") ErrProviderDeniedRequest = errors.New("Login provider denied login request") ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") ErrPasswordEmpty = errors.New("No password provided") - ErrUsersQuotaReached = errors.New("Users quota reached") - ErrGettingUserQuota = errors.New("Error getting user quota") ErrUserDisabled = errors.New("User is disabled") ) diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index b07f112b8d385..abd47fcb25734 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -2,6 +2,7 @@ package social import ( "encoding/json" + "errors" "fmt" "net/http" "regexp" @@ -20,6 +21,15 @@ type SocialGithub struct { teamIds []int } +type GithubTeam struct { + Id int `json:"id"` + Slug string `json:"slug"` + URL string `json:"html_url"` + Organization struct { + Login string `json:"login"` + } `json:"organization"` +} + var ( ErrMissingTeamMembership = &Error{"User not a member of one of the required teams"} ErrMissingOrganizationMembership = &Error{"User not a member of one of the required organizations"} @@ -48,8 +58,8 @@ func (s *SocialGithub) IsTeamMember(client *http.Client) bool { } for _, teamId := range s.teamIds { - for _, membershipId := range teamMemberships { - if teamId == membershipId { + for _, membership := range teamMemberships { + if teamId == membership.Id { return true } } @@ -108,14 +118,10 @@ func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) { return email, nil } -func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) { - type Record struct { - Id int `json:"id"` - } - +func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]GithubTeam, error) { url := fmt.Sprintf(s.apiUrl + "/teams?per_page=100") hasMore := true - ids := make([]int, 0) + teams := make([]GithubTeam, 0) for hasMore { @@ -124,27 +130,19 @@ func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) return nil, fmt.Errorf("Error getting team memberships: %s", err) } - var records []Record + var records []GithubTeam err = json.Unmarshal(response.Body, &records) if err != nil { return nil, fmt.Errorf("Error getting team memberships: %s", err) } - newRecords := len(records) - existingRecords := len(ids) - tempIds := make([]int, (newRecords + existingRecords)) - copy(tempIds, ids) - ids = tempIds - - for i, record := range records { - ids[i] = record.Id - } + teams = append(teams, records...) url, hasMore = s.HasMoreRecords(response.Headers) } - return ids, nil + return teams, nil } func (s *SocialGithub) HasMoreRecords(headers http.Header) (string, bool) { @@ -210,11 +208,19 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi return nil, fmt.Errorf("Error getting user info: %s", err) } + teamMemberships, err := s.FetchTeamMemberships(client) + if err != nil { + return nil, fmt.Errorf("Error getting user teams: %s", err) + } + + teams := convertToGroupList(teamMemberships) + userInfo := &BasicUserInfo{ - Name: data.Login, - Login: data.Login, - Id: fmt.Sprintf("%d", data.Id), - Email: data.Email, + Name: data.Login, + Login: data.Login, + Id: fmt.Sprintf("%d", data.Id), + Email: data.Email, + Groups: teams, } organizationsUrl := fmt.Sprintf(s.apiUrl + "/orgs") @@ -236,3 +242,26 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi return userInfo, nil } + +func (t *GithubTeam) GetShorthand() (string, error) { + if t.Organization.Login == "" || t.Slug == "" { + return "", errors.New("Error getting team shorthand") + } + return fmt.Sprintf("@%s/%s", t.Organization.Login, t.Slug), nil +} + +func convertToGroupList(t []GithubTeam) []string { + groups := make([]string, 0) + for _, team := range t { + // Group shouldn't be empty string, otherwise team sync will not work properly + if team.URL != "" { + groups = append(groups, team.URL) + } + teamShorthand, _ := team.GetShorthand() + if teamShorthand != "" { + groups = append(groups, teamShorthand) + } + } + + return groups +} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 46192ba861e07..d2837f8d2f038 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -20,6 +20,7 @@ type BasicUserInfo struct { Login string Company string Role string + Groups []string } type SocialConnector interface { diff --git a/pkg/middleware/auth_proxy/auth_proxy.go b/pkg/middleware/auth_proxy/auth_proxy.go index 94cb954291ba2..ff19627ecbb15 100644 --- a/pkg/middleware/auth_proxy/auth_proxy.go +++ b/pkg/middleware/auth_proxy/auth_proxy.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/multildap" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) const ( @@ -246,19 +247,23 @@ func (auth *AuthProxy) LoginViaHeader() (int64, error) { return 0, newError("Auth proxy header property invalid", nil) } - for _, field := range []string{"Name", "Email", "Login"} { + for _, field := range []string{"Name", "Email", "Login", "Groups"} { if auth.headers[field] == "" { continue } if val := auth.ctx.Req.Header.Get(auth.headers[field]); val != "" { - reflect.ValueOf(extUser).Elem().FieldByName(field).SetString(val) + if field == "Groups" { + extUser.Groups = util.SplitString(val) + } else { + reflect.ValueOf(extUser).Elem().FieldByName(field).SetString(val) + } } } upsert := &models.UpsertUserCommand{ ReqContext: auth.ctx, - SignupAllowed: true, + SignupAllowed: setting.AuthProxyAutoSignUp, ExternalUser: extUser, } err := bus.Dispatch(upsert) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 6b71d75f6e035..49ec9f54b2ade 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -14,26 +14,28 @@ import ( "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) +var getTime = time.Now + var ( ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) ReqSignedIn = Auth(&AuthOptions{ReqSignedIn: true}) - ReqEditorRole = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) + ReqEditorRole = RoleAuth(models.ROLE_EDITOR, models.ROLE_ADMIN) + ReqOrgAdmin = RoleAuth(models.ROLE_ADMIN) ) func GetContextHandler( - ats m.UserTokenService, + ats models.UserTokenService, remoteCache *remotecache.RemoteCache, ) macaron.Handler { return func(c *macaron.Context) { - ctx := &m.ReqContext{ + ctx := &models.ReqContext{ Context: c, - SignedInUser: &m.SignedInUser{}, + SignedInUser: &models.SignedInUser{}, IsSignedIn: false, AllowAnonymous: false, SkipCache: false, @@ -68,19 +70,19 @@ func GetContextHandler( // update last seen every 5min if ctx.ShouldUpdateLastSeenAt() { ctx.Logger.Debug("Updating last user_seen_at", "user_id", ctx.UserId) - if err := bus.Dispatch(&m.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil { + if err := bus.Dispatch(&models.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil { ctx.Logger.Error("Failed to update last_seen_at", "error", err) } } } } -func initContextWithAnonymousUser(ctx *m.ReqContext) bool { +func initContextWithAnonymousUser(ctx *models.ReqContext) bool { if !setting.AnonymousEnabled { return false } - orgQuery := m.GetOrgByNameQuery{Name: setting.AnonymousOrgName} + orgQuery := models.GetOrgByNameQuery{Name: setting.AnonymousOrgName} if err := bus.Dispatch(&orgQuery); err != nil { log.Error(3, "Anonymous access organization error: '%s': %s", setting.AnonymousOrgName, err) return false @@ -88,14 +90,14 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool { ctx.IsSignedIn = false ctx.AllowAnonymous = true - ctx.SignedInUser = &m.SignedInUser{IsAnonymous: true} - ctx.OrgRole = m.RoleType(setting.AnonymousOrgRole) + ctx.SignedInUser = &models.SignedInUser{IsAnonymous: true} + ctx.OrgRole = models.RoleType(setting.AnonymousOrgRole) ctx.OrgId = orgQuery.Result.Id ctx.OrgName = orgQuery.Result.Name return true } -func initContextWithApiKey(ctx *m.ReqContext) bool { +func initContextWithApiKey(ctx *models.ReqContext) bool { var keyString string if keyString = getApiKey(ctx); keyString == "" { return false @@ -109,7 +111,7 @@ func initContextWithApiKey(ctx *m.ReqContext) bool { } // fetch key - keyQuery := m.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} + keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} if err := bus.Dispatch(&keyQuery); err != nil { ctx.JsonApiErr(401, "Invalid API key", err) return true @@ -123,15 +125,21 @@ func initContextWithApiKey(ctx *m.ReqContext) bool { return true } + // check for expiration + if apikey.Expires != nil && *apikey.Expires <= getTime().Unix() { + ctx.JsonApiErr(401, "Expired API key", err) + return true + } + ctx.IsSignedIn = true - ctx.SignedInUser = &m.SignedInUser{} + ctx.SignedInUser = &models.SignedInUser{} ctx.OrgRole = apikey.Role ctx.ApiKeyId = apikey.Id ctx.OrgId = apikey.OrgId return true } -func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { +func initContextWithBasicAuth(ctx *models.ReqContext, orgId int64) bool { if !setting.BasicAuthEnabled { return false @@ -148,7 +156,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } - loginQuery := m.GetUserByLoginQuery{LoginOrEmail: username} + loginQuery := models.GetUserByLoginQuery{LoginOrEmail: username} if err := bus.Dispatch(&loginQuery); err != nil { ctx.JsonApiErr(401, "Basic auth failed", err) return true @@ -156,13 +164,13 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { user := loginQuery.Result - loginUserQuery := m.LoginUserQuery{Username: username, Password: password, User: user} + loginUserQuery := models.LoginUserQuery{Username: username, Password: password, User: user} if err := bus.Dispatch(&loginUserQuery); err != nil { ctx.JsonApiErr(401, "Invalid username or password", err) return true } - query := m.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId} + query := models.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId} if err := bus.Dispatch(&query); err != nil { ctx.JsonApiErr(401, "Authentication error", err) return true @@ -173,7 +181,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } -func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool { +func initContextWithToken(authTokenService models.UserTokenService, ctx *models.ReqContext, orgID int64) bool { rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false @@ -186,7 +194,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext return false } - query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} + query := models.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} if err := bus.Dispatch(&query); err != nil { ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err) return false @@ -209,7 +217,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext return true } -func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) { +func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays int) { if setting.Env == setting.DEV { ctx.Logger.Info("new token", "unhashed token", value) } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index e3687f6057d8a..45811586ebb31 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -13,14 +13,29 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/remotecache" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" "gopkg.in/macaron.v1" ) +func mockGetTime() { + var timeSeed int64 + getTime = func() time.Time { + fakeNow := time.Unix(timeSeed, 0) + timeSeed++ + return fakeNow + } +} + +func resetGetTime() { + getTime = time.Now +} + func TestMiddleWareSecurityHeaders(t *testing.T) { setting.ERR_TEMPLATE_NAME = "error-template" @@ -83,7 +98,7 @@ func TestMiddlewareContext(t *testing.T) { }) middlewareScenario(t, "middleware should add Cache-Control header for requests with html response", func(sc *scenarioContext) { - sc.handler(func(c *m.ReqContext) { + sc.handler(func(c *models.ReqContext) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, @@ -125,20 +140,20 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Using basic auth", func(sc *scenarioContext) { - bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { - query.Result = &m.User{ + bus.AddHandler("test", func(query *models.GetUserByLoginQuery) error { + query.Result = &models.User{ Password: util.EncodePassword("myPass", "salt"), Salt: "salt", } return nil }) - bus.AddHandler("test", func(loginUserQuery *m.LoginUserQuery) error { + bus.AddHandler("test", func(loginUserQuery *models.LoginUserQuery) error { return nil }) - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) @@ -156,8 +171,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Valid api key", func(sc *scenarioContext) { keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -170,15 +185,15 @@ func TestMiddlewareContext(t *testing.T) { Convey("Should init middleware context", func() { So(sc.context.IsSignedIn, ShouldEqual, true) So(sc.context.OrgId, ShouldEqual, 12) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) }) middlewareScenario(t, "Valid api key, but does not match db hash", func(sc *scenarioContext) { keyhash := "something_not_matching" - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -190,11 +205,34 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario(t, "Valid api key, but expired", func(sc *scenarioContext) { + mockGetTime() + defer resetGetTime() + + keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + + // api key expired one second before + expires := getTime().Add(-1 * time.Second).Unix() + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, + Expires: &expires} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + Convey("Should return 401", func() { + So(sc.resp.Code, ShouldEqual, 401) + So(sc.respJson["message"], ShouldEqual, "Expired API key") + }) + }) + middlewareScenario(t, "Valid api key via Basic auth", func(sc *scenarioContext) { keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -208,20 +246,20 @@ func TestMiddlewareContext(t *testing.T) { Convey("Should init middleware context", func() { So(sc.context.IsSignedIn, ShouldEqual, true) So(sc.context.OrgId, ShouldEqual, 12) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) }) middlewareScenario(t, "Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ UserId: 12, UnhashedToken: unhashedToken, }, nil @@ -244,19 +282,19 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *m.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -291,8 +329,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return nil, m.ErrUserTokenNotFound + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return nil, models.ErrUserTokenNotFound } sc.fakeReq("GET", "/").exec() @@ -307,12 +345,12 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "When anonymous access is enabled", func(sc *scenarioContext) { setting.AnonymousEnabled = true setting.AnonymousOrgName = "test" - setting.AnonymousOrgRole = string(m.ROLE_EDITOR) + setting.AnonymousOrgRole = string(models.ROLE_EDITOR) - bus.AddHandler("test", func(query *m.GetOrgByNameQuery) error { + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { So(query.Name, ShouldEqual, "test") - query.Result = &m.Org{Id: 2, Name: "test"} + query.Result = &models.Org{Id: 2, Name: "test"} return nil }) @@ -321,7 +359,7 @@ func TestMiddlewareContext(t *testing.T) { Convey("should init context with org info", func() { So(sc.context.UserId, ShouldEqual, 0) So(sc.context.OrgId, ShouldEqual, 2) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) Convey("context signed in should be false", func() { @@ -339,8 +377,8 @@ func TestMiddlewareContext(t *testing.T) { name := "markelog" middlewareScenario(t, "should not sync the user if it's in the cache", func(sc *scenarioContext) { - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: query.UserId} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: query.UserId} return nil }) @@ -358,20 +396,39 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario(t, "should respect auto signup option", func(sc *scenarioContext) { + setting.LDAPEnabled = false + setting.AuthProxyAutoSignUp = false + var actualAuthProxyAutoSignUp *bool = nil + + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + actualAuthProxyAutoSignUp = &cmd.SignupAllowed + return login.ErrInvalidCredentials + }) + + sc.fakeReq("GET", "/") + sc.req.Header.Add(setting.AuthProxyHeaderName, name) + sc.exec() + + assert.False(t, *actualAuthProxyAutoSignUp) + assert.Equal(t, sc.resp.Code, 500) + assert.Nil(t, sc.context) + }) + middlewareScenario(t, "should create an user from a header", func(sc *scenarioContext) { setting.LDAPEnabled = false setting.AuthProxyAutoSignUp = true - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { if query.UserId > 0 { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil } - return m.ErrUserNotFound + return models.ErrUserNotFound }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -389,13 +446,13 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "should get an existing user from header", func(sc *scenarioContext) { setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 12} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 12} return nil }) @@ -414,13 +471,13 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -440,13 +497,13 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyWhitelist = "8.8.8.8" setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -489,7 +546,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { sc.m.Use(OrgRedirect()) - sc.defaultHandler = func(c *m.ReqContext) { + sc.defaultHandler = func(c *models.ReqContext) { sc.context = c if sc.handlerFunc != nil { sc.handlerFunc(sc.context) @@ -504,7 +561,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { type scenarioContext struct { m *macaron.Macaron - context *m.ReqContext + context *models.ReqContext resp *httptest.ResponseRecorder apiKey string authHeader string @@ -587,4 +644,4 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *m.ReqContext) +type handlerFunc func(c *models.ReqContext) diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index a666cb30c6147..1edc8379d6478 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -6,6 +6,7 @@ import ( ) var ErrInvalidApiKey = errors.New("Invalid API Key") +var ErrInvalidApiKeyExpiration = errors.New("Negative value for SecondsToLive") type ApiKey struct { Id int64 @@ -15,15 +16,17 @@ type ApiKey struct { Role RoleType Created time.Time Updated time.Time + Expires *int64 } // --------------------- // COMMANDS type AddApiKeyCommand struct { - Name string `json:"name" binding:"Required"` - Role RoleType `json:"role" binding:"Required"` - OrgId int64 `json:"-"` - Key string `json:"-"` + Name string `json:"name" binding:"Required"` + Role RoleType `json:"role" binding:"Required"` + OrgId int64 `json:"-"` + Key string `json:"-"` + SecondsToLive int64 `json:"secondsToLive"` Result *ApiKey `json:"-"` } @@ -45,8 +48,9 @@ type DeleteApiKeyCommand struct { // QUERIES type GetApiKeysQuery struct { - OrgId int64 - Result []*ApiKey + OrgId int64 + IncludeInvalid bool + Result []*ApiKey } type GetApiKeyByNameQuery struct { @@ -64,7 +68,8 @@ type GetApiKeyByIdQuery struct { // DTO & Projections type ApiKeyDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Role RoleType `json:"role"` + Id int64 `json:"id"` + Name string `json:"name"` + Role RoleType `json:"role"` + Expiration *time.Time `json:"expiration,omitempty"` } diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 10d6b38cc7a42..6df4dcb34573f 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -24,6 +24,7 @@ const ( DS_ACCESS_PROXY = "proxy" DS_STACKDRIVER = "stackdriver" DS_AZURE_MONITOR = "grafana-azure-monitor-datasource" + DS_LOKI = "loki" ) var ( @@ -82,37 +83,41 @@ func (ds *DataSource) decryptedValue(field string, fallback string) string { } var knownDatasourcePlugins = map[string]bool{ - DS_ES: true, - DS_GRAPHITE: true, - DS_INFLUXDB: true, - DS_INFLUXDB_08: true, - DS_KAIROSDB: true, - DS_CLOUDWATCH: true, - DS_PROMETHEUS: true, - DS_OPENTSDB: true, - DS_POSTGRES: true, - DS_MYSQL: true, - DS_MSSQL: true, - DS_STACKDRIVER: true, - DS_AZURE_MONITOR: true, - "opennms": true, - "abhisant-druid-datasource": true, - "dalmatinerdb-datasource": true, - "gnocci": true, - "zabbix": true, - "newrelic-app": true, - "grafana-datadog-datasource": true, - "grafana-simple-json": true, - "grafana-splunk-datasource": true, - "udoprog-heroic-datasource": true, - "grafana-openfalcon-datasource": true, - "opennms-datasource": true, - "rackerlabs-blueflood-datasource": true, - "crate-datasource": true, - "ayoungprogrammer-finance-datasource": true, - "monasca-datasource": true, - "vertamedia-clickhouse-datasource": true, - "alexanderzobnin-zabbix-datasource": true, + DS_ES: true, + DS_GRAPHITE: true, + DS_INFLUXDB: true, + DS_INFLUXDB_08: true, + DS_KAIROSDB: true, + DS_CLOUDWATCH: true, + DS_PROMETHEUS: true, + DS_OPENTSDB: true, + DS_POSTGRES: true, + DS_MYSQL: true, + DS_MSSQL: true, + DS_STACKDRIVER: true, + DS_AZURE_MONITOR: true, + DS_LOKI: true, + "opennms": true, + "abhisant-druid-datasource": true, + "dalmatinerdb-datasource": true, + "gnocci": true, + "zabbix": true, + "newrelic-app": true, + "grafana-datadog-datasource": true, + "grafana-simple-json": true, + "grafana-splunk-datasource": true, + "udoprog-heroic-datasource": true, + "grafana-openfalcon-datasource": true, + "opennms-datasource": true, + "rackerlabs-blueflood-datasource": true, + "crate-datasource": true, + "ayoungprogrammer-finance-datasource": true, + "monasca-datasource": true, + "vertamedia-clickhouse-datasource": true, + "alexanderzobnin-zabbix-datasource": true, + "grafana-influxdb-flux-datasource": true, + "doitintl-bigquery-datasource": true, + "grafana-azure-data-explorer-datasource": true, } func IsKnownDataSourcePlugin(dsType string) bool { diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index b2a17f420f8be..d53d91666020d 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -261,6 +261,7 @@ func (server *Server) getSearchRequest( return &ldap.SearchRequest{ BaseDN: base, Scope: ldap.ScopeWholeSubtree, + SizeLimit: 1000, DerefAliases: ldap.NeverDerefAliases, Attributes: attributes, Filter: filter, diff --git a/pkg/services/login/errors.go b/pkg/services/login/errors.go index dbf15ea76cd46..7f39389c16df2 100644 --- a/pkg/services/login/errors.go +++ b/pkg/services/login/errors.go @@ -3,13 +3,7 @@ package login import "errors" var ( - ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") - ErrInvalidCredentials = errors.New("Invalid Username or Password") - ErrNoEmail = errors.New("Login provider didn't return an email address") - ErrProviderDeniedRequest = errors.New("Login provider denied login request") - ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") - ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") - ErrPasswordEmpty = errors.New("No password provided") - ErrUsersQuotaReached = errors.New("Users quota reached") - ErrGettingUserQuota = errors.New("Error getting user quota") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrUsersQuotaReached = errors.New("Users quota reached") + ErrGettingUserQuota = errors.New("Error getting user quota") ) diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index 775d4cf644737..13ea1feb7daf5 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -5,7 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" ) func init() { @@ -16,14 +16,18 @@ func init() { bus.AddHandler("sql", AddApiKey) } -func GetApiKeys(query *m.GetApiKeysQuery) error { - sess := x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name") +func GetApiKeys(query *models.GetApiKeysQuery) error { + sess := x.Limit(100, 0).Where("org_id=? and ( expires IS NULL or expires >= ?)", + query.OrgId, timeNow().Unix()).Asc("name") + if query.IncludeInvalid { + sess = x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name") + } - query.Result = make([]*m.ApiKey, 0) + query.Result = make([]*models.ApiKey, 0) return sess.Find(&query.Result) } -func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error { +func DeleteApiKeyCtx(ctx context.Context, cmd *models.DeleteApiKeyCommand) error { return withDbSession(ctx, func(sess *DBSession) error { var rawSql = "DELETE FROM api_key WHERE id=? and org_id=?" _, err := sess.Exec(rawSql, cmd.Id, cmd.OrgId) @@ -31,15 +35,24 @@ func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error { }) } -func AddApiKey(cmd *m.AddApiKeyCommand) error { +func AddApiKey(cmd *models.AddApiKeyCommand) error { return inTransaction(func(sess *DBSession) error { - t := m.ApiKey{ + updated := timeNow() + var expires *int64 = nil + if cmd.SecondsToLive > 0 { + v := updated.Add(time.Second * time.Duration(cmd.SecondsToLive)).Unix() + expires = &v + } else if cmd.SecondsToLive < 0 { + return models.ErrInvalidApiKeyExpiration + } + t := models.ApiKey{ OrgId: cmd.OrgId, Name: cmd.Name, Role: cmd.Role, Key: cmd.Key, - Created: time.Now(), - Updated: time.Now(), + Created: updated, + Updated: updated, + Expires: expires, } if _, err := sess.Insert(&t); err != nil { @@ -50,28 +63,28 @@ func AddApiKey(cmd *m.AddApiKeyCommand) error { }) } -func GetApiKeyById(query *m.GetApiKeyByIdQuery) error { - var apikey m.ApiKey +func GetApiKeyById(query *models.GetApiKeyByIdQuery) error { + var apikey models.ApiKey has, err := x.Id(query.ApiKeyId).Get(&apikey) if err != nil { return err } else if !has { - return m.ErrInvalidApiKey + return models.ErrInvalidApiKey } query.Result = &apikey return nil } -func GetApiKeyByName(query *m.GetApiKeyByNameQuery) error { - var apikey m.ApiKey +func GetApiKeyByName(query *models.GetApiKeyByNameQuery) error { + var apikey models.ApiKey has, err := x.Where("org_id=? AND name=?", query.OrgId, query.KeyName).Get(&apikey) if err != nil { return err } else if !has { - return m.ErrInvalidApiKey + return models.ErrInvalidApiKey } query.Result = &apikey diff --git a/pkg/services/sqlstore/apikey_test.go b/pkg/services/sqlstore/apikey_test.go index 790c8837def83..a1b06db0f9cde 100644 --- a/pkg/services/sqlstore/apikey_test.go +++ b/pkg/services/sqlstore/apikey_test.go @@ -1,31 +1,117 @@ package sqlstore import ( + "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" "testing" - - . "github.com/smartystreets/goconvey/convey" - - m "github.com/grafana/grafana/pkg/models" + "time" ) func TestApiKeyDataAccess(t *testing.T) { + mockTimeNow() + defer resetTimeNow() - Convey("Testing API Key data access", t, func() { + t.Run("Testing API Key data access", func(t *testing.T) { InitTestDB(t) - Convey("Given saved api key", func() { - cmd := m.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"} + t.Run("Given saved api key", func(t *testing.T) { + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"} err := AddApiKey(&cmd) - So(err, ShouldBeNil) + assert.Nil(t, err) - Convey("Should be able to get key by name", func() { - query := m.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1} + t.Run("Should be able to get key by name", func(t *testing.T) { + query := models.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1} err = GetApiKeyByName(&query) - So(err, ShouldBeNil) - So(query.Result, ShouldNotBeNil) + assert.Nil(t, err) + assert.NotNil(t, query.Result) }) }) + + t.Run("Add non expiring key", func(t *testing.T) { + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + query := models.GetApiKeyByNameQuery{KeyName: "non-expiring", OrgId: 1} + err = GetApiKeyByName(&query) + assert.Nil(t, err) + + assert.Nil(t, query.Result.Expires) + }) + + t.Run("Add an expiring key", func(t *testing.T) { + //expires in one hour + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "expiring-in-an-hour", Key: "asd2", SecondsToLive: 3600} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + query := models.GetApiKeyByNameQuery{KeyName: "expiring-in-an-hour", OrgId: 1} + err = GetApiKeyByName(&query) + assert.Nil(t, err) + + assert.True(t, *query.Result.Expires >= timeNow().Unix()) + + // timeNow() has been called twice since creation; once by AddApiKey and once by GetApiKeyByName + // therefore two seconds should be subtracted by next value retuned by timeNow() + // that equals the number by which timeSeed has been advanced + then := timeNow().Add(-2 * time.Second) + expected := then.Add(1 * time.Hour).UTC().Unix() + assert.Equal(t, *query.Result.Expires, expected) + }) + + t.Run("Add a key with negative lifespan", func(t *testing.T) { + //expires in one day + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key-with-negative-lifespan", Key: "asd3", SecondsToLive: -3600} + err := AddApiKey(&cmd) + assert.EqualError(t, err, models.ErrInvalidApiKeyExpiration.Error()) + + query := models.GetApiKeyByNameQuery{KeyName: "key-with-negative-lifespan", OrgId: 1} + err = GetApiKeyByName(&query) + assert.EqualError(t, err, "Invalid API Key") + }) + + t.Run("Add keys", func(t *testing.T) { + //never expires + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key1", Key: "key1", SecondsToLive: 0} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + //expires in 1s + cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key2", Key: "key2", SecondsToLive: 1} + err = AddApiKey(&cmd) + assert.Nil(t, err) + + //expires in one hour + cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key3", Key: "key3", SecondsToLive: 3600} + err = AddApiKey(&cmd) + assert.Nil(t, err) + + // advance mocked getTime by 1s + timeNow() + + query := models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: false} + err = GetApiKeys(&query) + assert.Nil(t, err) + + for _, k := range query.Result { + if k.Name == "key2" { + t.Fatalf("key2 should not be there") + } + } + + query = models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: true} + err = GetApiKeys(&query) + assert.Nil(t, err) + + found := false + for _, k := range query.Result { + if k.Name == "key2" { + found = true + } + } + assert.True(t, found) + }) }) } diff --git a/pkg/services/sqlstore/migrations/apikey_mig.go b/pkg/services/sqlstore/migrations/apikey_mig.go index 928f84c4fb026..bc3de8ef4c437 100644 --- a/pkg/services/sqlstore/migrations/apikey_mig.go +++ b/pkg/services/sqlstore/migrations/apikey_mig.go @@ -78,4 +78,8 @@ func addApiKeyMigrations(mg *Migrator) { {Name: "key", Type: DB_Varchar, Length: 190, Nullable: false}, {Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false}, })) + + mg.AddMigration("Add expires to api_key table", NewAddColumnMigration(apiKeyV2, &Column{ + Name: "expires", Type: DB_BigInt, Nullable: true, + })) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 05d42f1000e1c..f2f7a2957434e 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -259,6 +259,8 @@ type Cfg struct { RemoteCacheOptions *RemoteCacheOptions EditorsCanAdmin bool + + ApiKeyMaxSecondsToLive int64 } type CommandLineArgs struct { @@ -795,6 +797,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30) cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays + cfg.ApiKeyMaxSecondsToLive = auth.Key("api_key_max_seconds_to_live").MustInt64(-1) cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10) if cfg.TokenRotationIntervalMinutes < 2 { diff --git a/public/app/app.ts b/public/app/app.ts index 9f95634330d57..9bc44616f2e6f 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -11,7 +11,6 @@ import 'react'; import 'react-dom'; import 'vendor/bootstrap/bootstrap'; -import 'vendor/angular-ui/ui-bootstrap-tpls'; import 'vendor/angular-other/angular-strap'; import $ from 'jquery'; @@ -36,6 +35,7 @@ import { setupAngularRoutes } from 'app/routes/routes'; import 'app/routes/GrafanaCtrl'; import 'app/features/all'; import { setLocale } from '@grafana/ui/src/utils/moment_wrapper'; +import { setMarkdownOptions } from '@grafana/data'; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); @@ -70,6 +70,8 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); + setMarkdownOptions({ sanitize: !config.disableSanitizeHtml }); + app.config( ( $locationProvider: angular.ILocationProvider, @@ -122,8 +124,6 @@ export class GrafanaApp { 'ang-drag-drop', 'grafana', 'pasvaz.bindonce', - 'ui.bootstrap', - 'ui.bootstrap.tpls', 'react', ]; diff --git a/public/app/core/components/PluginHelp/PluginHelp.tsx b/public/app/core/components/PluginHelp/PluginHelp.tsx index 40aed4a6c0c88..67364ea9366f0 100644 --- a/public/app/core/components/PluginHelp/PluginHelp.tsx +++ b/public/app/core/components/PluginHelp/PluginHelp.tsx @@ -1,6 +1,5 @@ import React, { PureComponent } from 'react'; -// @ts-ignore -import Remarkable from 'remarkable'; +import { renderMarkdown } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; interface Props { @@ -39,8 +38,7 @@ export class PluginHelp extends PureComponent { getBackendSrv() .get(`/api/plugins/${plugin.id}/markdown/${type}`) .then((response: string) => { - const markdown = new Remarkable(); - const helpHtml = markdown.render(response); + const helpHtml = renderMarkdown(response); if (response === '' && type === 'help') { this.setState({ diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 5fe95a182d079..97fe3f7ccc20e 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -6,7 +6,7 @@ import { TimeSeries, Labels, LogLevel, - SeriesData, + DataFrame, findCommonLabels, findUniqueLabels, getLogLevel, @@ -24,7 +24,7 @@ import { } from '@grafana/ui'; import { getThemeColor } from 'app/core/utils/colors'; import { hasAnsiCodes } from 'app/core/utils/text'; -import { dateTime } from '@grafana/ui/src/utils/moment_wrapper'; +import { dateTime, toUtc } from '@grafana/ui/src/utils/moment_wrapper'; export const LogLevelColor = { [LogLevel.critical]: colors[7], @@ -250,15 +250,15 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time }); } -function isLogsData(series: SeriesData) { +function isLogsData(series: DataFrame) { return series.fields.some(f => f.type === FieldType.time) && series.fields.some(f => f.type === FieldType.string); } -export function seriesDataToLogsModel(seriesData: SeriesData[], intervalMs: number): LogsModel { - const metricSeries: SeriesData[] = []; - const logSeries: SeriesData[] = []; +export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number): LogsModel { + const metricSeries: DataFrame[] = []; + const logSeries: DataFrame[] = []; - for (const series of seriesData) { + for (const series of dataFrame) { if (isLogsData(series)) { logSeries.push(series); continue; @@ -289,7 +289,7 @@ export function seriesDataToLogsModel(seriesData: SeriesData[], intervalMs: numb }; } -export function logSeriesToLogsModel(logSeries: SeriesData[]): LogsModel { +export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel { if (logSeries.length === 0) { return undefined; } @@ -355,7 +355,7 @@ export function logSeriesToLogsModel(logSeries: SeriesData[]): LogsModel { } export function processLogSeriesRow( - series: SeriesData, + series: DataFrame, fieldCache: FieldCache, rowIndex: number, uniqueLabels: Labels @@ -369,6 +369,7 @@ export function processLogSeriesRow( const timeEpochMs = time.valueOf(); const timeFromNow = time.fromNow(); const timeLocal = time.format('YYYY-MM-DD HH:mm:ss'); + const timeUtc = toUtc(ts).format('YYYY-MM-DD HH:mm:ss'); let logLevel = LogLevel.unknown; const logLevelField = fieldCache.getFieldByName('level'); @@ -388,6 +389,7 @@ export function processLogSeriesRow( timeFromNow, timeEpochMs, timeLocal, + timeUtc, uniqueLabels, hasAnsi, searchWords, diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index c83f0ce6c1c00..bccbe9ed34af8 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -1,11 +1,11 @@ -import { SeriesData, FieldType, LogsModel, LogsMetaKind, LogsDedupStrategy, LogLevel } from '@grafana/ui'; +import { DataFrame, FieldType, LogsModel, LogsMetaKind, LogsDedupStrategy, LogLevel } from '@grafana/ui'; import { dedupLogRows, calculateFieldStats, calculateLogsLabelStats, getParser, LogsParsers, - seriesDataToLogsModel, + dataFrameToLogsModel, } from '../logs_model'; describe('dedupLogRows()', () => { @@ -337,23 +337,23 @@ const emptyLogsModel = { series: [], }; -describe('seriesDataToLogsModel', () => { +describe('dataFrameToLogsModel', () => { it('given empty series should return empty logs model', () => { - expect(seriesDataToLogsModel([] as SeriesData[], 0)).toMatchObject(emptyLogsModel); + expect(dataFrameToLogsModel([] as DataFrame[], 0)).toMatchObject(emptyLogsModel); }); it('given series without correct series name should return empty logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { fields: [], rows: [], }, ]; - expect(seriesDataToLogsModel(series, 0)).toMatchObject(emptyLogsModel); + expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel); }); it('given series without a time field should return empty logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { fields: [ { @@ -364,11 +364,11 @@ describe('seriesDataToLogsModel', () => { rows: [], }, ]; - expect(seriesDataToLogsModel(series, 0)).toMatchObject(emptyLogsModel); + expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel); }); it('given series without a string field should return empty logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { fields: [ { @@ -379,11 +379,11 @@ describe('seriesDataToLogsModel', () => { rows: [], }, ]; - expect(seriesDataToLogsModel(series, 0)).toMatchObject(emptyLogsModel); + expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel); }); it('given one series should return expected logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { labels: { filename: '/var/log/grafana/grafana.log', @@ -414,7 +414,7 @@ describe('seriesDataToLogsModel', () => { }, }, ]; - const logsModel = seriesDataToLogsModel(series, 0); + const logsModel = dataFrameToLogsModel(series, 0); expect(logsModel.hasUniqueLabels).toBeFalsy(); expect(logsModel.rows).toHaveLength(2); expect(logsModel.rows).toMatchObject([ @@ -449,7 +449,7 @@ describe('seriesDataToLogsModel', () => { }); it('given one series without labels should return expected logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { fields: [ { @@ -468,7 +468,7 @@ describe('seriesDataToLogsModel', () => { rows: [['1970-01-01T00:00:01Z', 'WARN boooo', 'dbug']], }, ]; - const logsModel = seriesDataToLogsModel(series, 0); + const logsModel = dataFrameToLogsModel(series, 0); expect(logsModel.rows).toHaveLength(1); expect(logsModel.rows).toMatchObject([ { @@ -481,7 +481,7 @@ describe('seriesDataToLogsModel', () => { }); it('given multiple series should return expected logs model', () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { labels: { foo: 'bar', @@ -520,7 +520,7 @@ describe('seriesDataToLogsModel', () => { rows: [['1970-01-01T00:00:00Z', 'INFO 1'], ['1970-01-01T00:00:02Z', 'INFO 2']], }, ]; - const logsModel = seriesDataToLogsModel(series, 0); + const logsModel = dataFrameToLogsModel(series, 0); expect(logsModel.hasUniqueLabels).toBeTruthy(); expect(logsModel.rows).toHaveLength(3); expect(logsModel.rows).toMatchObject([ diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 344de38032027..a2e2e1a2eb8ad 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -10,7 +10,7 @@ import { getFirstQueryErrorWithoutRefId, getRefIds, } from './explore'; -import { ExploreUrlState } from 'app/types/explore'; +import { ExploreUrlState, ExploreMode } from 'app/types/explore'; import store from 'app/core/store'; import { DataQueryError, LogsDedupStrategy } from '@grafana/ui'; @@ -18,6 +18,7 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = { datasource: null, queries: [], range: DEFAULT_RANGE, + mode: ExploreMode.Metrics, ui: { showingGraph: true, showingTable: true, @@ -84,6 +85,7 @@ describe('state functions', () => { expect(serializeStateToUrlParam(state)).toBe( '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"},' + + '"mode":"Metrics",' + '"ui":{"showingGraph":true,"showingTable":true,"showingLogs":true,"dedupStrategy":"none"}}' ); }); @@ -106,7 +108,7 @@ describe('state functions', () => { }, }; expect(serializeStateToUrlParam(state, true)).toBe( - '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"ui":[true,true,true,"none"]}]' + '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]' ); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 135490e630528..6159efe96d7d6 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -28,7 +28,14 @@ import { DataQueryRequest, DataStreamObserver, } from '@grafana/ui'; -import { ExploreUrlState, HistoryItem, QueryTransaction, QueryIntervals, QueryOptions } from 'app/types/explore'; +import { + ExploreUrlState, + HistoryItem, + QueryTransaction, + QueryIntervals, + QueryOptions, + ExploreMode, +} from 'app/types/explore'; import { config } from '../config'; export const DEFAULT_RANGE = { @@ -155,10 +162,11 @@ export function buildQueryTransaction( export const clearQueryKeys: (query: DataQuery) => object = ({ key, refId, ...rest }) => rest; -const metricProperties = ['expr', 'target', 'datasource']; +const metricProperties = ['expr', 'target', 'datasource', 'query']; const isMetricSegment = (segment: { [key: string]: string }) => metricProperties.some(prop => segment.hasOwnProperty(prop)); const isUISegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('ui'); +const isModeSegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('mode'); enum ParseUrlStateIndex { RangeFrom = 0, @@ -207,6 +215,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { queries: [], range: DEFAULT_RANGE, ui: DEFAULT_UI_STATE, + mode: null, }; if (!parsed) { @@ -229,6 +238,9 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { const datasource = parsed[ParseUrlStateIndex.Datasource]; const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart); const queries = parsedSegments.filter(segment => isMetricSegment(segment)); + const modeObj = parsedSegments.filter(segment => isModeSegment(segment))[0]; + const mode = modeObj ? modeObj.mode : ExploreMode.Metrics; + const uiState = parsedSegments.filter(segment => isUISegment(segment))[0]; const ui = uiState ? { @@ -239,7 +251,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { } : DEFAULT_UI_STATE; - return { datasource, queries, range, ui }; + return { datasource, queries, range, ui, mode }; } export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { @@ -249,6 +261,7 @@ export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: bo urlState.range.to, urlState.datasource, ...urlState.queries, + { mode: urlState.mode }, { ui: [ !!urlState.ui.showingGraph, diff --git a/public/app/core/utils/timePicker.test.ts b/public/app/core/utils/timePicker.test.ts new file mode 100644 index 0000000000000..b9c54a92d8a83 --- /dev/null +++ b/public/app/core/utils/timePicker.test.ts @@ -0,0 +1,79 @@ +import { toUtc, AbsoluteTimeRange } from '@grafana/ui'; + +import { getShiftedTimeRange, getZoomedTimeRange } from './timePicker'; + +export const setup = (options?: any) => { + const defaultOptions = { + range: { + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + direction: 0, + }; + + return { ...defaultOptions, ...options }; +}; + +describe('getShiftedTimeRange', () => { + describe('when called with a direction of -1', () => { + it('then it should return correct result', () => { + const { range, direction } = setup({ direction: -1 }); + const expectedRange: AbsoluteTimeRange = { + from: toUtc('2019-01-01 07:00:00').valueOf(), + to: toUtc('2019-01-01 13:00:00').valueOf(), + }; + + const result = getShiftedTimeRange(direction, range); + + expect(result).toEqual(expectedRange); + }); + }); + + describe('when called with a direction of 1', () => { + it('then it should return correct result', () => { + const { range, direction } = setup({ direction: 1 }); + const expectedRange: AbsoluteTimeRange = { + from: toUtc('2019-01-01 13:00:00').valueOf(), + to: toUtc('2019-01-01 19:00:00').valueOf(), + }; + + const result = getShiftedTimeRange(direction, range); + + expect(result).toEqual(expectedRange); + }); + }); + + describe('when called with any other direction', () => { + it('then it should return correct result', () => { + const { range, direction } = setup({ direction: 0 }); + const expectedRange: AbsoluteTimeRange = { + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }; + + const result = getShiftedTimeRange(direction, range); + + expect(result).toEqual(expectedRange); + }); + }); +}); + +describe('getZoomedTimeRange', () => { + describe('when called', () => { + it('then it should return correct result', () => { + const { range } = setup(); + const expectedRange: AbsoluteTimeRange = { + from: toUtc('2019-01-01 07:00:00').valueOf(), + to: toUtc('2019-01-01 19:00:00').valueOf(), + }; + + const result = getZoomedTimeRange(range, 2); + + expect(result).toEqual(expectedRange); + }); + }); +}); diff --git a/public/app/core/utils/timePicker.ts b/public/app/core/utils/timePicker.ts new file mode 100644 index 0000000000000..974588857a5dc --- /dev/null +++ b/public/app/core/utils/timePicker.ts @@ -0,0 +1,38 @@ +import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/ui'; + +export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => { + const range = { + from: toUtc(origRange.from), + to: toUtc(origRange.to), + }; + + const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; + let to: number, from: number; + + if (direction === -1) { + to = range.to.valueOf() - timespan; + from = range.from.valueOf() - timespan; + } else if (direction === 1) { + to = range.to.valueOf() + timespan; + from = range.from.valueOf() + timespan; + if (to > Date.now() && range.to.valueOf() < Date.now()) { + to = Date.now(); + from = range.from.valueOf(); + } + } else { + to = range.to.valueOf(); + from = range.from.valueOf(); + } + + return { from, to }; +}; + +export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTimeRange => { + const timespan = range.to.valueOf() - range.from.valueOf(); + const center = range.to.valueOf() - timespan / 2; + + const to = center + (timespan * factor) / 2; + const from = center - (timespan * factor) / 2; + + return { from, to }; +}; diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index a5ed686e02c0d..72052962bbf57 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,5 +1,5 @@ // Libaries -import angular from 'angular'; +import angular, { IQService } from 'angular'; import _ from 'lodash'; // Components @@ -11,6 +11,9 @@ import { makeRegions, dedupAnnotations } from './events_processing'; // Types import { DashboardModel } from '../dashboard/state/DashboardModel'; +import DatasourceSrv from '../plugins/datasource_srv'; +import { BackendSrv } from 'app/core/services/backend_srv'; +import { TimeSrv } from '../dashboard/services/TimeSrv'; export class AnnotationsSrv { globalAnnotationsPromise: any; @@ -18,7 +21,13 @@ export class AnnotationsSrv { datasourcePromises: any; /** @ngInject */ - constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {} + constructor( + private $rootScope: any, + private $q: IQService, + private datasourceSrv: DatasourceSrv, + private backendSrv: BackendSrv, + private timeSrv: TimeSrv + ) {} init(dashboard: DashboardModel) { // always clearPromiseCaches when loading new dashboard @@ -33,7 +42,7 @@ export class AnnotationsSrv { this.datasourcePromises = null; } - getAnnotations(options) { + getAnnotations(options: any) { return this.$q .all([this.getGlobalAnnotations(options), this.getAlertStates(options)]) .then(results => { @@ -70,7 +79,7 @@ export class AnnotationsSrv { }); } - getAlertStates(options) { + getAlertStates(options: any) { if (!options.dashboard.id) { return this.$q.when([]); } @@ -94,7 +103,7 @@ export class AnnotationsSrv { return this.alertStatesPromise; } - getGlobalAnnotations(options) { + getGlobalAnnotations(options: any) { const dashboard = options.dashboard; if (this.globalAnnotationsPromise) { @@ -117,7 +126,7 @@ export class AnnotationsSrv { dsPromises.push(datasourcePromise); promises.push( datasourcePromise - .then(datasource => { + .then((datasource: any) => { // issue query against data source return datasource.annotationQuery({ range: range, @@ -141,17 +150,17 @@ export class AnnotationsSrv { return this.globalAnnotationsPromise; } - saveAnnotationEvent(annotation) { + saveAnnotationEvent(annotation: any) { this.globalAnnotationsPromise = null; return this.backendSrv.post('/api/annotations', annotation); } - updateAnnotationEvent(annotation) { + updateAnnotationEvent(annotation: { id: any }) { this.globalAnnotationsPromise = null; return this.backendSrv.put(`/api/annotations/${annotation.id}`, annotation); } - deleteAnnotationEvent(annotation) { + deleteAnnotationEvent(annotation: { id: any; isRegion: any; regionId: any }) { this.globalAnnotationsPromise = null; let deleteUrl = `/api/annotations/${annotation.id}`; if (annotation.isRegion) { @@ -161,7 +170,7 @@ export class AnnotationsSrv { return this.backendSrv.delete(deleteUrl); } - translateQueryResult(annotation, results) { + translateQueryResult(annotation: any, results: any) { // if annotation has snapshotData // make clone and remove it if (annotation.snapshotData) { diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 817d932980caf..cb932334ec8ce 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -12,9 +12,34 @@ 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 { DeleteButton, Input } from '@grafana/ui'; +import { DeleteButton, EventsWithValidation, FormLabel, Input, ValidationEvents } from '@grafana/ui'; import { NavModel } from '@grafana/data'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { store } from 'app/store/store'; +import kbn from 'app/core/utils/kbn'; + +// Utils +import { dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper'; +import { getTimeZone } from 'app/features/profile/state/selectors'; + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: value => { + if (!value) { + return true; + } + try { + kbn.interval_to_seconds(value); + return true; + } catch { + return false; + } + }, + errorMessage: 'Not a valid duration', + }, + ], +}; export interface Props { navModel: NavModel; @@ -36,13 +61,18 @@ export interface State { enum ApiKeyStateProps { Name = 'name', Role = 'role', + SecondsToLive = 'secondsToLive', } const initialApiKeyState = { name: '', role: OrgRole.Viewer, + secondsToLive: '', }; +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 class ApiKeysPage extends PureComponent { constructor(props) { super(props); @@ -81,6 +111,9 @@ export class ApiKeysPage extends PureComponent { }); }; + // make sure that secondsToLive is number or null + const secondsToLive = this.state.newApiKey['secondsToLive']; + this.state.newApiKey['secondsToLive'] = secondsToLive ? kbn.interval_to_seconds(secondsToLive) : null; this.props.addApiKey(this.state.newApiKey, openModal); this.setState((prevState: State) => { return { @@ -130,6 +163,17 @@ export class ApiKeysPage extends PureComponent { ); } + formatDate(date, format?) { + if (!date) { + return 'No expiration date'; + } + date = isDateTime(date) ? date : dateTime(date); + format = format || 'YYYY-MM-DD HH:mm:ss'; + const timezone = getTimeZone(store.getState().user); + + return timezone === 'utc' ? date.utc().format(format) : date.format(format); + } + renderAddApiKeyForm() { const { newApiKey, isAdding } = this.state; @@ -170,6 +214,17 @@ export class ApiKeysPage extends PureComponent { +
+ Time to live + this.onApiKeyStateUpdate(evt, ApiKeyStateProps.SecondsToLive)} + /> +
@@ -211,6 +266,7 @@ export class ApiKeysPage extends PureComponent { Name Role + Expires @@ -221,6 +277,7 @@ export class ApiKeysPage extends PureComponent { {key.name} {key.role} + {this.formatDate(key.expiration)} this.onDeleteApiKey(key)} /> diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts index 117f0d6d0c647..099b4c92ec496 100644 --- a/public/app/features/api-keys/__mocks__/apiKeysMock.ts +++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts @@ -7,6 +7,8 @@ export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => { id: i, name: `test-${i}`, role: OrgRole.Viewer, + secondsToLive: null, + expiration: '2019-06-04', }); } @@ -18,5 +20,7 @@ export const getMockKey = (): ApiKey => { id: 1, name: 'test', role: OrgRole.Admin, + secondsToLive: null, + expiration: '2019-06-04', }; }; diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap index 1ece45d86ef21..634dd9738d157 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -130,6 +130,32 @@ exports[`Render should render CTA if there are no API keys 1`] = ` +
+ + Time to live + + +
diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 065596d73df0a..6e24e6db24600 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -16,6 +16,7 @@ import { TimePicker, RefreshPicker, RawTimeRange } from '@grafana/ui'; // Utils & Services import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker'; +import { getShiftedTimeRange } from 'app/core/utils/timePicker'; export interface Props { $injector: any; @@ -44,23 +45,7 @@ export class DashNavTimeControls extends Component { onMoveTimePicker = (direction: number) => { const range = this.timeSrv.timeRange(); - const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; - let to: number, from: number; - - if (direction === -1) { - to = range.to.valueOf() - timespan; - from = range.from.valueOf() - timespan; - } else if (direction === 1) { - to = range.to.valueOf() + timespan; - from = range.from.valueOf() + timespan; - if (to > Date.now() && range.to.valueOf() < Date.now()) { - to = Date.now(); - from = range.from.valueOf(); - } - } else { - to = range.to.valueOf(); - from = range.from.valueOf(); - } + const { from, to } = getShiftedTimeRange(direction, range); this.timeSrv.setTime({ from: toUtc(from), @@ -110,7 +95,7 @@ export class DashNavTimeControls extends Component { const timeZone = dashboard.getTimezone(); return ( - <> +
{ intervals={intervals} tooltip="Refresh dashboard" /> - +
); } } diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts new file mode 100644 index 0000000000000..f106dfd88f28e --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts @@ -0,0 +1,71 @@ +import coreModule from 'app/core/core_module'; +import { DashboardModel } from 'app/features/dashboard/state'; + +export class TimePickerCtrl { + panel: any; + dashboard: DashboardModel; + + constructor() { + this.panel = this.dashboard.timepicker; + this.panel.refresh_intervals = this.panel.refresh_intervals || [ + '5s', + '10s', + '30s', + '1m', + '5m', + '15m', + '30m', + '1h', + '2h', + '1d', + ]; + } +} + +const template = ` +
+
Time Options
+ +
+
+ +
+ +
+
+ +
+ Auto-refresh + +
+
+ Now delay now- + +
+ + + +
+
+`; + +export function TimePickerSettings() { + return { + restrict: 'E', + template: template, + controller: TimePickerCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + dashboard: '=', + }, + }; +} + +coreModule.directive('gfTimePickerSettings', TimePickerSettings); diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts index 0a89feada33ab..504e62e150414 100644 --- a/public/app/features/dashboard/components/DashboardSettings/index.ts +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -1,2 +1,3 @@ export { SettingsCtrl } from './SettingsCtrl'; export { DashboardSettings } from './DashboardSettings'; +export { TimePickerSettings } from './TimePickerSettings'; diff --git a/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts deleted file mode 100644 index f1d626e9e3479..0000000000000 --- a/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts +++ /dev/null @@ -1,189 +0,0 @@ -import _ from 'lodash'; -import angular from 'angular'; - -import * as rangeUtil from '@grafana/ui/src/utils/rangeutil'; - -export class TimePickerCtrl { - static tooltipFormat = 'MMM D, YYYY HH:mm:ss'; - static defaults = { - time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], - refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], - }; - - dashboard: any; - panel: any; - absolute: any; - timeRaw: any; - editTimeRaw: any; - tooltip: string; - rangeString: string; - timeOptions: any; - refresh: any; - isUtc: boolean; - firstDayOfWeek: number; - isOpen: boolean; - isAbsolute: boolean; - - /** @ngInject */ - constructor(private $scope, private $rootScope, private timeSrv) { - this.$scope.ctrl = this; - - $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope); - $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope); - $rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope); - - this.dashboard.on('refresh', this.onRefresh.bind(this), $scope); - - // init options - this.panel = this.dashboard.timepicker; - _.defaults(this.panel, TimePickerCtrl.defaults); - this.firstDayOfWeek = getLocaleData().firstDayOfWeek(); - - // init time stuff - this.onRefresh(); - } - - onRefresh() { - const time = angular.copy(this.timeSrv.timeRange()); - const timeRaw = angular.copy(time.raw); - - if (!this.dashboard.isTimezoneUtc()) { - time.from.local(); - time.to.local(); - if (isDateTime(timeRaw.from)) { - timeRaw.from.local(); - } - if (isDateTime(timeRaw.to)) { - timeRaw.to.local(); - } - this.isUtc = false; - } else { - this.isUtc = true; - } - - this.rangeString = rangeUtil.describeTimeRange(timeRaw); - this.absolute = { fromJs: time.from.toDate(), toJs: time.to.toDate() }; - this.tooltip = this.dashboard.formatDate(time.from) + '
to
'; - this.tooltip += this.dashboard.formatDate(time.to); - this.timeRaw = timeRaw; - this.isAbsolute = isDateTime(this.timeRaw.to); - } - - zoom(factor) { - this.$rootScope.appEvent('zoom-out', 2); - } - - move(direction) { - const range = this.timeSrv.timeRange(); - - const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; - let to, from; - if (direction === -1) { - to = range.to.valueOf() - timespan; - from = range.from.valueOf() - timespan; - } else if (direction === 1) { - to = range.to.valueOf() + timespan; - from = range.from.valueOf() + timespan; - if (to > Date.now() && range.to < Date.now()) { - to = Date.now(); - from = range.from.valueOf(); - } - } else { - to = range.to.valueOf(); - from = range.from.valueOf(); - } - - this.timeSrv.setTime({ from: toUtc(from), to: toUtc(to) }); - } - - openDropdown() { - if (this.isOpen) { - this.closeDropdown(); - return; - } - - this.onRefresh(); - this.editTimeRaw = this.timeRaw; - this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString); - this.refresh = { - value: this.dashboard.refresh, - options: this.panel.refresh_intervals.map((interval: any) => { - return { text: interval, value: interval }; - }), - }; - - this.refresh.options.unshift({ text: 'off' }); - this.isOpen = true; - this.$rootScope.appEvent('timepickerOpen'); - } - - closeDropdown() { - this.isOpen = false; - this.$rootScope.appEvent('timepickerClosed'); - } - - applyCustom() { - if (this.refresh.value !== this.dashboard.refresh) { - this.timeSrv.setAutoRefresh(this.refresh.value); - } - - this.timeSrv.setTime(this.editTimeRaw); - this.closeDropdown(); - } - - absoluteFromChanged() { - this.editTimeRaw.from = this.getAbsoluteMomentForTimezone(this.absolute.fromJs); - } - - absoluteToChanged() { - this.editTimeRaw.to = this.getAbsoluteMomentForTimezone(this.absolute.toJs); - } - - getAbsoluteMomentForTimezone(jsDate) { - return this.dashboard.isTimezoneUtc() ? dateTime(jsDate).utc() : dateTime(jsDate); - } - - setRelativeFilter(timespan) { - const range = { from: timespan.from, to: timespan.to }; - - if (this.panel.nowDelay && range.to === 'now') { - range.to = 'now-' + this.panel.nowDelay; - } - - this.timeSrv.setTime(range); - this.closeDropdown(); - } -} - -export function settingsDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html', - controller: TimePickerCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { - dashboard: '=', - }, - }; -} - -export function timePickerDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html', - controller: TimePickerCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { - dashboard: '=', - }, - }; -} - -angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective); -angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective); - -import { inputDateDirective } from './validation'; -import { toUtc, getLocaleData, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper'; -angular.module('grafana.directives').directive('inputDatetime', inputDateDirective); diff --git a/public/app/features/dashboard/components/TimePicker/index.ts b/public/app/features/dashboard/components/TimePicker/index.ts deleted file mode 100644 index ca6e2792c43ae..0000000000000 --- a/public/app/features/dashboard/components/TimePicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TimePickerCtrl } from './TimePickerCtrl'; diff --git a/public/app/features/dashboard/components/TimePicker/settings.html b/public/app/features/dashboard/components/TimePicker/settings.html deleted file mode 100644 index fd5170013c299..0000000000000 --- a/public/app/features/dashboard/components/TimePicker/settings.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
Time Options
- -
-
- -
- -
-
- -
- Auto-refresh - -
-
- Now delay now- - -
- - -
-
diff --git a/public/app/features/dashboard/components/TimePicker/template.html b/public/app/features/dashboard/components/TimePicker/template.html deleted file mode 100644 index 2821dd0ced537..0000000000000 --- a/public/app/features/dashboard/components/TimePicker/template.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - -
-
-
- Quick ranges -
-
-
    -
  • - -
  • -
-
-
- -
-
- Custom range -
-
- -
-
- -
-
- -
-
- -
- -
- - - -
-
- -
-
- -
-
- -
- -
- -
-
- -
-
-
-
-
- diff --git a/public/app/features/dashboard/components/TimePicker/validation.ts b/public/app/features/dashboard/components/TimePicker/validation.ts deleted file mode 100644 index a99409a0fb0c6..0000000000000 --- a/public/app/features/dashboard/components/TimePicker/validation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as dateMath from '@grafana/ui/src/utils/datemath'; -import { toUtc, dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper'; - -export function inputDateDirective() { - return { - restrict: 'A', - require: 'ngModel', - link: ($scope, $elem, attrs, ngModel) => { - const format = 'YYYY-MM-DD HH:mm:ss'; - - const fromUser = text => { - if (text.indexOf('now') !== -1) { - if (!dateMath.isValid(text)) { - ngModel.$setValidity('error', false); - return undefined; - } - ngModel.$setValidity('error', true); - return text; - } - - let parsed; - if ($scope.ctrl.isUtc) { - parsed = toUtc(text, format); - } else { - parsed = dateTime(text, format); - } - - if (!parsed.isValid()) { - ngModel.$setValidity('error', false); - return undefined; - } - - ngModel.$setValidity('error', true); - return parsed; - }; - - const toUser = currentValue => { - if (isDateTime(currentValue)) { - return currentValue.format(format); - } else { - return currentValue; - } - }; - - ngModel.$parsers.push(fromUser); - ngModel.$formatters.push(toUser); - }, - }; -} diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 6f57f6f42e391..639e34a16ae27 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -163,6 +163,7 @@ export class DashboardGrid extends PureComponent { for (const panel of this.props.dashboard.panels) { panel.resizeDone(); } + this.forceUpdate(); }; onViewModeChanged = () => { diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 485c409220c6a..e13cf5c6a6158 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -11,7 +11,7 @@ import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { applyPanelTimeOverrides, calculateInnerPanelHeight } from 'app/features/dashboard/utils/panel'; import { profiler } from 'app/core/profiler'; -import { getProcessedSeriesData } from '../state/PanelQueryState'; +import { getProcessedDataFrame } from '../state/PanelQueryState'; import templateSrv from 'app/features/templating/template_srv'; import config from 'app/core/config'; @@ -71,7 +71,7 @@ export class PanelChrome extends PureComponent { this.setState({ data: { state: LoadingState.Done, - series: getProcessedSeriesData(panel.snapshotData), + series: getProcessedDataFrame(panel.snapshotData), }, isFirstLoad: false, }); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index d3bd38b93d1a4..8aaba8bdd4a9d 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; -import Remarkable from 'remarkable'; + +import { renderMarkdown } from '@grafana/data'; import { Tooltip, ScopedVars, DataLink } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -45,11 +46,12 @@ export class PanelHeaderCorner extends Component { const markdown = panel.description; const linkSrv = new LinkSrv(templateSrv, this.timeSrv); const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars); - const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown); + const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); return ( -
-

+

+
+ {panel.links && panel.links.length > 0 && (
    {panel.links.map((link, idx) => { diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index 1a326d73bd9e9..e2c042ef13826 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -12,7 +12,6 @@ import './components/FolderPicker'; import './components/VersionHistory'; import './components/DashboardSettings'; import './components/SubMenu'; -import './components/TimePicker'; import './components/UnsavedChangesModal'; import './components/SaveModals'; import './components/ShareModal'; diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index 1716563874e25..5c1a305a2ba1c 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -12,6 +12,7 @@ import { ITimeoutService, ILocationService } from 'angular'; import { ContextSrv } from 'app/core/services/context_srv'; import { DashboardModel } from '../state/DashboardModel'; import { toUtc, dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper'; +import { getZoomedTimeRange } from 'app/core/utils/timePicker'; export class TimeSrv { time: any; @@ -238,12 +239,7 @@ export class TimeSrv { zoomOut(e: any, factor: number) { const range = this.timeRange(); - - const timespan = range.to.valueOf() - range.from.valueOf(); - const center = range.to.valueOf() - timespan / 2; - - const to = center + (timespan * factor) / 2; - const from = center - (timespan * factor) / 2; + const { from, to } = getZoomedTimeRange(range, factor); this.setTime({ from: toUtc(from), to: toUtc(to) }); } diff --git a/public/app/features/dashboard/state/PanelQueryState.test.ts b/public/app/features/dashboard/state/PanelQueryState.test.ts index 8d9a028ae3d9a..2155803b49dbc 100644 --- a/public/app/features/dashboard/state/PanelQueryState.test.ts +++ b/public/app/features/dashboard/state/PanelQueryState.test.ts @@ -1,4 +1,4 @@ -import { toDataQueryError, PanelQueryState, getProcessedSeriesData } from './PanelQueryState'; +import { toDataQueryError, PanelQueryState, getProcessedDataFrame } from './PanelQueryState'; import { MockDataSourceApi } from 'test/mocks/datasource_srv'; import { DataQueryResponse, LoadingState } from '@grafana/ui'; import { getQueryOptions } from 'test/helpers/getQueryOptions'; @@ -53,7 +53,7 @@ describe('PanelQueryState', () => { }); }); -describe('getProcessedSeriesData', () => { +describe('getProcessedDataFrame', () => { it('converts timeseries to table skipping nulls', () => { const input1 = { target: 'Field Name', @@ -64,7 +64,7 @@ describe('getProcessedSeriesData', () => { target: '', datapoints: [[100, 1], [200, 2]], }; - const data = getProcessedSeriesData([null, input1, input2, null, null]); + const data = getProcessedDataFrame([null, input1, input2, null, null]); expect(data.length).toBe(2); expect(data[0].fields[0].name).toBe(input1.target); expect(data[0].rows).toBe(input1.datapoints); @@ -82,10 +82,10 @@ describe('getProcessedSeriesData', () => { }); it('supports null values from query OK', () => { - expect(getProcessedSeriesData([null, null, null, null])).toEqual([]); - expect(getProcessedSeriesData(undefined)).toEqual([]); - expect(getProcessedSeriesData((null as unknown) as any[])).toEqual([]); - expect(getProcessedSeriesData([])).toEqual([]); + expect(getProcessedDataFrame([null, null, null, null])).toEqual([]); + expect(getProcessedDataFrame(undefined)).toEqual([]); + expect(getProcessedDataFrame((null as unknown) as any[])).toEqual([]); + expect(getProcessedDataFrame([])).toEqual([]); }); }); diff --git a/public/app/features/dashboard/state/PanelQueryState.ts b/public/app/features/dashboard/state/PanelQueryState.ts index 889390f50b195..75a8740059533 100644 --- a/public/app/features/dashboard/state/PanelQueryState.ts +++ b/public/app/features/dashboard/state/PanelQueryState.ts @@ -5,7 +5,7 @@ import isEqual from 'lodash/isEqual'; // Utils & Services import { getBackendSrv } from 'app/core/services/backend_srv'; import * as dateMath from '@grafana/ui/src/utils/datemath'; -import { guessFieldTypes, toSeriesData, isSeriesData } from '@grafana/ui/src/utils'; +import { guessFieldTypes, toDataFrame, isDataFrame } from '@grafana/ui/src/utils'; // Types import { @@ -17,7 +17,7 @@ import { DataQueryError, DataStreamObserver, DataStreamState, - SeriesData, + DataFrame, DataQueryResponseData, } from '@grafana/ui'; @@ -131,7 +131,7 @@ export class PanelQueryState { this.response = { state: LoadingState.Done, request: this.request, - series: this.sendSeries ? getProcessedSeriesData(resp.data) : [], + series: this.sendSeries ? getProcessedDataFrame(resp.data) : [], legacy: this.sendLegacy ? translateToLegacyData(resp.data) : undefined, }; resolve(this.validateStreamsAndGetPanelData()); @@ -182,7 +182,7 @@ export class PanelQueryState { return; } - const series: SeriesData[] = []; + const series: DataFrame[] = []; for (const stream of this.streams) { if (stream.series) { @@ -278,7 +278,7 @@ export class PanelQueryState { response.legacy = response.series.map(v => toLegacyResponseData(v)); } if (sendSeries && !response.series.length && response.legacy) { - response.series = response.legacy.map(v => toSeriesData(v)); + response.series = response.legacy.map(v => toDataFrame(v)); } return this.validateStreamsAndGetPanelData(); } @@ -333,7 +333,7 @@ export function toDataQueryError(err: any): DataQueryError { function translateToLegacyData(data: DataQueryResponseData) { return data.map(v => { - if (isSeriesData(v)) { + if (isDataFrame(v)) { return toLegacyResponseData(v); } return v; @@ -345,15 +345,15 @@ function translateToLegacyData(data: DataQueryResponseData) { * * This is also used by PanelChrome for snapshot support */ -export function getProcessedSeriesData(results?: any[]): SeriesData[] { +export function getProcessedDataFrame(results?: any[]): DataFrame[] { if (!results) { return []; } - const series: SeriesData[] = []; + const series: DataFrame[] = []; for (const r of results) { if (r) { - series.push(guessFieldTypes(toSeriesData(r))); + series.push(guessFieldTypes(toDataFrame(r))); } } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index a8a7bd9ab04dd..ffd04e6a23559 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -16,7 +16,6 @@ import GraphContainer from './GraphContainer'; import LogsContainer from './LogsContainer'; import QueryRows from './QueryRows'; import TableContainer from './TableContainer'; -import TimePicker from './TimePicker'; // Actions import { @@ -35,7 +34,6 @@ import { RawTimeRange, DataQuery, ExploreStartPageProps, DataSourceApi, DataQuer import { ExploreItemState, ExploreUrlState, - RangeScanner, ExploreId, ExploreUpdateState, ExploreUIState, @@ -71,7 +69,6 @@ interface ExploreProps { update: ExploreUpdateState; reconnectDatasource: typeof reconnectDatasource; refreshExplore: typeof refreshExplore; - scanner?: RangeScanner; scanning?: boolean; scanRange?: RawTimeRange; scanStart: typeof scanStart; @@ -83,9 +80,9 @@ interface ExploreProps { initialDatasource: string; initialQueries: DataQuery[]; initialRange: RawTimeRange; + mode: ExploreMode; initialUI: ExploreUIState; queryErrors: DataQueryError[]; - mode: ExploreMode; isLive: boolean; updateTimeRange: typeof updateTimeRange; } @@ -117,19 +114,14 @@ interface ExploreProps { export class Explore extends React.PureComponent { el: any; exploreEvents: Emitter; - /** - * Timepicker to control scanning - */ - timepickerRef: React.RefObject; constructor(props: ExploreProps) { super(props); this.exploreEvents = new Emitter(); - this.timepickerRef = React.createRef(); } componentDidMount() { - const { initialized, exploreId, initialDatasource, initialQueries, initialRange, initialUI } = this.props; + const { initialized, exploreId, initialDatasource, initialQueries, initialRange, mode, initialUI } = this.props; const width = this.el ? this.el.offsetWidth : 0; // initialize the whole explore first time we mount and if browser history contains a change in datasource @@ -139,6 +131,7 @@ export class Explore extends React.PureComponent { initialDatasource, initialQueries, initialRange, + mode, width, this.exploreEvents, initialUI @@ -158,11 +151,9 @@ export class Explore extends React.PureComponent { this.el = el; }; - onChangeTime = (rawRange: RawTimeRange, changedByScanner?: boolean) => { - const { updateTimeRange, exploreId, scanning } = this.props; - if (scanning && !changedByScanner) { - this.onStopScanning(); - } + onChangeTime = (rawRange: RawTimeRange) => { + const { updateTimeRange, exploreId } = this.props; + updateTimeRange({ exploreId, rawRange }); }; @@ -189,13 +180,7 @@ export class Explore extends React.PureComponent { onStartScanning = () => { // Scanner will trigger a query - const scanner = this.scanPreviousRange; - this.props.scanStart(this.props.exploreId, scanner); - }; - - scanPreviousRange = (): RawTimeRange => { - // Calling move() on the timepicker will trigger this.onChangeTime() - return this.timepickerRef.current.move(-1, true); + this.props.scanStart(this.props.exploreId); }; onStopScanning = () => { @@ -243,7 +228,7 @@ export class Explore extends React.PureComponent { return (
    - + {datasourceLoading ?
    Loading datasource...
    : null} {datasourceMissing ? this.renderEmptyState() : null} @@ -316,14 +301,32 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { urlState, update, queryErrors, - mode, isLive, + supportedModes, + mode, } = item; - const { datasource, queries, range: urlRange, ui } = (urlState || {}) as ExploreUrlState; + const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState; const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY); const initialQueries: DataQuery[] = ensureQueries(queries); const initialRange = urlRange ? getTimeRangeFromUrl(urlRange, timeZone).raw : DEFAULT_RANGE; + + let newMode: ExploreMode; + if (supportedModes.length) { + const urlModeIsValid = supportedModes.includes(urlMode); + const modeStateIsValid = supportedModes.includes(mode); + + if (urlModeIsValid) { + newMode = urlMode; + } else if (modeStateIsValid) { + newMode = mode; + } else { + newMode = supportedModes[0]; + } + } else { + newMode = [ExploreMode.Metrics, ExploreMode.Logs].includes(urlMode) ? urlMode : ExploreMode.Metrics; + } + const initialUI = ui || DEFAULT_UI_STATE; return { @@ -340,9 +343,9 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { initialDatasource, initialQueries, initialRange, + mode: newMode, initialUI, queryErrors, - mode, isLive, }; } diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx new file mode 100644 index 0000000000000..7ae5bf10a7b1a --- /dev/null +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -0,0 +1,112 @@ +// Libaries +import React, { Component } from 'react'; + +// Types +import { ExploreId } from 'app/types'; +import { TimeRange, TimeOption, TimeZone, SetInterval, toUtc, dateTime } from '@grafana/ui'; + +// State + +// Components +import { TimePicker, RefreshPicker, RawTimeRange } from '@grafana/ui'; + +// Utils & Services +import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker'; +import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; + +export interface Props { + exploreId: ExploreId; + hasLiveOption: boolean; + isLive: boolean; + loading: boolean; + range: TimeRange; + refreshInterval: string; + timeZone: TimeZone; + onRunQuery: () => void; + onChangeRefreshInterval: (interval: string) => void; + onChangeTime: (range: RawTimeRange) => void; +} + +export class ExploreTimeControls extends Component { + onMoveTimePicker = (direction: number) => { + const { range, onChangeTime, timeZone } = this.props; + const { from, to } = getShiftedTimeRange(direction, range); + const nextTimeRange = { + from: timeZone === 'utc' ? toUtc(from) : dateTime(from), + to: timeZone === 'utc' ? toUtc(to) : dateTime(to), + }; + + onChangeTime(nextTimeRange); + }; + + onMoveForward = () => this.onMoveTimePicker(1); + onMoveBack = () => this.onMoveTimePicker(-1); + + onChangeTimePicker = (timeRange: TimeRange) => { + this.props.onChangeTime(timeRange.raw); + }; + + onZoom = () => { + const { range, onChangeTime, timeZone } = this.props; + const { from, to } = getZoomedTimeRange(range, 2); + const nextTimeRange = { + from: timeZone === 'utc' ? toUtc(from) : dateTime(from), + to: timeZone === 'utc' ? toUtc(to) : dateTime(to), + }; + + onChangeTime(nextTimeRange); + }; + + setActiveTimeOption = (timeOptions: TimeOption[], rawTimeRange: RawTimeRange): TimeOption[] => { + return timeOptions.map(option => { + if (option.to === rawTimeRange.to && option.from === rawTimeRange.from) { + return { + ...option, + active: true, + }; + } + return { + ...option, + active: false, + }; + }); + }; + + render() { + const { + hasLiveOption, + isLive, + loading, + range, + refreshInterval, + timeZone, + onRunQuery, + onChangeRefreshInterval, + } = this.props; + + return ( + <> + {!isLive && ( + + )} + + + {refreshInterval && } + + ); + } +} diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 75f8cc75b7c04..1b81d42181e6e 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -3,15 +3,7 @@ import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { ExploreId, ExploreMode } from 'app/types/explore'; -import { - DataSourceSelectItem, - RawTimeRange, - ClickOutsideWrapper, - TimeZone, - TimeRange, - SelectOptionItem, - LoadingState, -} from '@grafana/ui'; +import { DataSourceSelectItem, RawTimeRange, TimeZone, TimeRange, SelectOptionItem, LoadingState } from '@grafana/ui'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; import { @@ -23,10 +15,9 @@ import { changeRefreshInterval, changeMode, } from './state/actions'; -import TimePicker from './TimePicker'; import { getTimeZone } from '../profile/state/selectors'; -import { RefreshPicker, SetInterval } from '@grafana/ui'; import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup'; +import { ExploreTimeControls } from './ExploreTimeControls'; enum IconSide { left = 'left', @@ -63,7 +54,6 @@ const createResponsiveButton = (options: { interface OwnProps { exploreId: ExploreId; - timepickerRef: React.RefObject; onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void; } @@ -111,10 +101,6 @@ export class UnConnectedExploreToolbar extends PureComponent { return this.props.runQueries(this.props.exploreId); }; - onCloseTimePicker = () => { - this.props.timepickerRef.current.setState({ isOpen: false }); - }; - onChangeRefreshInterval = (item: string) => { const { changeRefreshInterval, exploreId } = this.props; changeRefreshInterval(exploreId, item); @@ -136,7 +122,6 @@ export class UnConnectedExploreToolbar extends PureComponent { timeZone, selectedDatasource, splitted, - timepickerRef, refreshInterval, onChangeTime, split, @@ -213,21 +198,19 @@ export class UnConnectedExploreToolbar extends PureComponent { })}
    ) : null} -
    - {!isLive && ( - - - - )} - - + - {refreshInterval && }
    diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 1a65bd2397cf6..e57d533ee688f 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -79,7 +79,7 @@ interface GraphProps { split?: boolean; userOptions?: any; onChangeTime?: (range: AbsoluteTimeRange) => void; - onToggleSeries?: (alias: string, hiddenSeries: Set) => void; + onToggleSeries?: (alias: string, hiddenSeries: string[]) => void; } interface GraphState { @@ -213,13 +213,13 @@ export class Graph extends PureComponent { // This implementation is more or less a copy of GraphPanel's logic. // TODO: we need to use Graph's panel controller or split it into smaller // controllers to remove code duplication. Right now we cant easily use that, since Explore - // is not using SeriesData for graph yet + // is not using DataFrame for graph yet const exclusive = event.ctrlKey || event.metaKey || event.shiftKey; this.setState((state, props) => { - const { data } = props; - let nextHiddenSeries = []; + const { data, onToggleSeries } = props; + let nextHiddenSeries: string[] = []; if (exclusive) { // Toggling series with key makes the series itself to toggle if (state.hiddenSeries.indexOf(label) > -1) { @@ -238,6 +238,10 @@ export class Graph extends PureComponent { } } + if (onToggleSeries) { + onToggleSeries(label, nextHiddenSeries); + } + return { hiddenSeries: nextHiddenSeries, }; diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index 073eff5ec922a..c06fc0cf99bae 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/public/app/features/explore/LiveLogs.tsx @@ -8,6 +8,7 @@ import { LinkButton, LogsModel, LogRowModel, + TimeZone, } from '@grafana/ui'; import ElapsedTime from './ElapsedTime'; @@ -42,6 +43,7 @@ const getStyles = (theme: GrafanaTheme) => ({ export interface Props extends Themeable { logsResult?: LogsModel; + timeZone: TimeZone; stopLive: () => void; } @@ -73,10 +75,11 @@ class LiveLogs extends PureComponent { } render() { - const { theme } = this.props; + const { theme, timeZone } = this.props; const { renderCount } = this.state; const styles = getStyles(theme); const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : []; + const showUtc = timeZone === 'utc'; return ( <> @@ -87,9 +90,16 @@ class LiveLogs extends PureComponent { className={row.fresh ? cx(['logs-row', styles.logsRowFresh]) : cx(['logs-row', styles.logsRowOld])} key={`${row.timeEpochMs}-${index}`} > -
    - {row.timeLocal} -
    + {showUtc && ( +
    + {row.timeUtc} +
    + )} + {!showUtc && ( +
    + {row.timeLocal} +
    + )}
    {row.entry}
    ); diff --git a/public/app/features/explore/LogRow.tsx b/public/app/features/explore/LogRow.tsx index e626782f1deb9..10657eb9bdaad 100644 --- a/public/app/features/explore/LogRow.tsx +++ b/public/app/features/explore/LogRow.tsx @@ -1,5 +1,6 @@ import React, { PureComponent } from 'react'; import _ from 'lodash'; +// @ts-ignore import Highlighter from 'react-highlight-words'; import classnames from 'classnames'; @@ -23,6 +24,7 @@ import { LogRowModel, LogLabelStatsModel, LogsParser, + TimeZone, } from '@grafana/ui'; import { LogRowContext } from './LogRowContext'; import tinycolor from 'tinycolor2'; @@ -32,8 +34,8 @@ interface Props { row: LogRowModel; showDuplicates: boolean; showLabels: boolean; - showLocalTime: boolean; - showUtc: boolean; + showTime: boolean; + timeZone: TimeZone; getRows: () => LogRowModel[]; onClickLabel?: (label: string, value: string) => void; onContextClick?: () => void; @@ -57,7 +59,7 @@ interface State { * Renders a highlighted field. * When hovering, a stats icon is shown. */ -const FieldHighlight = onClick => props => { +const FieldHighlight = (onClick: any) => (props: any) => { return ( {props.children} @@ -85,7 +87,7 @@ const getLogRowWithContextStyles = (theme: GrafanaTheme, state: State) => { row: css` z-index: 1; outline: 9999px solid - ${tinycolor(outlineColor) + ${tinycolor(outlineColor as tinycolor.ColorInput) .setAlpha(0.7) .toRgbString()}; `, @@ -102,7 +104,7 @@ const getLogRowWithContextStyles = (theme: GrafanaTheme, state: State) => { export class LogRow extends PureComponent { mouseMessageTimer: NodeJS.Timer; - state = { + state: any = { fieldCount: 0, fieldLabel: null, fieldStats: null, @@ -209,8 +211,8 @@ export class LogRow extends PureComponent { row, showDuplicates, showLabels, - showLocalTime, - showUtc, + timeZone, + showTime, } = this.props; const { fieldCount, @@ -229,6 +231,7 @@ export class LogRow extends PureComponent { const highlightClassName = classnames('logs-row__match-highlight', { 'logs-row__match-highlight--preview': previewHighlights, }); + const showUtc = timeZone === 'utc'; return ( @@ -242,13 +245,13 @@ export class LogRow extends PureComponent {
    {row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
    )}
    - {showUtc && ( -
    - {row.timestamp} + {showTime && showUtc && ( +
    + {row.timeUtc}
    )} - {showLocalTime && ( -
    + {showTime && !showUtc && ( +
    {row.timeLocal}
    )} diff --git a/public/app/features/explore/LogRowContext.tsx b/public/app/features/explore/LogRowContext.tsx index da9c3ec481230..2e0e4b4c18615 100644 --- a/public/app/features/explore/LogRowContext.tsx +++ b/public/app/features/explore/LogRowContext.tsx @@ -103,12 +103,6 @@ const LogRowContextGroupHeader: React.FunctionComponent contextRow !== row.raw); - return (
    - Found {logRowsToRender.length} rows. + Found {rows.length} rows. {(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && ( JSX.Element; } +export const getRowContexts = async ( + getRowContext: (row: LogRowModel, options?: any) => Promise, + row: LogRowModel, + limit: number +) => { + const promises = [ + getRowContext(row, { + limit, + }), + getRowContext(row, { + limit: limit + 1, // Lets add one more to the limit as we're filtering out one row see comment below + direction: 'FORWARD', + }), + ]; + + const results: Array = await Promise.all(promises.map(p => p.catch(e => e))); + + return { + data: results.map((result, index) => { + const dataResult: DataQueryResponse = result as DataQueryResponse; + if (!dataResult.data) { + return []; + } + + // We need to filter out the row we're basing our search from because of how start/end params work in Loki API + // see https://github.com/grafana/loki/issues/597#issuecomment-506408980 + // the alternative to create our own add 1 nanosecond method to the a timestamp string would be quite complex + return dataResult.data.map(series => { + const filteredRows = series.rows.filter((r: any) => r[0] !== row.timestamp); + return filteredRows.map((row: any) => row[1]); + }); + }), + errors: results.map(result => { + const errorResult: DataQueryError = result as DataQueryError; + if (!errorResult.message) { + return null; + } + + return errorResult.message; + }), + }; +}; + export const LogRowContextProvider: React.FunctionComponent = ({ getRowContext, row, children, }) => { + // React Hook that creates a number state value called limit to component state and a setter function called setLimit + // The intial value for limit is 10 + // Used for the number of rows to retrieve from backend from a specific point in time const [limit, setLimit] = useState(10); + + // React Hook that creates an object state value called result to component state and a setter function called setResult + // The intial value for result is null + // Used for sorting the response from backend const [result, setResult] = useState<{ data: string[][]; errors: string[]; }>(null); + + // React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows + // The intial value for hasMoreContextRows is {before: true, after: true} + // Used for indicating in UI if there are more rows to load in a given direction const [hasMoreContextRows, setHasMoreContextRows] = useState({ before: true, after: true, }); + // React Hook that resolves two promises every time the limit prop changes + // First promise fetches limit number of rows backwards in time from a specific point in time + // Second promise fetches limit number of rows forwards in time from a specific point in time const { value } = useAsync(async () => { - const promises = [ - getRowContext(row, { - limit, - }), - getRowContext(row, { - limit, - direction: 'FORWARD', - }), - ]; - - const results: Array = await Promise.all(promises.map(p => p.catch(e => e))); - - return { - data: results.map(result => { - if ((result as DataQueryResponse).data) { - return (result as DataQueryResponse).data.map(series => { - return series.rows.map(row => row[1]); - }); - } else { - return []; - } - }), - errors: results.map(result => { - if ((result as DataQueryError).message) { - return (result as DataQueryError).message; - } else { - return null; - } - }), - }; + return await getRowContexts(getRowContext, row, limit); // Moved it to a separate function for debugging purposes }, [limit]); + // React Hook that performs a side effect every time the value (from useAsync hook) prop changes + // The side effect changes the result state with the response from the useAsync hook + // The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result useEffect(() => { if (value) { setResult(currentResult => { diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 201eda44ece3e..e2a9539be97f1 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -68,7 +68,7 @@ interface Props { onStartScanning?: () => void; onStopScanning?: () => void; onDedupStrategyChange: (dedupStrategy: LogsDedupStrategy) => void; - onToggleLogLevel: (hiddenLogLevels: Set) => void; + onToggleLogLevel: (hiddenLogLevels: LogLevel[]) => void; getRowContext?: (row: LogRowModel, options?: any) => Promise; } @@ -76,8 +76,7 @@ interface State { deferLogs: boolean; renderAll: boolean; showLabels: boolean; - showLocalTime: boolean; - showUtc: boolean; + showTime: boolean; } export default class Logs extends PureComponent { @@ -88,8 +87,7 @@ export default class Logs extends PureComponent { deferLogs: true, renderAll: false, showLabels: false, - showLocalTime: true, - showUtc: false, + showTime: true, }; componentDidMount() { @@ -103,7 +101,7 @@ export default class Logs extends PureComponent { } } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: Props, prevState: State) { // Staged rendering if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) { this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000); @@ -130,22 +128,15 @@ export default class Logs extends PureComponent { }); }; - onChangeLocalTime = (event: React.SyntheticEvent) => { + onChangeTime = (event: React.SyntheticEvent) => { const target = event.target as HTMLInputElement; this.setState({ - showLocalTime: target.checked, + showTime: target.checked, }); }; - onChangeUtc = (event: React.SyntheticEvent) => { - const target = event.target as HTMLInputElement; - this.setState({ - showUtc: target.checked, - }); - }; - - onToggleLogLevel = (rawLevel: string, hiddenRawLevels: Set) => { - const hiddenLogLevels: Set = new Set(Array.from(hiddenRawLevels).map(level => LogLevel[level])); + onToggleLogLevel = (rawLevel: string, hiddenRawLevels: string[]) => { + const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map(level => LogLevel[level]); this.props.onToggleLogLevel(hiddenLogLevels); }; @@ -178,7 +169,7 @@ export default class Logs extends PureComponent { return null; } - const { deferLogs, renderAll, showLabels, showLocalTime, showUtc } = this.state; + const { deferLogs, renderAll, showLabels, showTime } = this.state; const { dedupStrategy } = this.props; const hasData = data && data.rows && data.rows.length > 0; const hasLabel = hasData && dedupedData.hasUniqueLabels; @@ -223,8 +214,7 @@ export default class Logs extends PureComponent {
    - - + {Object.keys(LogsDedupStrategy).map((dedupType, i) => ( @@ -265,8 +255,8 @@ export default class Logs extends PureComponent { row={row} showDuplicates={showDuplicates} showLabels={showLabels && hasLabel} - showLocalTime={showLocalTime} - showUtc={showUtc} + showTime={showTime} + timeZone={timeZone} onClickLabel={onClickLabel} /> ))} @@ -281,8 +271,8 @@ export default class Logs extends PureComponent { row={row} showDuplicates={showDuplicates} showLabels={showLabels && hasLabel} - showLocalTime={showLocalTime} - showUtc={showUtc} + showTime={showTime} + timeZone={timeZone} onClickLabel={onClickLabel} /> ))} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 88639aa6864c3..4b5c00798d498 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -11,6 +11,7 @@ import { LogRowModel, LogsDedupStrategy, LoadingState, + TimeRange, } from '@grafana/ui'; import { ExploreId, ExploreItemState } from 'app/types/explore'; @@ -47,6 +48,7 @@ interface LogsContainerProps { isLive: boolean; stopLive: typeof changeRefreshIntervalAction; updateTimeRange: typeof updateTimeRange; + range: TimeRange; absoluteRange: AbsoluteTimeRange; } @@ -66,7 +68,7 @@ export class LogsContainer extends Component { this.props.changeDedupStrategy(this.props.exploreId, dedupStrategy); }; - hangleToggleLogLevel = (hiddenLogLevels: Set) => { + handleToggleLogLevel = (hiddenLogLevels: LogLevel[]) => { const { exploreId } = this.props; this.props.toggleLogLevelAction({ exploreId, @@ -90,7 +92,10 @@ export class LogsContainer extends Component { return ( nextProps.loading !== this.props.loading || nextProps.dedupStrategy !== this.props.dedupStrategy || - nextProps.logsHighlighterExpressions !== this.props.logsHighlighterExpressions + nextProps.logsHighlighterExpressions !== this.props.logsHighlighterExpressions || + nextProps.hiddenLogLevels !== this.props.hiddenLogLevels || + nextProps.scanning !== this.props.scanning || + nextProps.isLive !== this.props.isLive ); } @@ -107,7 +112,7 @@ export class LogsContainer extends Component { absoluteRange, timeZone, scanning, - scanRange, + range, width, hiddenLogLevels, isLive, @@ -116,7 +121,7 @@ export class LogsContainer extends Component { if (isLive) { return ( - + ); } @@ -135,11 +140,11 @@ export class LogsContainer extends Component { onStartScanning={onStartScanning} onStopScanning={onStopScanning} onDedupStrategyChange={this.handleDedupStrategyChange} - onToggleLogLevel={this.hangleToggleLogLevel} + onToggleLogLevel={this.handleToggleLogLevel} absoluteRange={absoluteRange} timeZone={timeZone} scanning={scanning} - scanRange={scanRange} + scanRange={range.raw} width={width} hiddenLogLevels={hiddenLogLevels} getRowContext={this.getLogRowContext} @@ -157,9 +162,9 @@ function mapStateToProps(state: StoreState, { exploreId }) { logsResult, loadingState, scanning, - scanRange, datasourceInstance, isLive, + range, absoluteRange, } = item; const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming; @@ -173,13 +178,13 @@ function mapStateToProps(state: StoreState, { exploreId }) { logsHighlighterExpressions, logsResult, scanning, - scanRange, timeZone, dedupStrategy, hiddenLogLevels, dedupedResult, datasourceInstance, isLive, + range, absoluteRange, }; } diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx index cad509d207160..03d066ce5a362 100644 --- a/public/app/features/explore/QueryRow.tsx +++ b/public/app/features/explore/QueryRow.tsx @@ -197,7 +197,7 @@ function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps) const query = queries[index]; const datasourceStatus = datasourceError ? DataSourceStatus.Disconnected : DataSourceStatus.Connected; const error = queryErrors.filter(queryError => queryError.refId === query.refId)[0]; - const series = graphResult ? graphResult : []; // TODO: use SeriesData + const series = graphResult ? graphResult : []; // TODO: use DataFrame const queryResponse: PanelData = { series, state: loadingState, diff --git a/public/app/features/explore/TimePicker.test.tsx b/public/app/features/explore/TimePicker.test.tsx deleted file mode 100644 index ea793096374b8..0000000000000 --- a/public/app/features/explore/TimePicker.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; - -import * as dateMath from '@grafana/ui/src/utils/datemath'; -import * as rangeUtil from '@grafana/ui/src/utils/rangeutil'; -import TimePicker from './TimePicker'; -import { RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui'; -import { toUtc, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper'; - -const DEFAULT_RANGE = { - from: 'now-6h', - to: 'now', -}; - -const fromRaw = (rawRange: RawTimeRange): TimeRange => { - const raw = { - from: isDateTime(rawRange.from) ? dateTime(rawRange.from) : rawRange.from, - to: isDateTime(rawRange.to) ? dateTime(rawRange.to) : rawRange.to, - }; - - return { - from: dateMath.parse(raw.from, false), - to: dateMath.parse(raw.to, true), - raw: rawRange, - }; -}; - -describe('', () => { - it('render default values when closed and relative time range', () => { - const range = fromRaw(DEFAULT_RANGE); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from); - expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to); - expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours'); - expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy(); - expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy(); - }); - - it('render default values when closed, utc and relative time range', () => { - const range = fromRaw(DEFAULT_RANGE); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from); - expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to); - expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours'); - expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy(); - expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy(); - }); - - it('renders default values when open and relative range', () => { - const range = fromRaw(DEFAULT_RANGE); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from); - expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to); - expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours'); - expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy(); - expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy(); - expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from); - expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to); - }); - - it('renders default values when open, utc and relative range', () => { - const range = fromRaw(DEFAULT_RANGE); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from); - expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to); - expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours'); - expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy(); - expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy(); - expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from); - expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to); - }); - - it('apply with absolute range and non-utc', () => { - const range = { - from: toUtc(1), - to: toUtc(1000), - raw: { - from: toUtc(1), - to: toUtc(1000), - }, - }; - const localRange = { - from: dateTime(1), - to: dateTime(1000), - raw: { - from: dateTime(1), - to: dateTime(1000), - }, - }; - const expectedRangeString = rangeUtil.describeTimeRange(localRange); - - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT)); - expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT)); - expect(wrapper.state('initialRange')).toBe(range.raw); - expect(wrapper.find('.timepicker-rangestring').text()).toBe(expectedRangeString); - expect(wrapper.find('.timepicker-from').props().value).toBe(localRange.from.format(TIME_FORMAT)); - expect(wrapper.find('.timepicker-to').props().value).toBe(localRange.to.format(TIME_FORMAT)); - - wrapper.find('button.gf-form-btn').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000); - - expect(wrapper.state('isOpen')).toBeFalsy(); - expect(wrapper.state('rangeString')).toBe(expectedRangeString); - }); - - it('apply with absolute range and utc', () => { - const range = { - from: toUtc(1), - to: toUtc(1000), - raw: { - from: toUtc(1), - to: toUtc(1000), - }, - }; - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00'); - expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01'); - expect(wrapper.state('initialRange')).toBe(range.raw); - expect(wrapper.find('.timepicker-rangestring').text()).toBe('1970-01-01 00:00:00 to 1970-01-01 00:00:01'); - expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00'); - expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01'); - - wrapper.find('button.gf-form-btn').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000); - - expect(wrapper.state('isOpen')).toBeFalsy(); - expect(wrapper.state('rangeString')).toBe('1970-01-01 00:00:00 to 1970-01-01 00:00:01'); - }); - - it('moves ranges backward by half the range on left arrow click when utc', () => { - const rawRange = { - from: toUtc(2000), - to: toUtc(4000), - raw: { - from: toUtc(2000), - to: toUtc(4000), - }, - }; - const range = fromRaw(rawRange); - - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02'); - expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04'); - - wrapper.find('.timepicker-left').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000); - }); - - it('moves ranges backward by half the range on left arrow click when not utc', () => { - const range = { - from: toUtc(2000), - to: toUtc(4000), - raw: { - from: toUtc(2000), - to: toUtc(4000), - }, - }; - const localRange = { - from: dateTime(2000), - to: dateTime(4000), - raw: { - from: dateTime(2000), - to: dateTime(4000), - }, - }; - - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT)); - expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT)); - - wrapper.find('.timepicker-left').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000); - }); - - it('moves ranges forward by half the range on right arrow click when utc', () => { - const range = { - from: toUtc(1000), - to: toUtc(3000), - raw: { - from: toUtc(1000), - to: toUtc(3000), - }, - }; - - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01'); - expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03'); - - wrapper.find('.timepicker-right').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000); - }); - - it('moves ranges forward by half the range on right arrow click when not utc', () => { - const range = { - from: toUtc(1000), - to: toUtc(3000), - raw: { - from: toUtc(1000), - to: toUtc(3000), - }, - }; - const localRange = { - from: dateTime(1000), - to: dateTime(3000), - raw: { - from: dateTime(1000), - to: dateTime(3000), - }, - }; - - const onChangeTime = sinon.spy(); - const wrapper = shallow(); - expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT)); - expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT)); - - wrapper.find('.timepicker-right').simulate('click'); - expect(onChangeTime.calledOnce).toBeTruthy(); - expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000); - expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000); - }); -}); diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx deleted file mode 100644 index 510646dcfe4fb..0000000000000 --- a/public/app/features/explore/TimePicker.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import React, { PureComponent, ChangeEvent } from 'react'; -import * as rangeUtil from '@grafana/ui/src/utils/rangeutil'; -import { Input, RawTimeRange, TimeRange, TIME_FORMAT, TimeZone } from '@grafana/ui'; -import { toUtc, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper'; - -interface TimePickerProps { - isOpen?: boolean; - range: TimeRange; - timeZone: TimeZone; - onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void; -} - -interface TimePickerState { - isOpen: boolean; - isUtc: boolean; - rangeString: string; - refreshInterval?: string; - initialRange: RawTimeRange; - - // Input-controlled text, keep these in a shape that is human-editable - fromRaw: string; - toRaw: string; -} - -const getRaw = (range: any, timeZone: TimeZone) => { - const rawRange = { - from: range.raw.from, - to: range.raw.to, - }; - - if (isDateTime(rawRange.from)) { - if (timeZone === 'browser') { - rawRange.from = rawRange.from.local(); - } - rawRange.from = rawRange.from.format(TIME_FORMAT); - } - - if (isDateTime(rawRange.to)) { - if (timeZone === 'browser') { - rawRange.to = rawRange.to.local(); - } - rawRange.to = rawRange.to.format(TIME_FORMAT); - } - - return rawRange; -}; - -/** - * TimePicker with dropdown menu for relative dates. - * - * Initialize with a range that is either based on relative rawRange.strings, - * or on Moment objects. - * Internally the component needs to keep a string representation in `fromRaw` - * and `toRaw` for the controlled inputs. - * When a time is picked, `onChangeTime` is called with the new range that - * is again based on relative time strings or Moment objects. - */ -export default class TimePicker extends PureComponent { - dropdownEl: any; - - constructor(props) { - super(props); - - const { range, timeZone, isOpen } = props; - const rawRange = getRaw(range, timeZone); - - this.state = { - isOpen: isOpen, - isUtc: timeZone === 'utc', - rangeString: rangeUtil.describeTimeRange(range.raw), - fromRaw: rawRange.from, - toRaw: rawRange.to, - initialRange: range.raw, - refreshInterval: '', - }; - } - - static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) { - if ( - state.initialRange && - state.initialRange.from === props.range.raw.from && - state.initialRange.to === props.range.raw.to - ) { - return state; - } - - const { range } = props; - const rawRange = getRaw(range, props.timeZone); - - return { - ...state, - fromRaw: rawRange.from, - toRaw: rawRange.to, - initialRange: range.raw, - rangeString: rangeUtil.describeTimeRange(range.raw), - }; - } - - move(direction: number, scanning?: boolean): RawTimeRange { - const { onChangeTime, range: origRange } = this.props; - const range = { - from: toUtc(origRange.from), - to: toUtc(origRange.to), - }; - - const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; - let to, from; - - if (direction === -1) { - to = range.to.valueOf() - timespan; - from = range.from.valueOf() - timespan; - } else if (direction === 1) { - to = range.to.valueOf() + timespan; - from = range.from.valueOf() + timespan; - } else { - to = range.to.valueOf(); - from = range.from.valueOf(); - } - - const nextTimeRange = { - from: this.props.timeZone === 'utc' ? toUtc(from) : dateTime(from), - to: this.props.timeZone === 'utc' ? toUtc(to) : dateTime(to), - }; - - if (onChangeTime) { - onChangeTime(nextTimeRange); - } - return nextTimeRange; - } - - handleChangeFrom = (event: ChangeEvent) => { - this.setState({ - fromRaw: event.target.value, - }); - }; - - handleChangeTo = (event: ChangeEvent) => { - this.setState({ - toRaw: event.target.value, - }); - }; - - handleClickApply = () => { - const { onChangeTime, timeZone } = this.props; - let rawRange; - - this.setState( - state => { - const { toRaw, fromRaw } = this.state; - rawRange = { - from: fromRaw, - to: toRaw, - }; - - if (rawRange.from.indexOf('now') === -1) { - rawRange.from = timeZone === 'utc' ? toUtc(rawRange.from, TIME_FORMAT) : dateTime(rawRange.from, TIME_FORMAT); - } - - if (rawRange.to.indexOf('now') === -1) { - rawRange.to = timeZone === 'utc' ? toUtc(rawRange.to, TIME_FORMAT) : dateTime(rawRange.to, TIME_FORMAT); - } - - const rangeString = rangeUtil.describeTimeRange(rawRange); - return { - isOpen: false, - rangeString, - }; - }, - () => { - if (onChangeTime) { - onChangeTime(rawRange); - } - } - ); - }; - - handleClickLeft = () => this.move(-1); - handleClickPicker = () => { - this.setState(state => ({ - isOpen: !state.isOpen, - })); - }; - handleClickRight = () => this.move(1); - handleClickRefresh = () => {}; - handleClickRelativeOption = range => { - const { onChangeTime } = this.props; - const rangeString = rangeUtil.describeTimeRange(range); - const rawRange = { - from: range.from, - to: range.to, - }; - this.setState( - { - toRaw: rawRange.to, - fromRaw: rawRange.from, - isOpen: false, - rangeString, - }, - () => { - if (onChangeTime) { - onChangeTime(rawRange); - } - } - ); - }; - - getTimeOptions() { - return rangeUtil.getRelativeTimesList({}, this.state.rangeString); - } - - dropdownRef = el => { - this.dropdownEl = el; - }; - - renderDropdown() { - const { fromRaw, isOpen, toRaw } = this.state; - if (!isOpen) { - return null; - } - const timeOptions = this.getTimeOptions(); - return ( -
    -
    -
    - Quick ranges -
    -
    - {Object.keys(timeOptions).map(section => { - const group = timeOptions[section]; - return ( - - ); - })} -
    -
    - -
    -
    - Custom range -
    -
    - -
    -
    - -
    -
    - - -
    -
    - -
    -
    -
    - -
    -
    -
    -
    - ); - } - - render() { - const { isUtc, rangeString, refreshInterval } = this.state; - - return ( -
    -
    - - - -
    - {this.renderDropdown()} -
    - ); - } -} diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index da7d0d4dcf1ed..579a026d4dc13 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -9,22 +9,14 @@ import { LogLevel, TimeRange, DataQueryError, - SeriesData, + DataFrame, LogsModel, TimeSeries, DataQueryResponseData, LoadingState, AbsoluteTimeRange, } from '@grafana/ui/src/types'; -import { - ExploreId, - ExploreItemState, - HistoryItem, - RangeScanner, - ExploreUIState, - ExploreMode, - QueryOptions, -} from 'app/types/explore'; +import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore'; import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory'; import TableModel from 'app/core/table_model'; @@ -98,6 +90,7 @@ export interface InitializeExplorePayload { eventBridge: Emitter; queries: DataQuery[]; range: TimeRange; + mode: ExploreMode; ui: ExploreUIState; } @@ -170,12 +163,6 @@ export interface RemoveQueryRowPayload { export interface ScanStartPayload { exploreId: ExploreId; - scanner: RangeScanner; -} - -export interface ScanRangePayload { - exploreId: ExploreId; - range: RawTimeRange; } export interface ScanStopPayload { @@ -214,7 +201,7 @@ export interface UpdateDatasourceInstancePayload { export interface ToggleLogLevelPayload { exploreId: ExploreId; - hiddenLogLevels: Set; + hiddenLogLevels: LogLevel[]; } export interface QueriesImportedPayload { @@ -252,7 +239,7 @@ export interface ProcessQueryResultsPayload { datasourceId: string; loadingState: LoadingState; series?: DataQueryResponseData[]; - delta?: SeriesData[]; + delta?: DataFrame[]; } export interface RunQueriesBatchPayload { @@ -261,7 +248,7 @@ export interface RunQueriesBatchPayload { } export interface LimitMessageRatePayload { - series: SeriesData[]; + series: DataFrame[]; exploreId: ExploreId; datasourceId: string; } @@ -396,7 +383,6 @@ export const runQueriesAction = actionCreatorFactory('explore * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range */ export const scanStartAction = actionCreatorFactory('explore/SCAN_START').create(); -export const scanRangeAction = actionCreatorFactory('explore/SCAN_RANGE').create(); /** * Stop any scanning for more results. diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index ab5421770fec7..a50d9d08010a6 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/public/app/features/explore/state/actions.test.ts @@ -1,5 +1,5 @@ import { refreshExplore, testDatasource, loadDatasource } from './actions'; -import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types'; +import { ExploreId, ExploreUrlState, ExploreUpdateState, ExploreMode } from 'app/types'; import { thunkTester } from 'test/core/thunk/thunkTester'; import { initializeExploreAction, @@ -55,6 +55,7 @@ const setup = (updateOverides?: Partial) => { datasource: 'some-datasource', queries: [], range: range.raw, + mode: ExploreMode.Metrics, ui, }; const updateDefaults = makeInitialUpdateState(); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index b33805d119c94..8d961a37c3ed0 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -26,7 +26,7 @@ import { LogsDedupStrategy, AbsoluteTimeRange, } from '@grafana/ui'; -import { ExploreId, RangeScanner, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore'; +import { ExploreId, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore'; import { updateDatasourceInstanceAction, changeQueryAction, @@ -58,7 +58,6 @@ import { loadExploreDatasources, changeModeAction, scanStopAction, - scanRangeAction, runQueriesAction, stateSaveAction, updateTimeRangeAction, @@ -66,6 +65,7 @@ import { import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; +import { getShiftedTimeRange } from 'app/core/utils/timePicker'; /** * Updates UI state and save it to the URL @@ -105,10 +105,10 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance; const queries = getState().explore[exploreId].queries; - await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, newDataSourceInstance)); - dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: newDataSourceInstance })); + await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, newDataSourceInstance)); + if (getState().explore[exploreId].isLive) { dispatch(changeRefreshInterval(exploreId, offOption.value)); } @@ -123,9 +123,8 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun */ export function changeMode(exploreId: ExploreId, mode: ExploreMode): ThunkResult { return dispatch => { - dispatch(clearQueries(exploreId)); + dispatch(clearQueriesAction({ exploreId })); dispatch(changeModeAction({ exploreId, mode })); - dispatch(runQueries(exploreId)); }; } @@ -236,6 +235,7 @@ export function initializeExplore( datasourceName: string, queries: DataQuery[], rawRange: RawTimeRange, + mode: ExploreMode, containerWidth: number, eventBridge: Emitter, ui: ExploreUIState @@ -251,6 +251,7 @@ export function initializeExplore( eventBridge, queries, range, + mode, ui, }) ); @@ -412,14 +413,15 @@ export function runQueries(exploreId: ExploreId): ThunkResult { * @param exploreId Explore area * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range */ -export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult { - return dispatch => { +export function scanStart(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { // Register the scanner - dispatch(scanStartAction({ exploreId, scanner })); + dispatch(scanStartAction({ exploreId })); // Scanning must trigger query run, and return the new range - const range = scanner(); + const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); // Set the new range to be displayed - dispatch(scanRangeAction({ exploreId, range })); + dispatch(updateTimeRangeAction({ exploreId, absoluteRange: range })); + dispatch(runQueriesAction({ exploreId })); }; } @@ -527,7 +529,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { } const { urlState, update, containerWidth, eventBridge } = itemState; - const { datasource, queries, range: urlRange, ui } = urlState; + const { datasource, queries, range: urlRange, mode, ui } = urlState; const refreshQueries: DataQuery[] = []; for (let index = 0; index < queries.length; index++) { const query = queries[index]; @@ -539,7 +541,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { // need to refresh datasource if (update.datasource) { const initialQueries = ensureQueries(queries); - dispatch(initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, ui)); + dispatch(initializeExplore(exploreId, datasource, initialQueries, range, mode, containerWidth, eventBridge, ui)); return; } @@ -557,6 +559,11 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { dispatch(setQueriesAction({ exploreId, queries: refreshQueries })); } + // need to refresh mode + if (update.mode) { + dispatch(changeModeAction({ exploreId, mode })); + } + // always run queries when refresh is needed if (update.queries || update.ui || update.range) { dispatch(runQueries(exploreId)); diff --git a/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts b/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts index c5da93081aa5d..5848bb2d3b212 100644 --- a/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts +++ b/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts @@ -1,23 +1,24 @@ import { mockExploreState } from 'test/mocks/mockExploreState'; -import { epicTester } from 'test/core/redux/epicTester'; +import { epicTester, MOCKED_ABSOLUTE_RANGE } from 'test/core/redux/epicTester'; import { processQueryResultsAction, resetQueryErrorAction, querySuccessAction, scanStopAction, - scanRangeAction, + updateTimeRangeAction, + runQueriesAction, } from '../actionTypes'; -import { SeriesData, LoadingState } from '@grafana/ui'; +import { DataFrame, LoadingState } from '@grafana/ui'; import { processQueryResultsEpic } from './processQueryResultsEpic'; import TableModel from 'app/core/table_model'; const testContext = () => { - const serieA: SeriesData = { + const serieA: DataFrame = { fields: [], refId: 'A', rows: [], }; - const serieB: SeriesData = { + const serieB: DataFrame = { fields: [], refId: 'B', rows: [], @@ -81,7 +82,7 @@ describe('processQueryResultsEpic', () => { describe('and we do not have a result', () => { it('then correct actions are dispatched', () => { - const { datasourceId, exploreId, state, scanner } = mockExploreState({ scanning: true }); + const { datasourceId, exploreId, state } = mockExploreState({ scanning: true }); const { latency, loadingState } = testContext(); const graphResult = []; const tableResult = new TableModel(); @@ -94,7 +95,8 @@ describe('processQueryResultsEpic', () => { .thenResultingActionsEqual( resetQueryErrorAction({ exploreId, refIds: [] }), querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }), - scanRangeAction({ exploreId, range: scanner() }) + updateTimeRangeAction({ exploreId, absoluteRange: MOCKED_ABSOLUTE_RANGE }), + runQueriesAction({ exploreId }) ); }); }); diff --git a/public/app/features/explore/state/epics/processQueryResultsEpic.ts b/public/app/features/explore/state/epics/processQueryResultsEpic.ts index 76e767c36a095..db46659eb6e37 100644 --- a/public/app/features/explore/state/epics/processQueryResultsEpic.ts +++ b/public/app/features/explore/state/epics/processQueryResultsEpic.ts @@ -11,17 +11,22 @@ import { processQueryResultsAction, ProcessQueryResultsPayload, querySuccessAction, - scanRangeAction, resetQueryErrorAction, scanStopAction, + updateTimeRangeAction, + runQueriesAction, } from '../actionTypes'; import { ResultProcessor } from '../../utils/ResultProcessor'; -export const processQueryResultsEpic: Epic, ActionOf, StoreState> = (action$, state$) => { +export const processQueryResultsEpic: Epic, ActionOf, StoreState> = ( + action$, + state$, + { getTimeZone, getShiftedTimeRange } +) => { return action$.ofType(processQueryResultsAction.type).pipe( mergeMap((action: ActionOf) => { const { exploreId, datasourceId, latency, loadingState, series, delta } = action.payload; - const { datasourceInstance, scanning, scanner, eventBridge } = state$.value.explore[exploreId]; + const { datasourceInstance, scanning, eventBridge } = state$.value.explore[exploreId]; // If datasource already changed, results do not matter if (datasourceInstance.meta.id !== datasourceId) { @@ -62,8 +67,9 @@ export const processQueryResultsEpic: Epic, ActionOf, StoreSt // Keep scanning for results if this was the last scanning transaction if (scanning) { if (_.size(result) === 0) { - const range = scanner(); - actions.push(scanRangeAction({ exploreId, range })); + const range = getShiftedTimeRange(-1, state$.value.explore[exploreId].range, getTimeZone(state$.value.user)); + actions.push(updateTimeRangeAction({ exploreId, absoluteRange: range })); + actions.push(runQueriesAction({ exploreId })); } else { // We can stop scanning if we have a result actions.push(scanStopAction({ exploreId })); diff --git a/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts b/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts index 6ddada2bc32af..7498f27aea467 100644 --- a/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts +++ b/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts @@ -14,10 +14,10 @@ import { clearQueriesAction, stateSaveAction, } from '../actionTypes'; -import { LoadingState, DataQueryRequest, SeriesData, FieldType } from '@grafana/ui'; +import { LoadingState, DataQueryRequest, DataFrame, FieldType } from '@grafana/ui'; const testContext = () => { - const series: SeriesData[] = [ + const series: DataFrame[] = [ { fields: [ { diff --git a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts b/public/app/features/explore/state/epics/runQueriesBatchEpic.ts index d5187d9438fc6..7f4ab4afd8cbf 100644 --- a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts +++ b/public/app/features/explore/state/epics/runQueriesBatchEpic.ts @@ -7,7 +7,7 @@ import { DataStreamState, LoadingState, DataQueryResponse, - SeriesData, + DataFrame, DataQueryResponseData, AbsoluteTimeRange, } from '@grafana/ui'; @@ -46,7 +46,7 @@ interface ProcessResponseConfig { now: number; loadingState: LoadingState; series?: DataQueryResponseData[]; - delta?: SeriesData[]; + delta?: DataFrame[]; } const processResponse = (config: ProcessResponseConfig) => { diff --git a/public/app/features/explore/state/epics/stateSaveEpic.test.ts b/public/app/features/explore/state/epics/stateSaveEpic.test.ts index bee12ad92a9e9..08df3ac4fb46a 100644 --- a/public/app/features/explore/state/epics/stateSaveEpic.test.ts +++ b/public/app/features/explore/state/epics/stateSaveEpic.test.ts @@ -15,7 +15,7 @@ describe('stateSaveEpic', () => { .whenActionIsDispatched(stateSaveAction()) .thenResultingActionsEqual( updateLocation({ - query: { left: '["now-6h","now","test",{"ui":[true,true,true,null]}]' }, + query: { left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' }, replace: true, }), setUrlReplacedAction({ exploreId }) @@ -32,8 +32,8 @@ describe('stateSaveEpic', () => { .thenResultingActionsEqual( updateLocation({ query: { - left: '["now-6h","now","test",{"ui":[true,true,true,null]}]', - right: '["now-6h","now","test",{"ui":[true,true,true,null]}]', + left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]', + right: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]', }, replace: true, }), @@ -51,7 +51,7 @@ describe('stateSaveEpic', () => { .whenActionIsDispatched(stateSaveAction()) .thenResultingActionsEqual( updateLocation({ - query: { left: '["now-6h","now","test",{"ui":[true,true,true,null]}]' }, + query: { left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' }, replace: false, }) ); diff --git a/public/app/features/explore/state/epics/stateSaveEpic.ts b/public/app/features/explore/state/epics/stateSaveEpic.ts index 107f1de547b47..210a14369e870 100644 --- a/public/app/features/explore/state/epics/stateSaveEpic.ts +++ b/public/app/features/explore/state/epics/stateSaveEpic.ts @@ -37,6 +37,7 @@ export const stateSaveEpic: Epic, ActionOf, StoreState> = (ac datasource: left.datasourceInstance.name, queries: left.queries.map(clearQueryKeys), range: toRawTimeRange(left.range), + mode: left.mode, ui: { showingGraph: left.showingGraph, showingLogs: true, @@ -50,6 +51,7 @@ export const stateSaveEpic: Epic, ActionOf, StoreState> = (ac datasource: right.datasourceInstance.name, queries: right.queries.map(clearQueryKeys), range: toRawTimeRange(right.range), + mode: right.mode, ui: { showingGraph: right.showingGraph, showingLogs: true, diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 7175089e4d751..9404e1591f469 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -5,14 +5,7 @@ import { makeInitialUpdateState, initialExploreState, } from './reducers'; -import { - ExploreId, - ExploreItemState, - ExploreUrlState, - ExploreState, - RangeScanner, - ExploreMode, -} from 'app/types/explore'; +import { ExploreId, ExploreItemState, ExploreUrlState, ExploreState, ExploreMode } from 'app/types/explore'; import { reducerTester } from 'test/core/redux/reducerTester'; import { scanStartAction, @@ -36,28 +29,23 @@ import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState } describe('Explore item reducer', () => { describe('scanning', () => { it('should start scanning', () => { - const scanner = jest.fn(); const initalState = { ...makeExploreItemState(), scanning: false, - scanner: undefined as RangeScanner, }; reducerTester() .givenReducer(itemReducer as Reducer>, initalState) - .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left, scanner })) + .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), scanning: true, - scanner, }); }); it('should stop scanning', () => { - const scanner = jest.fn(); const initalState = { ...makeExploreItemState(), scanning: true, - scanner, scanRange: {}, }; @@ -67,7 +55,6 @@ describe('Explore item reducer', () => { .thenStateShouldEqual({ ...makeExploreItemState(), scanning: false, - scanner: undefined, scanRange: undefined, }); }); @@ -104,6 +91,7 @@ describe('Explore item reducer', () => { datasource: true, queries: true, range: true, + mode: true, ui: true, }, }; @@ -213,6 +201,7 @@ export const setup = (urlStateOverrides?: any) => { from: '', to: '', }, + mode: ExploreMode.Metrics, ui: { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 37e9c7950d012..dcbd19bc1cfee 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -24,7 +24,6 @@ import { queryFailureAction, setUrlReplacedAction, querySuccessAction, - scanRangeAction, scanStopAction, resetQueryErrorAction, queryStartAction, @@ -70,6 +69,7 @@ export const makeInitialUpdateState = (): ExploreUpdateState => ({ datasource: false, queries: false, range: false, + mode: false, ui: false, }); @@ -215,12 +215,13 @@ export const itemReducer = reducerFactory({} as ExploreItemSta .addMapper({ filter: initializeExploreAction, mapper: (state, action): ExploreItemState => { - const { containerWidth, eventBridge, queries, range, ui } = action.payload; + const { containerWidth, eventBridge, queries, range, mode, ui } = action.payload; return { ...state, containerWidth, eventBridge, range, + mode, queries, initialized: true, queryKeys: getQueryKeys(queries, state.datasourceInstance), @@ -402,16 +403,10 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }; }, }) - .addMapper({ - filter: scanRangeAction, - mapper: (state, action): ExploreItemState => { - return { ...state, scanRange: action.payload.range }; - }, - }) .addMapper({ filter: scanStartAction, mapper: (state, action): ExploreItemState => { - return { ...state, scanning: true, scanner: action.payload.scanner }; + return { ...state, scanning: true }; }, }) .addMapper({ @@ -421,7 +416,6 @@ export const itemReducer = reducerFactory({} as ExploreItemSta ...state, scanning: false, scanRange: undefined, - scanner: undefined, update: makeInitialUpdateState(), }; }, @@ -599,13 +593,14 @@ export const updateChildRefreshState = ( return { ...state, urlState, - update: { datasource: false, queries: false, range: false, ui: false }, + update: { datasource: false, queries: false, range: false, mode: false, ui: false }, }; } const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false; const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false; const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false; + const mode = _.isEqual(urlState ? urlState.mode : ExploreMode.Metrics, state.urlState.mode) === false; const ui = _.isEqual(urlState ? urlState.ui : DEFAULT_UI_STATE, state.urlState.ui) === false; return { @@ -616,6 +611,7 @@ export const updateChildRefreshState = ( datasource, queries, range, + mode, ui, }, }; diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 4420450c2211b..50b1bd5cecf50 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -6,6 +6,11 @@ jest.mock('@grafana/ui/src/utils/moment_wrapper', () => ({ format: (fmt: string) => 'format() jest mocked', }; }, + toUtc: (ts: any) => { + return { + format: (fmt: string) => 'format() jest mocked', + }; + }, })); import { ResultProcessor } from './ResultProcessor'; @@ -178,6 +183,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038519831, uniqueLabels: {}, }, @@ -191,6 +197,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038518831, uniqueLabels: {}, }, @@ -321,6 +328,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038519831, uniqueLabels: {}, }, @@ -335,6 +343,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038518831, uniqueLabels: {}, }, @@ -375,6 +384,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038519831, uniqueLabels: {}, }, @@ -389,6 +399,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038518831, uniqueLabels: {}, }, @@ -403,6 +414,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038519831, uniqueLabels: {}, }, @@ -417,6 +429,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038518831, uniqueLabels: {}, }, diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 7c4c27c5b22ac..586b04255eaa4 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -3,17 +3,17 @@ import { TableData, isTableData, LogsModel, - toSeriesData, + toDataFrame, guessFieldTypes, DataQueryResponseData, TimeSeries, } from '@grafana/ui'; import { ExploreItemState, ExploreMode } from 'app/types/explore'; -import { getProcessedSeriesData } from 'app/features/dashboard/state/PanelQueryState'; +import { getProcessedDataFrame } from 'app/features/dashboard/state/PanelQueryState'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import { sortLogsResult } from 'app/core/utils/explore'; -import { seriesDataToLogsModel } from 'app/core/logs_model'; +import { dataFrameToLogsModel } from 'app/core/logs_model'; import { default as TimeSeries2 } from 'app/core/time_series2'; import { DataProcessor } from 'app/plugins/panel/graph/data_processor'; @@ -77,8 +77,8 @@ export class ResultProcessor { return null; } const graphInterval = this.state.queryIntervals.intervalMs; - const seriesData = this.rawData.map(result => guessFieldTypes(toSeriesData(result))); - const newResults = this.rawData ? seriesDataToLogsModel(seriesData, graphInterval) : null; + const dataFrame = this.rawData.map(result => guessFieldTypes(toDataFrame(result))); + const newResults = this.rawData ? dataFrameToLogsModel(dataFrame, graphInterval) : null; if (this.replacePreviousResults) { return newResults; @@ -107,7 +107,7 @@ export class ResultProcessor { }; private makeTimeSeriesList = (rawData: any[]) => { - const dataList = getProcessedSeriesData(rawData); + const dataList = getProcessedDataFrame(rawData); const dataProcessor = new DataProcessor({ xaxis: {}, aliasColors: [] }); // Hack before we use GraphSeriesXY instead const timeSeries = dataProcessor.getSeriesList({ dataList }); diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index a03a431ae0fd9..062dae30d339e 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -8,14 +8,14 @@ import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/u import { ContextSrv } from 'app/core/services/context_srv'; import { toLegacyResponseData, - isSeriesData, + isDataFrame, LegacyResponseData, TimeRange, DataSourceApi, PanelData, LoadingState, DataQueryResponse, - SeriesData, + DataFrame, } from '@grafana/ui'; import { Unsubscribable } from 'rxjs'; import { PanelModel } from 'app/features/dashboard/state'; @@ -150,7 +150,7 @@ class MetricsPanelCtrl extends PanelCtrl { // The result should already be processed, but just in case if (!data.legacy) { data.legacy = data.series.map(v => { - if (isSeriesData(v)) { + if (isDataFrame(v)) { return toLegacyResponseData(v); } return v; @@ -163,7 +163,7 @@ class MetricsPanelCtrl extends PanelCtrl { data: data.legacy, }); } else { - this.handleSeriesData(data.series); + this.handleDataFrame(data.series); } }, }; @@ -222,14 +222,14 @@ class MetricsPanelCtrl extends PanelCtrl { }); } - handleSeriesData(data: SeriesData[]) { + handleDataFrame(data: DataFrame[]) { this.loading = false; if (this.dashboard && this.dashboard.snapshot) { this.panel.snapshotData = data; } - // Subclasses that asked for SeriesData will override + // Subclasses that asked for DataFrame will override } handleQueryResult(result: DataQueryResponse) { diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 52ef34b581010..473ccdbb99b81 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import Remarkable from 'remarkable'; import { sanitize, escapeHtml } from 'app/core/utils/text'; +import { renderMarkdown } from '@grafana/data'; import config from 'app/core/config'; import { profiler } from 'app/core/core'; @@ -259,8 +259,8 @@ export class PanelCtrl { const interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars); let html = '
    '; - const md = new Remarkable().render(interpolatedMarkdown); - html += sanitize(md); + const md = renderMarkdown(interpolatedMarkdown); + html += config.disableSanitizeHtml ? md : sanitize(md); if (this.panel.links && this.panel.links.length > 0) { html += '