diff --git a/.circleci/config.yml b/.circleci/config.yml index f6d66daab84..7e89ffe7a1f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,7 +19,7 @@ version: 2 jobs: mysql-integration-test: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 - image: circleci/mysql:5.6-ram environment: MYSQL_ROOT_PASSWORD: rootpass @@ -39,7 +39,7 @@ jobs: postgres-integration-test: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 - image: circleci/postgres:9.3-ram environment: POSTGRES_USER: grafanatest @@ -74,7 +74,7 @@ jobs: gometalinter: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 environment: # we need CGO because of go-sqlite3 CGO_ENABLED: 1 @@ -106,7 +106,7 @@ jobs: test-backend: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -116,7 +116,7 @@ jobs: build-all: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -147,9 +147,6 @@ jobs: - run: name: sha-sum packages command: 'go run build.go sha-dist' - - run: - name: Build Grafana.com master publisher - command: 'go build -o scripts/publish scripts/build/publish.go' - run: name: Test and build Grafana.com release publisher command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .' @@ -158,13 +155,12 @@ jobs: paths: - dist/grafana* - scripts/*.sh - - scripts/publish - scripts/build/release_publisher/release_publisher - scripts/build/publish.sh build: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -233,7 +229,7 @@ jobs: build-enterprise: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -265,7 +261,7 @@ jobs: build-all-enterprise: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -393,7 +389,7 @@ jobs: name: Publish to Grafana.com command: | rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz - ./scripts/publish -apiKey ${GRAFANA_COM_API_KEY} + cd dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -from-local deploy-release: docker: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9225d6545e4..67acea4e149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,44 @@ -# 5.5.0 (unreleased) +# 6.0.0-beta1 (unreleased) ### New Features * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) * **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968) +* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl) * **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) ### Minor +* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813) * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) * **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175) +* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh) +* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh) +* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) +* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551) +* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) -* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) -* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK) * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) -* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard) * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) +* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard) +* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud) +* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia) +* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK) * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik) -* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) -* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) +* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo) +* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484) +* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062) +* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) ### Bug fixes * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) * **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795) +* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy) + +### Breaking changes +* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`. +* **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991) # 5.4.3 (2019-01-14) diff --git a/Dockerfile b/Dockerfile index c3af89b6092..c3e59c8048e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Golang build container -FROM golang:1.11.4 +FROM golang:1.11.5 WORKDIR $GOPATH/src/github.com/grafana/grafana @@ -19,11 +19,13 @@ COPY package.json package.json RUN go run build.go build # Node build container -FROM node:8 +FROM node:10.14.2 WORKDIR /usr/src/app/ COPY package.json yarn.lock ./ +COPY packages packages + RUN yarn install --pure-lockfile --no-progress COPY Gruntfile.js tsconfig.json tslint.json ./ diff --git a/ROADMAP.md b/ROADMAP.md index 891bc9f790b..b5e62578475 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan. ### Short term (1-2 months) - PRs & Bugs - - Multi-Stat panel + - React Panel Support + - React Query Editor Support - Metrics & Log Explore UI - + - Grafana UI library shared between grafana & plugins + - Seperate visualization from panels + - More reuse between Explore & dashboard + - Explore logging support for more data sources + ### Mid term (2-4 months) - - React Panels - - Change visualization (panel type) on the fly. - - Templating Query Editor UI Plugin hook - - Backend plugins + - Drilldown links + - Dashboards as code workflows + - React migration + - New panels ### Long term (4 - 8 months) - Alerting improvements (silence, per series tracking, etc) - - Progress on React migration ### In a distant future far far away - Meta queries diff --git a/appveyor.yml b/appveyor.yml index 5f97784dd38..ccf9b5a06e1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "8" GOPATH: C:\gopath - GOVERSION: 1.11.4 + GOVERSION: 1.11.5 install: - rmdir c:\go /s /q diff --git a/build.go b/build.go index 4486cd3deb9..ebe240d97ef 100644 --- a/build.go +++ b/build.go @@ -46,6 +46,8 @@ var ( binaries []string = []string{"grafana-server", "grafana-cli"} isDev bool = false enterprise bool = false + skipRpmGen bool = false + skipDebGen bool = false ) func main() { @@ -67,6 +69,8 @@ func main() { flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana") flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system") flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") + flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)") + flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)") flag.Parse() buildId = shortenBuildId(buildIdRaw) @@ -165,6 +169,7 @@ func makeLatestDistCopies() { ".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm", ".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz", ".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz", + ".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz", ".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz", } @@ -239,6 +244,8 @@ func createDebPackages() { previousPkgArch := pkgArch if pkgArch == "armv7" { pkgArch = "armhf" + } else if pkgArch == "armv6" { + pkgArch = "armel" } createPackage(linuxPackageOptions{ packageType: "deb", @@ -289,8 +296,13 @@ func createRpmPackages() { } func createLinuxPackages() { - createDebPackages() - createRpmPackages() + if !skipDebGen { + createDebPackages() + } + + if !skipRpmGen { + createRpmPackages() + } } func createPackage(options linuxPackageOptions) { diff --git a/conf/defaults.ini b/conf/defaults.ini index 7f61ac96870..4da3588bef2 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -106,6 +106,22 @@ path = grafana.db # For "sqlite3" only. cache mode setting used for connecting to the database cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +login_remember_days = 7 + +# How often should the login token be rotated. default to '10m' +rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +delete_expired_token_after_days = 30 + #################################### Session ############################# [session] # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" @@ -175,11 +191,6 @@ admin_password = admin # used for signing secret_key = SW2YcwTIb9zpOOhoPsMm -# Auto-login remember days -login_remember_days = 7 -cookie_username = grafana_user -cookie_remember_name = grafana_remember - # disable gravatar profile images disable_gravatar = false @@ -189,6 +200,9 @@ data_source_proxy_whitelist = # disable protection against brute force login attempts disable_brute_force_login_protection = false +# set cookies as https only. default is false +https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -490,7 +504,7 @@ concurrent_render_limit = 5 #################################### Explore ############################# [explore] # Enable the Explore section -enabled = false +enabled = true #################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /metrics @@ -570,6 +584,7 @@ callback_url = [panels] enable_alpha = false +disable_sanitize_html = false [enterprise] license_path = diff --git a/conf/sample.ini b/conf/sample.ini index 014016d45bc..8b731e43bd9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -102,6 +102,22 @@ log_queries = # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) ;cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +;cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +;login_remember_days = 7 + +# How often should the login token be rotated. default to '10' +;rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +;delete_expired_token_after_days = 30 + #################################### Session #################################### [session] # Either "memory", "file", "redis", "mysql", "postgres", default is "file" @@ -162,11 +178,6 @@ log_queries = # used for signing ;secret_key = SW2YcwTIb9zpOOhoPsMm -# Auto-login remember days -;login_remember_days = 7 -;cookie_username = grafana_user -;cookie_remember_name = grafana_remember - # disable gravatar profile images ;disable_gravatar = false @@ -176,6 +187,9 @@ log_queries = # disable protection against brute force login attempts ;disable_brute_force_login_protection = false +# set cookies as https only. default is false +;https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -415,7 +429,7 @@ log_queries = #################################### Explore ############################# [explore] # Enable the Explore section -;enabled = false +;enabled = true #################################### Internal Grafana Metrics ########################## # Metrics available at HTTP API Url /metrics @@ -495,3 +509,8 @@ log_queries = # Path to a valid Grafana Enterprise license.jwt file ;license_path = +[panels] +;enable_alpha = false +# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. +;disable_sanitize_html = false + diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index 1195e2a977c..504ee86404d 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -54,7 +54,8 @@ services: # - GF_DATABASE_SSL_MODE=disable # - GF_SESSION_PROVIDER=postgres # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable - - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug + - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug + - GF_LOGIN_ROTATE_TOKEN_MINUTES=2 ports: - 3000 depends_on: diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md new file mode 100644 index 00000000000..8e724637acb --- /dev/null +++ b/devenv/docker/loadtest/README.md @@ -0,0 +1,69 @@ +# Grafana load test + +Runs load tests and checks using [k6](https://k6.io/). + +## Prerequisites + +Docker + +## Run + +Run load test for 15 minutes: + +```bash +$ ./run.sh +``` + +Run load test for custom duration: + +```bash +$ ./run.sh -d 10s +``` + +Example output: + +```bash + + /\ |‾‾| /‾‾/ /‾/ + /\ / \ | |_/ / / / + / \/ \ | | / ‾‾\ + / \ | |‾\ \ | (_) | + / __________ \ |__| \__\ \___/ .io + + execution: local + output: - + script: src/auth_token_test.js + + duration: 15m0s, iterations: - + vus: 2, max: 2 + + done [==========================================================] 15m0s / 15m0s + + █ user auth token test + + █ user authenticates thru ui with username and password + + ✓ response status is 200 + ✓ response has cookie 'grafana_session' with 32 characters + + █ batch tsdb requests + + ✓ response status is 200 + + checks.....................: 100.00% ✓ 32844 ✗ 0 + data_received..............: 411 MB 457 kB/s + data_sent..................: 12 MB 14 kB/s + group_duration.............: avg=95.64ms min=16.42ms med=94.35ms max=307.52ms p(90)=137.78ms p(95)=146.75ms + http_req_blocked...........: avg=1.27ms min=942ns med=610.08µs max=48.32ms p(90)=2.92ms p(95)=4.25ms + http_req_connecting........: avg=1.06ms min=0s med=456.79µs max=47.19ms p(90)=2.55ms p(95)=3.78ms + http_req_duration..........: avg=58.16ms min=1ms med=52.59ms max=293.35ms p(90)=109.53ms p(95)=120.19ms + http_req_receiving.........: avg=38.98µs min=6.43µs med=32.55µs max=16.2ms p(90)=64.63µs p(95)=78.8µs + http_req_sending...........: avg=328.66µs min=8.09µs med=110.77µs max=44.13ms p(90)=552.65µs p(95)=1.09ms + http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...........: avg=57.79ms min=935.02µs med=52.15ms max=293.06ms p(90)=109.04ms p(95)=119.71ms + http_reqs..................: 34486 38.317775/s + iteration_duration.........: avg=1.09s min=1.81µs med=1.09s max=1.3s p(90)=1.13s p(95)=1.14s + iterations.................: 1642 1.824444/s + vus........................: 2 min=2 max=2 + vus_max....................: 2 min=2 max=2 +``` diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js new file mode 100644 index 00000000000..e1356fb6f9a --- /dev/null +++ b/devenv/docker/loadtest/auth_token_test.js @@ -0,0 +1,71 @@ +import { sleep, check, group } from 'k6'; +import { createClient, createBasicAuthClient } from './modules/client.js'; +import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js'; + +export let options = { + noCookiesReset: true +}; + +let endpoint = __ENV.URL || 'http://localhost:3000'; +const client = createClient(endpoint); + +export const setup = () => { + const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin'); + const orgId = createTestOrgIfNotExists(basicAuthClient); + const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient); + client.withOrgId(orgId); + return { + orgId: orgId, + datasourceId: datasourceId, + }; +} + +export default (data) => { + group("user auth token test", () => { + if (__ITER === 0) { + group("user authenticates thru ui with username and password", () => { + let res = client.ui.login('admin', 'admin'); + + check(res, { + 'response status is 200': (r) => r.status === 200, + 'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32, + }); + }); + } + + if (__ITER !== 0) { + group("batch tsdb requests", () => { + const batchCount = 20; + const requests = []; + const payload = { + from: '1547765247624', + to: '1547768847624', + queries: [{ + refId: 'A', + scenarioId: 'random_walk', + intervalMs: 10000, + maxDataPoints: 433, + datasourceId: data.datasourceId, + }] + }; + + requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' }); + + for (let n = 0; n < batchCount; n++) { + requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload }); + } + + let responses = client.batch(requests); + for (let n = 0; n < batchCount; n++) { + check(responses[n], { + 'response status is 200': (r) => r.status === 200, + }); + } + }); + } + }); + + sleep(1) +} + +export const teardown = (data) => {} diff --git a/devenv/docker/loadtest/modules/client.js b/devenv/docker/loadtest/modules/client.js new file mode 100644 index 00000000000..bda0da64564 --- /dev/null +++ b/devenv/docker/loadtest/modules/client.js @@ -0,0 +1,187 @@ +import http from "k6/http"; +import encoding from 'k6/encoding'; + +export const UIEndpoint = class UIEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + login(username, pwd) { + const payload = { user: username, password: pwd }; + return this.httpClient.formPost('/login', payload); + } +} + +export const DatasourcesEndpoint = class DatasourcesEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getById(id) { + return this.httpClient.get(`/datasources/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/datasources/name/${name}`); + } + + create(payload) { + return this.httpClient.post(`/datasources`, JSON.stringify(payload)); + } + + delete(id) { + return this.httpClient.delete(`/datasources/${id}`); + } +} + +export const OrganizationsEndpoint = class OrganizationsEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getById(id) { + return this.httpClient.get(`/orgs/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/orgs/name/${name}`); + } + + create(name) { + let payload = { + name: name, + }; + return this.httpClient.post(`/orgs`, JSON.stringify(payload)); + } + + delete(id) { + return this.httpClient.delete(`/orgs/${id}`); + } +} + +export const GrafanaClient = class GrafanaClient { + constructor(httpClient) { + httpClient.onBeforeRequest = this.onBeforeRequest; + this.raw = httpClient; + this.ui = new UIEndpoint(httpClient); + this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api')); + this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api')); + } + + batch(requests) { + return this.raw.batch(requests); + } + + withOrgId(orgId) { + this.orgId = orgId; + } + + onBeforeRequest(params) { + if (this.orgId && this.orgId > 0) { + params = params.headers || {}; + params.headers["X-Grafana-Org-Id"] = this.orgId; + } + } +} + +export const BaseClient = class BaseClient { + constructor(url, subUrl) { + if (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + + if (subUrl.endsWith('/')) { + subUrl = subUrl.substring(0, subUrl.length - 1); + } + + this.url = url + subUrl; + this.onBeforeRequest = () => {}; + } + + withUrl(subUrl) { + let c = new BaseClient(this.url, subUrl); + c.onBeforeRequest = this.onBeforeRequest; + return c; + } + + beforeRequest(params) { + + } + + get(url, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.get(this.url + url, params); + } + + formPost(url, body, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.post(this.url + url, body, params); + } + + post(url, body, params) { + params = params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/json'; + + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.post(this.url + url, body, params); + } + + delete(url, params) { + params = params || {}; + this.beforeRequest(params); + this.onBeforeRequest(params); + return http.del(this.url + url, null, params); + } + + batch(requests) { + for (let n = 0; n < requests.length; n++) { + let params = requests[n].params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/json'; + this.beforeRequest(params); + this.onBeforeRequest(params); + requests[n].params = params; + requests[n].url = this.url + requests[n].url; + if (requests[n].body) { + requests[n].body = JSON.stringify(requests[n].body); + } + } + + return http.batch(requests); + } +} + +export class BasicAuthClient extends BaseClient { + constructor(url, subUrl, username, password) { + super(url, subUrl); + this.username = username; + this.password = password; + } + + withUrl(subUrl) { + let c = new BasicAuthClient(this.url, subUrl, this.username, this.password); + c.onBeforeRequest = this.onBeforeRequest; + return c; + } + + beforeRequest(params) { + params = params || {}; + params.headers = params.headers || {}; + let token = `${this.username}:${this.password}`; + params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`; + } +} + +export const createClient = (url) => { + return new GrafanaClient(new BaseClient(url, '')); +} + +export const createBasicAuthClient = (url, username, password) => { + return new GrafanaClient(new BasicAuthClient(url, '', username, password)); +} diff --git a/devenv/docker/loadtest/modules/util.js b/devenv/docker/loadtest/modules/util.js new file mode 100644 index 00000000000..af6d4cdac09 --- /dev/null +++ b/devenv/docker/loadtest/modules/util.js @@ -0,0 +1,35 @@ +export const createTestOrgIfNotExists = (client) => { + let orgId = 0; + let res = client.orgs.getByName('k6'); + if (res.status === 404) { + res = client.orgs.create('k6'); + if (res.status !== 200) { + throw new Error('Expected 200 response status when creating org'); + } + orgId = res.json().orgId; + } else { + orgId = res.json().id; + } + + client.withOrgId(orgId); + return orgId; +} + +export const createTestdataDatasourceIfNotExists = (client) => { + const payload = { + access: 'proxy', + isDefault: false, + name: 'k6-testdata', + type: 'testdata', + }; + + let res = client.datasources.getByName(payload.name); + if (res.status === 404) { + res = client.datasources.create(payload); + if (res.status !== 200) { + throw new Error('Expected 200 response status when creating datasource'); + } + } + + return res.json().id; +} diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh new file mode 100755 index 00000000000..474d75383b6 --- /dev/null +++ b/devenv/docker/loadtest/run.sh @@ -0,0 +1,24 @@ +#/bin/bash + +PWD=$(pwd) + +run() { + duration='15m' + url='http://localhost:3000' + + while getopts ":d:u:" o; do + case "${o}" in + d) + duration=${OPTARG} + ;; + u) + url=${OPTARG} + ;; + esac + done + shift $((OPTIND-1)) + + docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js +} + +run "$@" diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 9aaf29ec5f4..364b55b0cfc 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "defaultRegion": "us-west-1" }, "secureJsonData": { - "accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded - "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded + "accessKey": "Ol4pIDpeKSA6XikgOl4p", + "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" } } ``` diff --git a/docs/sources/http_api/other.md b/docs/sources/http_api/other.md index 5bf0cde05fe..ea905bf88f0 100644 --- a/docs/sources/http_api/other.md +++ b/docs/sources/http_api/other.md @@ -82,4 +82,29 @@ HTTP/1.1 200 Content-Type: application/json {"message": "Logged in"} -``` \ No newline at end of file +``` + +# Health API + +## Returns health information about Grafana + +`GET /api/health` + +**Example Request** + +```http +GET /api/health +Accept: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 OK + +{ + "commit": "087143285", + "database": "ok", + "version": "5.1.3" +} +``` diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 0e5a55b3c0e..46bab83654e 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -391,6 +391,12 @@ value is `true`. If you want to track Grafana usage via Google analytics specify *your* Universal Analytics ID here. By default this feature is disabled. +### check_for_updates + +Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used +in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor +send any sensitive information. +
## [dashboards] @@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler Alert notifications can include images, but rendering many images at the same time can overload the server. This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default value is `5`. + +## [panels] + +### enable_alpha +Set to true if you want to test panels that are not yet ready for general usage. + +### disable_sanitize_html +If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default +is false. This settings was introduced in Grafana v6.0. + + diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 71ce6bdd2ae..3ef32b1b10f 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description `csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string `distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB. `lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression. +`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded. Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1). diff --git a/package.json b/package.json index 470101ff0c4..c794375793b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.5.0-pre1", + "version": "6.0.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -188,7 +188,8 @@ "slate-react": "^0.12.4", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop/tarball/master", - "tinycolor2": "^1.4.1" + "tinycolor2": "^1.4.1", + "xss": "^1.0.3" }, "resolutions": { "caniuse-db": "1.0.30000772", diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 434ee03c7a1..40f6c6c3c37 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -7,12 +7,12 @@ interface Props { autoHide?: boolean; autoHideTimeout?: number; autoHideDuration?: number; - autoMaxHeight?: string; + autoHeightMax?: string; hideTracksWhenNotNeeded?: boolean; renderTrackHorizontal?: React.FunctionComponent; renderTrackVertical?: React.FunctionComponent; scrollTop?: number; - setScrollTop: (value: React.MouseEvent) => void; + setScrollTop: (event: any) => void; autoHeightMin?: number | string; } @@ -22,13 +22,13 @@ interface Props { export class CustomScrollbar extends PureComponent { static defaultProps: Partial = { customClassName: 'custom-scrollbars', - autoHide: true, + autoHide: false, autoHideTimeout: 200, autoHideDuration: 200, - autoMaxHeight: '100%', - hideTracksWhenNotNeeded: false, setScrollTop: () => {}, + hideTracksWhenNotNeeded: false, autoHeightMin: '0', + autoHeightMax: '100%', }; private ref: React.RefObject; @@ -59,16 +59,32 @@ export class CustomScrollbar extends PureComponent { } render() { - const { customClassName, children, autoMaxHeight, renderTrackHorizontal, renderTrackVertical } = this.props; + const { + customClassName, + children, + autoHeightMax, + autoHeightMin, + setScrollTop, + autoHide, + autoHideTimeout, + hideTracksWhenNotNeeded, + renderTrackHorizontal, + renderTrackVertical, + } = this.props; return (
)} renderTrackVertical={renderTrackVertical || (props =>
)} renderThumbHorizontal={props =>
} diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index aabe3dd98c5..60b4a2e0aa5 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` Object { "height": "auto", "maxHeight": "100%", - "minHeight": 0, + "minHeight": "0", "overflow": "hidden", "position": "relative", "width": "100%", @@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` "marginBottom": 0, "marginRight": 0, "maxHeight": "calc(100% + 0px)", - "minHeight": 0, + "minHeight": "calc(0 + 0px)", "overflow": "scroll", "position": "relative", "right": undefined, diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx index b3396841d4d..396b7a03162 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -98,83 +98,6 @@ describe('Get thresholds formatted', () => { }); }); -describe('Format value with value mappings', () => { - it('should return undefined with no valuemappings', () => { - const valueMappings: ValueMapping[] = []; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result).toBeUndefined(); - }); - - it('should return undefined with no matching valuemappings', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result).toBeUndefined(); - }); - - it('should return first matching mapping with lowest id', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result.text).toEqual('1-20'); - }); - - it('should return rangeToText mapping where value equals to', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, - { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result.text).toEqual('1-10'); - }); - - it('should return rangeToText mapping where value equals from', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, - { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result.text).toEqual('10-20'); - }); - - it('should return rangeToText mapping where value is between from and to', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings }); - - const result = instance.getFirstFormattedValueMapping(valueMappings, value); - - expect(result.text).toEqual('1-20'); - }); -}); - describe('Format value', () => { it('should return if value isNaN', () => { const valueMappings: ValueMapping[] = []; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index c0f23f17bc3..8d4d8c2169d 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -1,10 +1,10 @@ import React, { PureComponent } from 'react'; import $ from 'jquery'; -import { ValueMapping, Threshold, MappingType, BasicGaugeColor, ValueMap, RangeMap } from '../../types/panel'; +import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel'; import { TimeSeriesVMs } from '../../types/series'; -import { getValueFormat } from '../../utils/valueFormats/valueFormats'; import { GrafanaTheme } from '../../types'; +import { getValueFormat } from '../../utils/valueFormats/valueFormats'; import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; type TimeSeriesValue = string | number | null; @@ -52,70 +52,6 @@ export class Gauge extends PureComponent { this.draw(); } - addValueToTextMappingText(allValueMappings: ValueMapping[], valueToTextMapping: ValueMap, value: TimeSeriesValue) { - if (!valueToTextMapping.value) { - return allValueMappings; - } - - const valueAsNumber = parseFloat(value as string); - const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); - - if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) { - return allValueMappings; - } - - if (valueAsNumber !== valueToTextMappingAsNumber) { - return allValueMappings; - } - - return allValueMappings.concat(valueToTextMapping); - } - - addRangeToTextMappingText(allValueMappings: ValueMapping[], rangeToTextMapping: RangeMap, value: TimeSeriesValue) { - if (!rangeToTextMapping.from || !rangeToTextMapping.to || !value) { - return allValueMappings; - } - - const valueAsNumber = parseFloat(value as string); - const fromAsNumber = parseFloat(rangeToTextMapping.from as string); - const toAsNumber = parseFloat(rangeToTextMapping.to as string); - - if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) { - return allValueMappings; - } - - if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) { - return allValueMappings.concat(rangeToTextMapping); - } - - return allValueMappings; - } - - getAllFormattedValueMappings(valueMappings: ValueMapping[], value: TimeSeriesValue) { - const allFormattedValueMappings = valueMappings.reduce( - (allValueMappings, valueMapping) => { - if (valueMapping.type === MappingType.ValueToText) { - allValueMappings = this.addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); - } else if (valueMapping.type === MappingType.RangeToText) { - allValueMappings = this.addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); - } - - return allValueMappings; - }, - [] as ValueMapping[] - ); - - allFormattedValueMappings.sort((t1, t2) => { - return t1.id - t2.id; - }); - - return allFormattedValueMappings; - } - - getFirstFormattedValueMapping(valueMappings: ValueMapping[], value: TimeSeriesValue) { - return this.getAllFormattedValueMappings(valueMappings, value)[0]; - } - formatValue(value: TimeSeriesValue) { const { decimals, valueMappings, prefix, suffix, unit } = this.props; @@ -124,7 +60,7 @@ export class Gauge extends PureComponent { } if (valueMappings.length > 0) { - const valueMappedValue = this.getFirstFormattedValueMapping(valueMappings, value); + const valueMappedValue = getMappedValue(valueMappings, value); if (valueMappedValue) { return `${prefix} ${valueMappedValue.text} ${suffix}`; } @@ -132,8 +68,9 @@ export class Gauge extends PureComponent { const formatFunc = getValueFormat(unit); const formattedValue = formatFunc(value as number, decimals); + const handleNoValueValue = formattedValue || 'no value'; - return `${prefix} ${formattedValue} ${suffix}`; + return `${prefix} ${handleNoValueValue} ${suffix}`; } getFontColor(value: TimeSeriesValue) { @@ -197,7 +134,7 @@ export class Gauge extends PureComponent { if (timeSeries[0]) { value = timeSeries[0].stats[stat]; } else { - value = 'N/A'; + value = null; } const dimension = Math.min(width, height * 1.3); diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index 5246c7cbf15..6d83968d546 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -61,7 +61,7 @@ interface AsyncProps { export const MenuList = (props: any) => { return ( - {props.children} + {props.children} ); }; diff --git a/packages/grafana-ui/src/utils/valueMappings.test.ts b/packages/grafana-ui/src/utils/valueMappings.test.ts new file mode 100644 index 00000000000..d37e0beedab --- /dev/null +++ b/packages/grafana-ui/src/utils/valueMappings.test.ts @@ -0,0 +1,81 @@ +import { getMappedValue } from './valueMappings'; +import { ValueMapping, MappingType } from '../types/panel'; + +describe('Format value with value mappings', () => { + it('should return undefined with no valuemappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '10'; + + expect(getMappedValue(valueMappings, value)).toBeUndefined(); + }); + + it('should return undefined with no matching valuemappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value)).toBeUndefined(); + }); + + it('should return first matching mapping with lowest id', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); + }); + + it('should return if value is null and value to text mapping value is null', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: '', type: MappingType.ValueToText, value: 'null' }, + ]; + const value = null; + + expect(getMappedValue(valueMappings, value).text).toEqual(''); + }); + + it('should return if value is null and range to text mapping from and to is null', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '', type: MappingType.RangeToText, from: 'null', to: 'null' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = null; + + expect(getMappedValue(valueMappings, value).text).toEqual(''); + }); + + it('should return rangeToText mapping where value equals to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-10'); + }); + + it('should return rangeToText mapping where value equals from', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('10-20'); + }); + + it('should return rangeToText mapping where value is between from and to', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '10'; + + expect(getMappedValue(valueMappings, value).text).toEqual('1-20'); + }); +}); diff --git a/packages/grafana-ui/src/utils/valueMappings.ts b/packages/grafana-ui/src/utils/valueMappings.ts new file mode 100644 index 00000000000..c9b926ea0a4 --- /dev/null +++ b/packages/grafana-ui/src/utils/valueMappings.ts @@ -0,0 +1,89 @@ +import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types'; + +export type TimeSeriesValue = string | number | null; + +const addValueToTextMappingText = ( + allValueMappings: ValueMapping[], + valueToTextMapping: ValueMap, + value: TimeSeriesValue +) => { + if (valueToTextMapping.value === undefined) { + return allValueMappings; + } + + if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') { + return allValueMappings.concat(valueToTextMapping); + } + + const valueAsNumber = parseFloat(value as string); + const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string); + + if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) { + return allValueMappings; + } + + if (valueAsNumber !== valueToTextMappingAsNumber) { + return allValueMappings; + } + + return allValueMappings.concat(valueToTextMapping); +}; + +const addRangeToTextMappingText = ( + allValueMappings: ValueMapping[], + rangeToTextMapping: RangeMap, + value: TimeSeriesValue +) => { + if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) { + return allValueMappings; + } + + if ( + value === null && + rangeToTextMapping.from && + rangeToTextMapping.to && + rangeToTextMapping.from.toLowerCase() === 'null' && + rangeToTextMapping.to.toLowerCase() === 'null' + ) { + return allValueMappings.concat(rangeToTextMapping); + } + + const valueAsNumber = parseFloat(value as string); + const fromAsNumber = parseFloat(rangeToTextMapping.from as string); + const toAsNumber = parseFloat(rangeToTextMapping.to as string); + + if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) { + return allValueMappings; + } + + if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) { + return allValueMappings.concat(rangeToTextMapping); + } + + return allValueMappings; +}; + +const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => { + const allFormattedValueMappings = valueMappings.reduce( + (allValueMappings, valueMapping) => { + if (valueMapping.type === MappingType.ValueToText) { + allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value); + } else if (valueMapping.type === MappingType.RangeToText) { + allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value); + } + + return allValueMappings; + }, + [] as ValueMapping[] + ); + + allFormattedValueMappings.sort((t1, t2) => { + return t1.id - t2.id; + }); + + return allFormattedValueMappings; +}; + +export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => { + return getAllFormattedValueMappings(valueMappings, value)[0]; +}; diff --git a/pkg/api/api.go b/pkg/api/api.go index 0526ee80afe..07cb712f794 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() { // not logged in views r.Get("/", reqSignedIn, hs.Index) - r.Get("/logout", Logout) - r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) - r.Get("/login/:name", quota("session"), OAuthLogin) + r.Get("/logout", hs.Logout) + r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost)) + r.Get("/login/:name", quota("session"), hs.OAuthLogin) r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) - r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) + r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2)) // invited r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode)) - r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) + r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite)) // reset password r.Get("/user/password/send-reset-email", hs.Index) @@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() { r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) // api renew session based on remember cookie - r.Get("/api/login/ping", quota("session"), LoginAPIPing) + r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing) // authed api r.Group("/api", func(apiRoute routing.RouteRegister) { diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 8b66a7a468b..eb1f89e3f22 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -5,7 +5,6 @@ import ( "net/http/httptest" "path/filepath" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string + userAuthTokenService *fakeUserAuthTokenService } func (sc *scenarioContext) exec() { @@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{}, 0)) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService)) return sc } + +type fakeUserAuthTokenService struct { + initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool +} + +func newFakeUserAuthTokenService() *fakeUserAuthTokenService { + return &fakeUserAuthTokenService{ + initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { + return false + }, + } +} + +func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { + return s.initContextWithTokenProvider(ctx, orgID) +} + +func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { + return nil +} + +func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 6d6cc708496..ed7054050e4 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -166,6 +166,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, "viewersCanEdit": setting.ViewersCanEdit, + "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, "commit": setting.BuildCommit, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index d4d7b41bec5..7b7c1478a4c 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -11,14 +11,8 @@ import ( "path" "time" - "github.com/grafana/grafana/pkg/api/routing" - "github.com/prometheus/client_golang/prometheus" - - "github.com/prometheus/client_golang/prometheus/promhttp" - - macaron "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/api/live" + "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -27,11 +21,16 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + macaron "gopkg.in/macaron.v1" ) func init() { @@ -49,13 +48,14 @@ type HTTPServer struct { streamManager *live.StreamManager httpSrv *http.Server - RouteRegister routing.RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` - HooksService *hooks.HooksService `inject:""` - CacheService *cache.CacheService `inject:""` - DatasourceCache datasources.CacheService `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Bus bus.Bus `inject:""` + RenderService rendering.Service `inject:""` + Cfg *setting.Cfg `inject:""` + HooksService *hooks.HooksService `inject:""` + CacheService *cache.CacheService `inject:""` + DatasourceCache datasources.CacheService `inject:""` + AuthTokenService auth.UserAuthTokenService `inject:""` } func (hs *HTTPServer) Init() error { @@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error { hs.macaron = hs.newMacaron() hs.registerRoutes() + session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime) + return nil } @@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.Use(hs.healthHandler) m.Use(hs.metricsEndpoint) - m.Use(middleware.GetContextHandler()) - m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime)) + m.Use(middleware.GetContextHandler(hs.AuthTokenService)) m.Use(middleware.OrgRedirect()) // needs to be after context handler diff --git a/pkg/api/login.go b/pkg/api/login.go index 05afc40e59a..50c62e0835a 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -1,6 +1,8 @@ package api import ( + "encoding/hex" + "net/http" "net/url" "github.com/grafana/grafana/pkg/api/dtos" @@ -9,12 +11,13 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) const ( - ViewIndex = "index" + ViewIndex = "index" + LoginErrorCookieName = "login_error" ) func (hs *HTTPServer) LoginView(c *m.ReqContext) { @@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["disableLoginForm"] = setting.DisableLoginForm - if loginError, ok := c.Session.Get("loginError").(string); ok { - c.Session.Delete("loginError") + if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { + deleteCookie(c, LoginErrorCookieName) viewData.Settings["loginError"] = loginError } @@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { return } - if !tryLoginUsingRememberCookie(c) { + if !c.IsSignedIn { c.HTML(200, ViewIndex, viewData) return } @@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool { return false } -func tryLoginUsingRememberCookie(c *m.ReqContext) bool { - // Check auto-login. - uname := c.GetCookie(setting.CookieUserName) - if len(uname) == 0 { - return false +func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response { + if c.IsSignedIn || c.IsAnonymous { + return JSON(200, "Logged in") } - isSucceed := false - defer func() { - if !isSucceed { - log.Trace("auto-login cookie cleared: %s", uname) - c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") - c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/") - return - } - }() - - userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname} - if err := bus.Dispatch(&userQuery); err != nil { - return false - } - - user := userQuery.Result - - // validate remember me cookie - signingKey := user.Rands + user.Password - if len(signingKey) < 10 { - c.Logger.Error("Invalid user signingKey") - return false - } - - if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login { - return false - } - - isSucceed = true - loginUserWithUser(user, c) - return true + return Error(401, "Unauthorized", nil) } -func LoginAPIPing(c *m.ReqContext) { - if !tryLoginUsingRememberCookie(c) { - c.JsonApiErr(401, "Unauthorized", nil) - return - } - - c.JsonOK("Logged in") -} - -func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { +func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { if setting.DisableLoginForm { return Error(401, "Login is disabled", nil) } @@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { user := authQuery.User - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) result := map[string]interface{}{ "message": "Logged in", @@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response { return JSON(200, result) } -func loginUserWithUser(user *m.User, c *m.ReqContext) { +func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { if user == nil { - log.Error(3, "User login with nil user") + hs.log.Error("User login with nil user") } - c.Resp.Header().Del("Set-Cookie") - - days := 86400 * setting.LogInRememberDays - if days > 0 { - c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/") - c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/") + err := hs.AuthTokenService.UserAuthenticatedHook(user, c) + if err != nil { + hs.log.Error("User auth hook failed", "error", err) } - - c.Session.RegenerateId(c.Context) - c.Session.Set(session.SESS_KEY_USERID, user.Id) } -func Logout(c *m.ReqContext) { - c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") - c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/") - c.Session.Destory(c.Context) +func (hs *HTTPServer) Logout(c *m.ReqContext) { + hs.AuthTokenService.UserSignedOutHook(c) + if setting.SignoutRedirectUrl != "" { c.Redirect(setting.SignoutRedirectUrl) } else { c.Redirect(setting.AppSubUrl + "/login") } } + +func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) { + cookie := ctx.GetCookie(cookieName) + if cookie == "" { + return "", false + } + + decoded, err := hex.DecodeString(cookie) + if err != nil { + return "", false + } + + decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey) + return string(decryptedError), err == nil +} + +func deleteCookie(ctx *m.ReqContext, cookieName string) { + ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/") +} + +func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error { + encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey) + if err != nil { + return err + } + + http.SetCookie(ctx.Resp, &http.Cookie{ + Name: cookieName, + MaxAge: 60, + Value: hex.EncodeToString(encryptedError), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.SecurityHTTPSCookies, + }) + + return nil +} diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index fe4fa93b621..4160d48733e 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -3,9 +3,11 @@ package api import ( "context" "crypto/rand" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "fmt" "io/ioutil" "net/http" @@ -18,12 +20,14 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/social" ) -var oauthLogger = log.New("oauth") +var ( + oauthLogger = log.New("oauth") + OauthStateCookieName = "oauth_state" +) func GenStateString() string { rnd := make([]byte, 32) @@ -31,7 +35,7 @@ func GenStateString() string { return base64.URLEncoding.EncodeToString(rnd) } -func OAuthLogin(ctx *m.ReqContext) { +func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) { if setting.OAuthService == nil { ctx.Handle(404, "OAuth not enabled", nil) return @@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) { if errorParam != "" { errorDesc := ctx.Query("error_description") oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) - redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) + hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) return } code := ctx.Query("code") if code == "" { state := GenStateString() - ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state) + hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret) + hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60) if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) } else { @@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) { return } - savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string) - if !ok { + cookieState := ctx.GetCookie(OauthStateCookieName) + + // delete cookie + ctx.Resp.Header().Del("Set-Cookie") + hs.deleteCookie(ctx.Resp, OauthStateCookieName) + + if cookieState == "" { ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) return } - queryState := ctx.Query("state") - if savedState != queryState { + queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret) + oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState) + if cookieState != queryState { ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) return } @@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) { userInfo, err := connect.UserInfo(client, token) if err != nil { if sErr, ok := err.(*social.Error); ok { - redirectWithError(ctx, sErr) + hs.redirectWithError(ctx, sErr) } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } @@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) { // validate that we got at least an email address if userInfo.Email == "" { - redirectWithError(ctx, login.ErrNoEmail) + hs.redirectWithError(ctx, login.ErrNoEmail) return } // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { - redirectWithError(ctx, login.ErrEmailNotAllowed) + hs.redirectWithError(ctx, login.ErrEmailNotAllowed) return } @@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) { ExternalUser: extUser, SignupAllowed: connect.IsSignupAllowed(), } + err = bus.Dispatch(cmd) if err != nil { - redirectWithError(ctx, err) + hs.redirectWithError(ctx, err) return } // login - loginUserWithUser(cmd.Result, ctx) + hs.loginUserWithUser(cmd.Result, ctx) metrics.M_Api_Login_OAuth.Inc() @@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) { ctx.Redirect(setting.AppSubUrl + "/") } -func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { +func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) { + hs.writeCookie(w, name, "", -1) +} + +func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: name, + MaxAge: maxAge, + Value: value, + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.SecurityHTTPSCookies, + }) +} + +func hashStatecode(code, seed string) string { + hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed)) + return hex.EncodeToString(hashBytes[:]) +} + +func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { ctx.Logger.Error(err.Error(), v...) - ctx.Session.Set("loginError", err.Error()) + hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60) + ctx.Redirect(setting.AppSubUrl + "/login") } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index dfb2cf045ed..835b03a2cc9 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response { }) } -func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { +func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} if err := bus.Dispatch(&query); err != nil { @@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res return rsp } - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpInvite.Inc() diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 200a3ebc9d1..fe577dd9ef9 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response { return JSON(200, util.DynMap{"status": "SignUpCreated"}) } -func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { +func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { if !setting.AllowUserSignUp { return Error(401, "User signup is disabled", nil) } @@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { apiResponse["code"] = "redirect-to-select-org" } - loginUserWithUser(user, c) + hs.loginUserWithUser(user, c) metrics.M_Api_User_SignUpCompleted.Inc() return JSON(200, apiResponse) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 5faee1e3fa7..27248342c8d 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -7,7 +7,6 @@ import ( "gopkg.in/macaron.v1" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -17,16 +16,6 @@ type AuthOptions struct { ReqSignedIn bool } -func getRequestUserId(c *m.ReqContext) int64 { - userID := c.Session.Get(session.SESS_KEY_USERID) - - if userID != nil { - return userID.(int64) - } - - return 0 -} - func getApiKey(c *m.ReqContext) string { header := c.Req.Header.Get("Authorization") parts := strings.SplitN(header, " ", 2) diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go index fc109ac707f..93ee577e3c6 100644 --- a/pkg/middleware/auth_proxy.go +++ b/pkg/middleware/auth_proxy.go @@ -16,7 +16,9 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" +var ( + AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" +) func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { if !setting.AuthProxyEnabled { @@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { return false } + defer func() { + if err := ctx.Session.Release(); err != nil { + ctx.Logger.Error("failed to save session data", "error", err) + } + }() + query := &m.GetSignedInUserQuery{OrgId: orgID} // if this session has already been authenticated by authProxy just load the user @@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error { return nil } +func getRequestUserId(c *m.ReqContext) int64 { + userID := c.Session.Get(session.SESS_KEY_USERID) + + if userID != nil { + return userID.(int64) + } + + return 0 +} + func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error { if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { return nil diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index ace72d998eb..3722ac3058f 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -3,15 +3,15 @@ package middleware import ( "strconv" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + macaron "gopkg.in/macaron.v1" ) var ( @@ -21,12 +21,12 @@ var ( ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ) -func GetContextHandler() macaron.Handler { +func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ Context: c, SignedInUser: &m.SignedInUser{}, - Session: session.GetSession(), + Session: session.GetSession(), // should only be used by auth_proxy IsSignedIn: false, AllowAnonymous: false, SkipCache: false, @@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler { case initContextWithApiKey(ctx): case initContextWithBasicAuth(ctx, orgId): case initContextWithAuthProxy(ctx, orgId): - case initContextWithUserSessionCookie(ctx, orgId): + case ats.InitContextWithToken(ctx, orgId): case initContextWithAnonymousUser(ctx): } @@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool { return true } -func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool { - // initialize session - if err := ctx.Session.Start(ctx.Context); err != nil { - ctx.Logger.Error("Failed to start session", "error", err) - return false - } - - var userId int64 - if userId = getRequestUserId(ctx); userId == 0 { - return false - } - - query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId} - if err := bus.Dispatch(&query); err != nil { - ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err) - return false - } - - ctx.SignedInUser = query.Result - ctx.IsSignedIn = true - return true -} - func initContextWithApiKey(ctx *m.ReqContext) bool { var keyString string if keyString = getApiKey(ctx); keyString == "" { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index b9a8afce6c6..11740574d0b 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - ms "github.com/go-macaron/session" + msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/session" @@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) { So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty) }) - middlewareScenario("Non api request should init session", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").exec() - So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess") - }) - middlewareScenario("Invalid api key", func(sc *scenarioContext) { sc.apiKey = "invalid_key_test" sc.fakeReq("GET", "/").exec() @@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) { }) }) - middlewareScenario("UserId in session", func(sc *scenarioContext) { - - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) + middlewareScenario("Auth token service", func(sc *scenarioContext) { + var wasCalled bool + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + wasCalled = true + return false + } sc.fakeReq("GET", "/").exec() - Convey("should init context with user info", func() { - So(sc.context.IsSignedIn, ShouldBeTrue) - So(sc.context.UserId, ShouldEqual, 12) + Convey("should call middleware", func() { + So(wasCalled, ShouldBeTrue) }) }) @@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) + setting.SessionOptions = msession.Options{} sc.fakeReq("GET", "/") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.exec() @@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { defer bus.ClearBusHandlers() sc := &scenarioContext{} + viewsPath, _ := filepath.Abs("../../public/views") sc.m = macaron.New() @@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(GetContextHandler()) + session.Init(&msession.Options{}, 0) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} - sc.m.Use(Sessioner(&ms.Options{}, 0)) + setting.SessionOptions = msession.Options{} + sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) @@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) { } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - apiKey string - authHeader string - respJson map[string]interface{} - handlerFunc handlerFunc - defaultHandler macaron.Handler - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + apiKey string + authHeader string + respJson map[string]interface{} + handlerFunc handlerFunc + defaultHandler macaron.Handler + url string + userAuthTokenService *fakeUserAuthTokenService req *http.Request } @@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) + +type fakeUserAuthTokenService struct { + initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool +} + +func newFakeUserAuthTokenService() *fakeUserAuthTokenService { + return &fakeUserAuthTokenService{ + initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { + return false + }, + } +} + +func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { + return s.initContextWithTokenProvider(ctx, orgID) +} + +func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { + return nil +} + +func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {} diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index db263c2a17a..ca63733946c 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - "gopkg.in/macaron.v1" ) diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index fa08154b250..46b8776fdcc 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" . "github.com/smartystreets/goconvey/convey" ) @@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) { Convey("Can redirect to correct org", t, func() { middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return nil }) - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} - return nil - }) + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} + ctx.IsSignedIn = true + return true + } sc.m.Get("/", sc.defaultHandler) sc.fakeReq("GET", "/?orgId=3").exec() @@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) { }) middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() - bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return fmt.Errorf("") }) + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} + ctx.IsSignedIn = true + return true + } + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} return nil diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 92c3d62674d..4f2203a5d3d 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) { }) middlewareScenario("with user logged in", func(sc *scenarioContext) { - // log us in, so we have a user_id and org_id in the context - sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { - c.Session.Set(session.SESS_KEY_USERID, int64(12)) - }).exec() + sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { + ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12} + ctx.IsSignedIn = true + return true + } - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} - return nil - }) bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { query.Result = &m.GlobalQuotaDTO{ Target: query.Target, diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index c92150f3b7d..e041d42e56b 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -4,13 +4,12 @@ import ( "path/filepath" "testing" - ms "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" - "gopkg.in/macaron.v1" + macaron "gopkg.in/macaron.v1" ) func TestRecoveryMiddleware(t *testing.T) { @@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.m.Use(GetContextHandler()) + sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} - sc.m.Use(Sessioner(&ms.Options{}, 0)) sc.m.Use(OrgRedirect()) sc.m.Use(AddDefaultResponseHeaders()) diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go deleted file mode 100644 index 19cfa368b49..00000000000 --- a/pkg/middleware/session.go +++ /dev/null @@ -1,21 +0,0 @@ -package middleware - -import ( - ms "github.com/go-macaron/session" - "gopkg.in/macaron.v1" - - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/session" -) - -func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler { - session.Init(options, sessionConnMaxLifetime) - - return func(ctx *m.ReqContext) { - ctx.Next() - - if err := ctx.Session.Release(); err != nil { - panic("session(release): " + err.Error()) - } - } -} diff --git a/pkg/models/context.go b/pkg/models/context.go index 7cb80a957c3..df970451304 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -3,18 +3,18 @@ package models import ( "strings" - "github.com/prometheus/client_golang/prometheus" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "gopkg.in/macaron.v1" ) type ReqContext struct { *macaron.Context *SignedInUser + // This should only be used by the auth_proxy Session session.SessionStore IsSignedIn bool diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index 0f8e24bcef5..22cbe2456b7 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error { var ( unfinishedWorkTimeout = time.Second * 5 // TODO: Make alertTimeout and alertMaxAttempts configurable in the config file. - alertTimeout = time.Second * 30 - alertMaxAttempts = 3 + alertTimeout = time.Second * 30 + resultHandleTimeout = time.Second * 30 + alertMaxAttempts = 3 ) func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error { @@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J } }() - cancelChan := make(chan context.CancelFunc, alertMaxAttempts) + cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2) attemptChan := make(chan int, 1) // Initialize with first attemptID=1 @@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel } } + // create new context with timeout for notifications + resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout) + cancelChan <- resultHandleCancelFn + + // override the context used for evaluation with a new context for notifications. + // This makes it possible for notifiers to execute when datasources + // dont respond within the timeout limit. We should rewrite this so notifications + // dont reuse the evalContext and get its own context. + evalContext.Ctx = resultHandleCtx evalContext.Rule.State = evalContext.GetNewState() e.resultHandler.Handle(evalContext) span.Finish() diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go new file mode 100644 index 00000000000..aa518baae24 --- /dev/null +++ b/pkg/services/alerting/engine_integration_test.go @@ -0,0 +1,148 @@ +// +build integration + +package alerting + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestEngineTimeouts(t *testing.T) { + Convey("Alerting engine timeout tests", t, func() { + engine := NewEngine() + engine.resultHandler = &FakeResultHandler{} + job := &Job{Running: true, Rule: &Rule{}} + + Convey("Should trigger as many retries as needed", func() { + Convey("pended alert for datasource -> result handler should be worked", func() { + // reduce alert timeout to test quickly + originAlertTimeout := alertTimeout + alertTimeout = 2 * time.Second + transportTimeoutInterval := 2 * time.Second + serverBusySleepDuration := 1 * time.Second + + evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) + resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) + engine.evalHandler = evalHandler + engine.resultHandler = resultHandler + + engine.processJobWithRetry(context.TODO(), job) + + So(evalHandler.EvalSucceed, ShouldEqual, true) + So(resultHandler.ResultHandleSucceed, ShouldEqual, true) + + // initialize for other tests. + alertTimeout = originAlertTimeout + engine.resultHandler = &FakeResultHandler{} + }) + }) + }) +} + +type FakeCommonTimeoutHandler struct { + TransportTimeoutDuration time.Duration + ServerBusySleepDuration time.Duration + EvalSucceed bool + ResultHandleSucceed bool +} + +func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler { + return &FakeCommonTimeoutHandler{ + TransportTimeoutDuration: transportTimeoutDuration, + ServerBusySleepDuration: serverBusySleepDuration, + EvalSucceed: false, + ResultHandleSucceed: false, + } +} + +func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) { + // 1. prepare mock server + path := "/evaltimeout" + srv := runBusyServer(path, handler.ServerBusySleepDuration) + defer srv.Close() + + // 2. send requests + url := srv.URL + path + res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) + if res != nil { + defer res.Body.Close() + } + + if err != nil { + evalContext.Error = errors.New("Fake evaluation timeout test failure") + return + } + + if res.StatusCode == 200 { + handler.EvalSucceed = true + } + + evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response") +} + +func (handler *FakeCommonTimeoutHandler) Handle(evalContext *EvalContext) error { + // 1. prepare mock server + path := "/resulthandle" + srv := runBusyServer(path, handler.ServerBusySleepDuration) + defer srv.Close() + + // 2. send requests + url := srv.URL + path + res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration) + if res != nil { + defer res.Body.Close() + } + + if err != nil { + evalContext.Error = errors.New("Fake result handle timeout test failure") + return evalContext.Error + } + + if res.StatusCode == 200 { + handler.ResultHandleSucceed = true + return nil + } + + evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response") + + return evalContext.Error +} + +func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(serverBusySleepDuration) + }) + + return server +} + +func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req = req.WithContext(context) + + transport := http.Transport{ + Dial: (&net.Dialer{ + Timeout: transportTimeoutInterval, + KeepAlive: transportTimeoutInterval, + }).Dial, + } + client := http.Client{ + Transport: &transport, + } + + return client.Do(req) +} diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go new file mode 100644 index 00000000000..7e9433c2d70 --- /dev/null +++ b/pkg/services/auth/auth_token.go @@ -0,0 +1,266 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/url" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +func init() { + registry.RegisterService(&UserAuthTokenServiceImpl{}) +} + +var ( + getTime = time.Now + UrgentRotateTime = 1 * time.Minute + oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often. +) + +// UserAuthTokenService are used for generating and validating user auth tokens +type UserAuthTokenService interface { + InitContextWithToken(ctx *models.ReqContext, orgID int64) bool + UserAuthenticatedHook(user *models.User, c *models.ReqContext) error + UserSignedOutHook(c *models.ReqContext) +} + +type UserAuthTokenServiceImpl struct { + SQLStore *sqlstore.SqlStore `inject:""` + ServerLockService *serverlock.ServerLockService `inject:""` + Cfg *setting.Cfg `inject:""` + log log.Logger +} + +// Init this service +func (s *UserAuthTokenServiceImpl) Init() error { + s.log = log.New("auth") + return nil +} + +func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool { + //auth User + unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName) + if unhashedToken == "" { + return false + } + + userToken, err := s.LookupToken(unhashedToken) + if err != nil { + ctx.Logger.Info("failed to look up user based on cookie", "error", err) + return false + } + + query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID} + if err := bus.Dispatch(&query); err != nil { + ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err) + return false + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + + //rotate session token if needed. + rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent()) + if err != nil { + ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id) + return true + } + + if rotated { + s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds) + } + + return true +} + +func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) { + if setting.Env == setting.DEV { + ctx.Logger.Info("new token", "unhashed token", value) + } + + ctx.Resp.Header().Del("Set-Cookie") + cookie := http.Cookie{ + Name: s.Cfg.LoginCookieName, + Value: url.QueryEscape(value), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: s.Cfg.SecurityHTTPSCookies, + MaxAge: maxAge, + } + + http.SetCookie(ctx.Resp, &cookie) +} + +func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error { + userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent()) + if err != nil { + return err + } + + s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds) + return nil +} + +func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) { + s.writeSessionCookie(c, "", -1) +} + +func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) { + clientIP = util.ParseIPAddress(clientIP) + token, err := util.RandomHex(16) + if err != nil { + return nil, err + } + + hashedToken := hashToken(token) + + now := getTime().Unix() + + userToken := userAuthToken{ + UserId: userId, + AuthToken: hashedToken, + PrevAuthToken: hashedToken, + ClientIp: clientIP, + UserAgent: userAgent, + RotatedAt: now, + CreatedAt: now, + UpdatedAt: now, + SeenAt: 0, + AuthTokenSeen: false, + } + _, err = s.SQLStore.NewSession().Insert(&userToken) + if err != nil { + return nil, err + } + + userToken.UnhashedToken = token + + return &userToken, nil +} + +func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) { + hashedToken := hashToken(unhashedToken) + if setting.Env == setting.DEV { + s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) + } + + expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() + + var userToken userAuthToken + exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken) + if err != nil { + return nil, err + } + + if !exists { + return nil, ErrAuthTokenNotFound + } + + if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen { + userTokenCopy := userToken + userTokenCopy.AuthTokenSeen = false + expireBefore := getTime().Add(-UrgentRotateTime).Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy) + if err != nil { + return nil, err + } + + if affectedRows == 0 { + s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } else { + s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } + } + + if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken { + userTokenCopy := userToken + userTokenCopy.AuthTokenSeen = true + userTokenCopy.SeenAt = getTime().Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy) + if err != nil { + return nil, err + } + + if affectedRows == 1 { + userToken = userTokenCopy + } + + if affectedRows == 0 { + s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } else { + s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent) + } + } + + userToken.UnhashedToken = unhashedToken + + return &userToken, nil +} + +func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) { + if token == nil { + return false, nil + } + + now := getTime() + + needsRotation := false + rotatedAt := time.Unix(token.RotatedAt, 0) + if token.AuthTokenSeen { + needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute)) + } else { + needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime)) + } + + if !needsRotation { + return false, nil + } + + s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id) + + clientIP = util.ParseIPAddress(clientIP) + newToken, _ := util.RandomHex(16) + hashedToken := hashToken(newToken) + + // very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly + sql := ` + UPDATE user_auth_token + SET + seen_at = 0, + user_agent = ?, + client_ip = ?, + prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end, + auth_token = ?, + auth_token_seen = ?, + rotated_at = ? + WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)` + + res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix()) + if err != nil { + return false, err + } + + affected, _ := res.RowsAffected() + s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId) + if affected > 0 { + token.UnhashedToken = newToken + return true, nil + } + + return false, nil +} + +func hashToken(token string) string { + hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) + return hex.EncodeToString(hashBytes[:]) +} diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go new file mode 100644 index 00000000000..2f75c660d9d --- /dev/null +++ b/pkg/services/auth/auth_token_test.go @@ -0,0 +1,339 @@ +package auth + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserAuthToken(t *testing.T) { + Convey("Test user auth token", t, func() { + ctx := createTestContext(t) + userAuthTokenService := ctx.tokenService + userID := int64(10) + + t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC) + getTime = func() time.Time { + return t + } + + Convey("When creating token", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.AuthTokenSeen, ShouldBeFalse) + + Convey("When lookup unhashed token should return user auth token", func() { + LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(LookupToken, ShouldNotBeNil) + So(LookupToken.UserId, ShouldEqual, userID) + So(LookupToken.AuthTokenSeen, ShouldBeTrue) + + storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id) + So(err, ShouldBeNil) + So(storedAuthToken, ShouldNotBeNil) + So(storedAuthToken.AuthTokenSeen, ShouldBeTrue) + }) + + Convey("When lookup hashed token should return user auth token not found error", func() { + LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(LookupToken, ShouldBeNil) + }) + }) + + Convey("expires correctly", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + _, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + _, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + + stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(stillGood, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(24 * 7 * time.Hour) + } + notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(notGood, ShouldBeNil) + }) + + Convey("can properly rotate tokens", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + prevToken := token.AuthToken + unhashedPrev := token.UnhashedToken + + refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeFalse) + + updated, err := ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + unhashedToken := token.UnhashedToken + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + token.UnhashedToken = unhashedToken + + So(token.RotatedAt, ShouldEqual, getTime().Unix()) + So(token.ClientIp, ShouldEqual, "192.168.10.12") + So(token.UserAgent, ShouldEqual, "a new user agent") + So(token.AuthTokenSeen, ShouldBeFalse) + So(token.SeenAt, ShouldEqual, 0) + So(token.PrevAuthToken, ShouldEqual, prevToken) + + // ability to auth using an old token + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + So(lookedUp.SeenAt, ShouldEqual, getTime().Unix()) + + lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.Id, ShouldEqual, token.Id) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(time.Hour + (2 * time.Minute)) + } + + lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + + lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeFalse) + + refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + token, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.SeenAt, ShouldEqual, 0) + }) + + Convey("keeps prev token valid for 1 minute after it is confirmed", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + prevToken := token.UnhashedToken + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + current, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(current, ShouldNotBeNil) + + prev, err := userAuthTokenService.LookupToken(prevToken) + So(err, ShouldBeNil) + So(prev, ShouldNotBeNil) + }) + + Convey("will not mark token unseen when prev and current are the same", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + + lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id) + So(err, ShouldBeNil) + So(lookedUp, ShouldNotBeNil) + So(lookedUp.AuthTokenSeen, ShouldBeTrue) + }) + + Convey("Rotate token", func() { + token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + + prevToken := token.AuthToken + + Convey("Should rotate current token and previous token when auth token seen", func() { + updated, err := ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + + prevToken = storedToken.AuthToken + + updated, err = ctx.markAuthTokenAsSeen(token.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err = ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + }) + + Convey("Should rotate current token, but keep previous token when auth token not seen", func() { + token.RotatedAt = getTime().Add(-2 * time.Minute).Unix() + + getTime = func() time.Time { + return t.Add(2 * time.Minute) + } + + refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(refreshed, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(token.Id) + So(err, ShouldBeNil) + So(storedToken, ShouldNotBeNil) + So(storedToken.AuthTokenSeen, ShouldBeFalse) + So(storedToken.PrevAuthToken, ShouldEqual, prevToken) + So(storedToken.AuthToken, ShouldNotEqual, prevToken) + }) + }) + + Reset(func() { + getTime = time.Now + }) + }) +} + +func createTestContext(t *testing.T) *testContext { + t.Helper() + + sqlstore := sqlstore.InitTestDB(t) + tokenService := &UserAuthTokenServiceImpl{ + SQLStore: sqlstore, + Cfg: &setting.Cfg{ + LoginCookieName: "grafana_session", + LoginCookieMaxDays: 7, + LoginDeleteExpiredTokensAfterDays: 30, + LoginCookieRotation: 10, + }, + log: log.New("test-logger"), + } + + UrgentRotateTime = time.Minute + + return &testContext{ + sqlstore: sqlstore, + tokenService: tokenService, + } +} + +type testContext struct { + sqlstore *sqlstore.SqlStore + tokenService *UserAuthTokenServiceImpl +} + +func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) { + sess := c.sqlstore.NewSession() + var t userAuthToken + found, err := sess.ID(id).Get(&t) + if err != nil || !found { + return nil, err + } + + return &t, nil +} + +func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) { + sess := c.sqlstore.NewSession() + res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id) + if err != nil { + return false, err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return false, err + } + return rowsAffected == 1, nil +} diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go new file mode 100644 index 00000000000..7a0f49539f2 --- /dev/null +++ b/pkg/services/auth/model.go @@ -0,0 +1,25 @@ +package auth + +import ( + "errors" +) + +// Typed errors +var ( + ErrAuthTokenNotFound = errors.New("User auth token not found") +) + +type userAuthToken struct { + Id int64 + UserId int64 + AuthToken string + PrevAuthToken string + UserAgent string + ClientIp string + AuthTokenSeen bool + SeenAt int64 + RotatedAt int64 + CreatedAt int64 + UpdatedAt int64 + UnhashedToken string `xorm:"-"` +} diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go new file mode 100644 index 00000000000..7e523181a7b --- /dev/null +++ b/pkg/services/auth/session_cleanup.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "time" +) + +func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { + ticker := time.NewTicker(time.Hour * 12) + deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays) + + for { + select { + case <-ticker.C: + srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() { + srv.deleteOldSession(deleteSessionAfter) + }) + + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) { + sql := `DELETE from user_auth_token WHERE rotated_at < ?` + + deleteBefore := getTime().Add(-deleteSessionAfter) + res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix()) + if err != nil { + return 0, err + } + + affected, err := res.RowsAffected() + srv.log.Info("deleted old sessions", "count", affected) + + return affected, err +} diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go new file mode 100644 index 00000000000..eef2cd74d04 --- /dev/null +++ b/pkg/services/auth/session_cleanup_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "fmt" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserAuthTokenCleanup(t *testing.T) { + + Convey("Test user auth token cleanup", t, func() { + ctx := createTestContext(t) + + insertToken := func(token string, prev string, rotatedAt int64) { + ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""} + _, err := ctx.sqlstore.NewSession().Insert(&ut) + So(err, ShouldBeNil) + } + + // insert three old tokens that should be deleted + for i := 0; i < 3; i++ { + insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i)) + } + + // insert three active tokens that should not be deleted + for i := 0; i < 3; i++ { + insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix()) + } + + affected, err := ctx.tokenService.deleteOldSession(time.Hour) + So(err, ShouldBeNil) + So(affected, ShouldEqual, 3) + }) +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 59ceefa0be5..33f418cbee3 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, User: dto.User, } - if err := bus.Dispatch(&alertCmd); err != nil { - return err - } - - return nil + return bus.Dispatch(&alertCmd) } func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go index 5873a6a5b72..2e60b8a25d7 100644 --- a/pkg/services/session/session.go +++ b/pkg/services/session/session.go @@ -14,8 +14,6 @@ import ( const ( SESS_KEY_USERID = "uid" - SESS_KEY_OAUTH_STATE = "state" - SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys SESS_KEY_LASTLDAPSYNC = "last_ldap_sync" ) diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 36cd8e5ed62..931259ec3ed 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) { addLoginAttemptMigrations(mg) addUserAuthMigrations(mg) addServerlockMigrations(mg) + addUserAuthTokenMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/user_auth_token_mig.go b/pkg/services/sqlstore/migrations/user_auth_token_mig.go new file mode 100644 index 00000000000..9794b7a78c7 --- /dev/null +++ b/pkg/services/sqlstore/migrations/user_auth_token_mig.go @@ -0,0 +1,32 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addUserAuthTokenMigrations(mg *Migrator) { + userAuthTokenV1 := Table{ + Name: "user_auth_token", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "user_id", Type: DB_BigInt, Nullable: false}, + {Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "auth_token_seen", Type: DB_Bool, Nullable: false}, + {Name: "seen_at", Type: DB_Int, Nullable: true}, + {Name: "rotated_at", Type: DB_Int, Nullable: false}, + {Name: "created_at", Type: DB_Int, Nullable: false}, + {Name: "updated_at", Type: DB_Int, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"auth_token"}, Type: UniqueIndex}, + {Cols: []string{"prev_auth_token"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create user auth token table", NewAddTableMigration(userAuthTokenV1)) + mg.AddMigration("add unique index user_auth_token.auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[0])) + mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1])) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1417392fdf8..d1eca777004 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -18,7 +18,7 @@ import ( "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" - "gopkg.in/ini.v1" + ini "gopkg.in/ini.v1" ) type Scheme string @@ -83,9 +83,6 @@ var ( // Security settings. SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string DisableGravatar bool EmailCodeValidMinutes int DataProxyWhiteList map[string]bool @@ -222,7 +219,15 @@ type Cfg struct { MetricsEndpointBasicAuthUsername string MetricsEndpointBasicAuthPassword string EnableAlphaPanels bool + DisableSanitizeHtml bool EnterpriseLicensePath string + + LoginCookieName string + LoginCookieMaxDays int + LoginCookieRotation int + LoginDeleteExpiredTokensAfterDays int + + SecurityHTTPSCookies bool } type CommandLineArgs struct { @@ -546,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { ApplicationName = APP_NAME_ENTERPRISE } + //login + login := iniFile.Section("login") + cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session") + cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7) + cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30) + cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10) + if cfg.LoginCookieRotation < 2 { + cfg.LoginCookieRotation = 2 + } + Env = iniFile.Section("").Key("app_mode").MustString("development") InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name") PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath) @@ -586,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // read security settings security := iniFile.Section("security") SecretKey = security.Key("secret_key").String() - LogInRememberDays = security.Key("login_remember_days").MustInt() - CookieUserName = security.Key("cookie_username").String() - CookieRememberName = security.Key("cookie_remember_name").String() DisableGravatar = security.Key("disable_gravatar").MustBool(true) cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) + cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false) DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection // read snapshots settings @@ -705,10 +718,11 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data") explore := iniFile.Section("explore") - ExploreEnabled = explore.Key("enabled").MustBool(false) + ExploreEnabled = explore.Key("enabled").MustBool(true) panels := iniFile.Section("panels") cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false) + cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false) cfg.readSessionConfig() cfg.readSmtpSettings() diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go index 0edb721e422..e82344d73f9 100644 --- a/pkg/util/encoding.go +++ b/pkg/util/encoding.go @@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) { return userAndPass[0], userAndPass[1], nil } + +func RandomHex(n int) (string, error) { + bytes := make([]byte, n) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go new file mode 100644 index 00000000000..d8d95ef3acd --- /dev/null +++ b/pkg/util/ip_address.go @@ -0,0 +1,29 @@ +package util + +import ( + "net" + "strings" +) + +// ParseIPAddress parses an IP address and removes port and/or IPV6 format +func ParseIPAddress(input string) string { + s := input + lastIndex := strings.LastIndex(input, ":") + + if lastIndex != -1 { + if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" { + s = input[:lastIndex] + } + } + + s = strings.Replace(s, "[", "", -1) + s = strings.Replace(s, "]", "", -1) + + ip := net.ParseIP(s) + + if ip.IsLoopback() { + return "127.0.0.1" + } + + return ip.String() +} diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go new file mode 100644 index 00000000000..fd3e3ea8587 --- /dev/null +++ b/pkg/util/ip_address_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestParseIPAddress(t *testing.T) { + Convey("Test parse ip address", t, func() { + So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140") + So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1") + So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1") + So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140") + }) +} diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts index 6f7ac67363e..8669788fa16 100644 --- a/public/app/core/actions/location.ts +++ b/public/app/core/actions/location.ts @@ -1,13 +1,17 @@ import { LocationUpdate } from 'app/types'; +export enum CoreActionTypes { + UpdateLocation = 'UPDATE_LOCATION', +} + export type Action = UpdateLocationAction; export interface UpdateLocationAction { - type: 'UPDATE_LOCATION'; + type: CoreActionTypes.UpdateLocation; payload: LocationUpdate; } export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ - type: 'UPDATE_LOCATION', + type: CoreActionTypes.UpdateLocation, payload: location, }); diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx index 4231e992b19..db2172039c6 100644 --- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx +++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx @@ -10,7 +10,9 @@ const SideMenuDropDown: FC = props => { return (
  • - {link.text} + + {link.text} +
  • {link.children && link.children.map((child, index) => { diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap index 861168c1cc3..20d0a3ef3a4 100644 --- a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap +++ b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap @@ -8,11 +8,15 @@ exports[`Render should render children 1`] = `
  • - - link - + + link + +
  • - - link - + + link + +
`; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 26f31ffcf54..395e40e914b 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -35,8 +35,9 @@ export class Settings { loginHint: any; loginError: any; viewersCanEdit: boolean; + disableSanitizeHtml: boolean; - constructor(options) { + constructor(options: Settings) { const defaults = { datasources: {}, windowTitlePrefix: 'Grafana - ', @@ -52,6 +53,7 @@ export class Settings { isEnterprise: false, }, viewersCanEdit: false, + disableSanitizeHtml: false }; _.extend(this, defaults, options); diff --git a/public/app/core/controllers/all.ts b/public/app/core/controllers/all.ts index 0dbcdf4cb28..f6a4e51bad4 100644 --- a/public/app/core/controllers/all.ts +++ b/public/app/core/controllers/all.ts @@ -1,4 +1,3 @@ -import './inspect_ctrl'; import './json_editor_ctrl'; import './login_ctrl'; import './invited_ctrl'; diff --git a/public/app/core/controllers/inspect_ctrl.ts b/public/app/core/controllers/inspect_ctrl.ts deleted file mode 100644 index d106b42da16..00000000000 --- a/public/app/core/controllers/inspect_ctrl.ts +++ /dev/null @@ -1,71 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import $ from 'jquery'; -import coreModule from '../core_module'; - -export class InspectCtrl { - /** @ngInject */ - constructor($scope, $sanitize) { - const model = $scope.inspector; - - $scope.init = function() { - $scope.editor = { index: 0 }; - - if (!model.error) { - return; - } - - if (_.isString(model.error.data)) { - $scope.response = $('
' + model.error.data + '
').text(); - } else if (model.error.data) { - if (model.error.data.response) { - $scope.response = $sanitize(model.error.data.response); - } else { - $scope.response = angular.toJson(model.error.data, true); - } - } else if (model.error.message) { - $scope.message = model.error.message; - } - - if (model.error.config && model.error.config.params) { - $scope.request_parameters = _.map(model.error.config.params, (value, key) => { - return { key: key, value: value }; - }); - } - - if (model.error.stack) { - $scope.editor.index = 3; - $scope.stack_trace = model.error.stack; - $scope.message = model.error.message; - } - - if (model.error.config && model.error.config.data) { - $scope.editor.index = 2; - - if (_.isString(model.error.config.data)) { - $scope.request_parameters = this.getParametersFromQueryString(model.error.config.data); - } else { - $scope.request_parameters = _.map(model.error.config.data, (value, key) => { - return { key: key, value: angular.toJson(value, true) }; - }); - } - } - }; - } - getParametersFromQueryString(queryString) { - const result = []; - const parameters = queryString.split('&'); - for (let i = 0; i < parameters.length; i++) { - const keyValue = parameters[i].split('='); - if (keyValue[1].length > 0) { - result.push({ - key: keyValue[0], - value: (window as any).unescape(keyValue[1]), - }); - } - } - return result; - } -} - -coreModule.controller('InspectCtrl', InspectCtrl); diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 4cf9a029a2a..a3f78e7152a 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -42,7 +42,7 @@ export interface LogSearchMatch { text: string; } -export interface LogRow { +export interface LogRowModel { duplicates?: number; entry: string; key: string; // timestamp + labels @@ -56,7 +56,7 @@ export interface LogRow { uniqueLabels?: LogsStreamLabels; } -export interface LogsLabelStat { +export interface LogLabelStatsModel { active?: boolean; count: number; proportion: number; @@ -78,7 +78,7 @@ export interface LogsMetaItem { export interface LogsModel { id: string; // Identify one logs result from another meta?: LogsMetaItem[]; - rows: LogRow[]; + rows: LogRowModel[]; series?: TimeSeries[]; } @@ -188,13 +188,13 @@ export const LogsParsers: { [name: string]: LogsParser } = { }, }; -export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] { +export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] { // Consider only rows that satisfy the matcher const rowsWithField = rows.filter(row => extractor.test(row.entry)); const rowCount = rowsWithField.length; // Get field value counts for eligible rows - const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]); + const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]); const sortedCounts = _.chain(countsByValue) .map((count, value) => ({ count, value, proportion: count / rowCount })) .sortBy('count') @@ -204,13 +204,13 @@ export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabe return sortedCounts; } -export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { +export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { // Consider only rows that have the given label const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); const rowCount = rowsWithLabel.length; // Get label value counts for eligible rows - const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]); + const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]); const sortedCounts = _.chain(countsByValue) .map((count, value) => ({ count, value, proportion: count / rowCount })) .sortBy('count') @@ -221,7 +221,7 @@ export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabe } const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; -function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean { +function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean { switch (strategy) { case LogsDedupStrategy.exact: // Exact still strips dates @@ -243,7 +243,7 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs return logs; } - const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { + const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => { const previous = result[result.length - 1]; if (index > 0 && isDuplicateRow(row, previous, strategy)) { previous.duplicates++; @@ -278,7 +278,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set) return logs; } - const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { + const filteredRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => { if (!hiddenLogLevels.has(row.logLevel)) { result.push(row); } @@ -291,7 +291,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set) }; } -export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { +export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] { // currently interval is rangeMs / resolution, which is too low for showing series as bars. // need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain // when executing queries & interval calculated and not here but this is a temporary fix. diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index a42bd813782..6b39710dcca 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,4 +1,4 @@ -import { Action } from 'app/core/actions/location'; +import { Action, CoreActionTypes } from 'app/core/actions/location'; import { LocationState } from 'app/types'; import { renderUrl } from 'app/core/utils/url'; import _ from 'lodash'; @@ -12,7 +12,7 @@ export const initialState: LocationState = { export const locationReducer = (state = initialState, action: Action): LocationState => { switch (action.type) { - case 'UPDATE_LOCATION': { + case CoreActionTypes.UpdateLocation: { const { path, routeParams } = action.payload; let query = action.payload.query || state.query; @@ -24,9 +24,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS return { url: renderUrl(path || state.path, query), path: path || state.path, - query: { - ...query, - }, + query: { ...query }, routeParams: routeParams || state.routeParams, }; } diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 9e128c449a6..989746fd067 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -236,7 +236,7 @@ export class KeybindingSrv { shareScope.dashboard = dashboard; appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/partials/shareModal.html', + src: 'public/app/features/dashboard/components/ShareModal/template.html', scope: shareScope, }); } diff --git a/public/app/core/specs/url.test.ts b/public/app/core/specs/url.test.ts index b5994488128..3b7f81494f9 100644 --- a/public/app/core/specs/url.test.ts +++ b/public/app/core/specs/url.test.ts @@ -14,3 +14,12 @@ describe('toUrlParams', () => { expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined='); }); }); + +describe('toUrlParams', () => { + it('should encode the same way as angularjs', () => { + const url = toUrlParams({ + server: ':@', + }); + expect(url).toBe('server=:@'); + }); +}); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 45b70672bc6..7a9f54a0cae 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -84,7 +84,7 @@ export async function getExploreUrl( } const exploreState = JSON.stringify(state); - url = renderUrl('/explore', { state: exploreState }); + url = renderUrl('/explore', { left: exploreState }); } return url; } diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts index 4e948116dba..427b0102c95 100644 --- a/public/app/core/utils/text.ts +++ b/public/app/core/utils/text.ts @@ -1,4 +1,5 @@ import { TextMatch } from 'app/types/explore'; +import xss from 'xss'; /** * Adapt findMatchesInText for react-highlight-words findChunks handler. @@ -22,7 +23,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] } const matches = []; const cleaned = cleanNeedle(needle); - let regexp; + let regexp: RegExp; try { regexp = new RegExp(`(?:${cleaned})`, 'g'); } catch (error) { @@ -42,3 +43,28 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] }); return matches; } + +const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => { + acc[element] = xss.whiteList[element].concat(['class', 'style']); + return acc; +}, {}); + +const sanitizeXSS = new xss.FilterXSS({ + whiteList: XSSWL +}); + +/** + * Returns string safe from XSS attacks. + * + * Even though we allow the style-attribute, there's still default filtering applied to it + * Info: https://github.com/leizongmin/js-xss#customize-css-filter + * Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js + */ +export function sanitize (unsanitizedString: string): string { + try { + return sanitizeXSS.process(unsanitizedString); + } catch (error) { + console.log('String could not be sanitized', unsanitizedString); + return unsanitizedString; + } +} diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts index ab8be8ad222..824e0e4e9c9 100644 --- a/public/app/core/utils/url.ts +++ b/public/app/core/utils/url.ts @@ -11,6 +11,16 @@ export function renderUrl(path: string, query: UrlQueryMap | undefined): string return path; } +export function encodeURIComponentAsAngularJS(val, pctEncodeSpaces) { + return encodeURIComponent(val). + replace(/%40/gi, '@'). + replace(/%3A/gi, ':'). + replace(/%24/g, '$'). + replace(/%2C/gi, ','). + replace(/%3B/gi, ';'). + replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); +} + export function toUrlParams(a) { const s = []; const rbracket = /\[\]$/; @@ -22,9 +32,9 @@ export function toUrlParams(a) { const add = (k, v) => { v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v; if (typeof v !== 'boolean') { - s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v); + s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true); } else { - s[s.length] = encodeURIComponent(k); + s[s.length] = encodeURIComponentAsAngularJS(k, true); } }; diff --git a/public/app/features/all.ts b/public/app/features/all.ts index 1ba6a85899c..83146596ea0 100644 --- a/public/app/features/all.ts +++ b/public/app/features/all.ts @@ -1,7 +1,7 @@ import './annotations/all'; import './templating/all'; import './plugins/all'; -import './dashboard/all'; +import './dashboard'; import './playlist/all'; import './panel/all'; import './org/all'; diff --git a/public/app/features/dashboard/alerting_srv.ts b/public/app/features/dashboard/alerting_srv.ts deleted file mode 100644 index 446c3218f79..00000000000 --- a/public/app/features/dashboard/alerting_srv.ts +++ /dev/null @@ -1,13 +0,0 @@ -import coreModule from 'app/core/core_module'; - -export class AlertingSrv { - dashboard: any; - alerts: any[]; - - init(dashboard, alerts) { - this.dashboard = dashboard; - this.alerts = alerts || []; - } -} - -coreModule.service('alertingSrv', AlertingSrv); diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts deleted file mode 100644 index 5ec4e5e3929..00000000000 --- a/public/app/features/dashboard/all.ts +++ /dev/null @@ -1,45 +0,0 @@ -import './dashboard_ctrl'; -import './alerting_srv'; -import './history/history'; -import './dashboard_loader_srv'; -import './dashnav/dashnav'; -import './submenu/submenu'; -import './save_as_modal'; -import './save_modal'; -import './save_provisioned_modal'; -import './shareModalCtrl'; -import './share_snapshot_ctrl'; -import './dashboard_srv'; -import './view_state_srv'; -import './validation_srv'; -import './time_srv'; -import './unsaved_changes_srv'; -import './unsaved_changes_modal'; -import './timepicker/timepicker'; -import './upload'; -import './export/export_modal'; -import './export_data/export_data_modal'; -import './ad_hoc_filters'; -import './repeat_option/repeat_option'; -import './dashgrid/DashboardGridDirective'; -import './dashgrid/RowOptions'; -import './folder_picker/folder_picker'; -import './move_to_folder_modal/move_to_folder'; -import './settings/settings'; -import './panellinks/module'; -import './dashlinks/module'; - -// angular wrappers -import { react2AngularDirective } from 'app/core/utils/react2angular'; -import DashboardPermissions from './permissions/DashboardPermissions'; - -react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']); - -import coreModule from 'app/core/core_module'; -import { FolderDashboardsCtrl } from './folder_dashboards_ctrl'; -import { DashboardImportCtrl } from './dashboard_import_ctrl'; -import { CreateFolderCtrl } from './create_folder_ctrl'; - -coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl); -coreModule.controller('DashboardImportCtrl', DashboardImportCtrl); -coreModule.controller('CreateFolderCtrl', CreateFolderCtrl); diff --git a/public/app/features/dashboard/ad_hoc_filters.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts similarity index 100% rename from public/app/features/dashboard/ad_hoc_filters.ts rename to public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts diff --git a/public/app/features/dashboard/components/AdHocFilters/index.ts b/public/app/features/dashboard/components/AdHocFilters/index.ts new file mode 100644 index 00000000000..522b564d004 --- /dev/null +++ b/public/app/features/dashboard/components/AdHocFilters/index.ts @@ -0,0 +1 @@ +export { AdHocFiltersCtrl } from './AdHocFiltersCtrl'; diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx similarity index 86% rename from public/app/features/dashboard/dashgrid/AddPanelPanel.tsx rename to public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index 95d03152b14..4d46d88a1d2 100644 --- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -1,23 +1,23 @@ import React from 'react'; import _ from 'lodash'; import config from 'app/core/config'; -import { PanelModel } from '../panel_model'; -import { DashboardModel } from '../dashboard_model'; +import { PanelModel } from '../../panel_model'; +import { DashboardModel } from '../../dashboard_model'; import store from 'app/core/store'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { updateLocation } from 'app/core/actions'; import { store as reduxStore } from 'app/store/store'; -export interface AddPanelPanelProps { +export interface Props { panel: PanelModel; dashboard: DashboardModel; } -export interface AddPanelPanelState { +export interface State { copiedPanelPlugins: any[]; } -export class AddPanelPanel extends React.Component { +export class AddPanelWidget extends React.Component { constructor(props) { super(props); this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this); @@ -133,15 +133,15 @@ export class AddPanelPanel extends React.Component -
-
+
+
+
-
-
+
diff --git a/public/sass/components/_panel_add_panel.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss similarity index 81% rename from public/sass/components/_panel_add_panel.scss rename to public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 86921fb43f3..5a1cbee4b44 100644 --- a/public/sass/components/_panel_add_panel.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -1,12 +1,12 @@ -.add-panel-container { +.add-panel-widget-container { height: 100%; } -.add-panel { +.add-panel-widget { height: 100%; } -.add-panel__header { +.add-panel-widget__header { top: 0; position: absolute; padding: 0 15px; @@ -26,7 +26,7 @@ } } -.add-panel__close { +.add-panel-widget__close { margin-left: auto; background-color: transparent; border: 0; @@ -34,7 +34,7 @@ margin-right: -10px; } -.add-panel-btn-container { +.add-panel-widget__btn-container { display: flex; justify-content: center; align-items: center; diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts new file mode 100644 index 00000000000..b96948ab1c0 --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/index.ts @@ -0,0 +1 @@ +export { AddPanelWidget } from './AddPanelWidget'; diff --git a/public/app/features/dashboard/export/export_modal.ts b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts similarity index 92% rename from public/app/features/dashboard/export/export_modal.ts rename to public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts index 8136c77cd8f..7769bdf114a 100644 --- a/public/app/features/dashboard/export/export_modal.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts @@ -2,7 +2,7 @@ import angular from 'angular'; import { saveAs } from 'file-saver'; import coreModule from 'app/core/core_module'; -import { DashboardExporter } from './exporter'; +import { DashboardExporter } from './DashboardExporter'; export class DashExportCtrl { dash: any; @@ -66,7 +66,7 @@ export class DashExportCtrl { export function dashExportDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/export/export_modal.html', + templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html', controller: DashExportCtrl, bindToController: true, controllerAs: 'ctrl', diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts similarity index 98% rename from public/app/features/dashboard/specs/exporter.test.ts rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index eac6b0b272a..20ab21541a5 100644 --- a/public/app/features/dashboard/specs/exporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -6,8 +6,8 @@ jest.mock('app/core/store', () => { import _ from 'lodash'; import config from 'app/core/config'; -import { DashboardExporter } from '../export/exporter'; -import { DashboardModel } from '../dashboard_model'; +import { DashboardExporter } from './DashboardExporter'; +import { DashboardModel } from '../../dashboard_model'; describe('given dashboard with repeated panels', () => { let dash, exported; diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts similarity index 98% rename from public/app/features/dashboard/export/exporter.ts rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 7aecb5c384f..22b93b767d6 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -1,6 +1,6 @@ import config from 'app/core/config'; import _ from 'lodash'; -import { DashboardModel } from '../dashboard_model'; +import { DashboardModel } from '../../dashboard_model'; export class DashboardExporter { constructor(private datasourceSrv) {} diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts new file mode 100644 index 00000000000..6529cf07ad9 --- /dev/null +++ b/public/app/features/dashboard/components/DashExportModal/index.ts @@ -0,0 +1,2 @@ +export { DashboardExporter } from './DashboardExporter'; +export { DashExportCtrl } from './DashExportCtrl'; diff --git a/public/app/features/dashboard/export/export_modal.html b/public/app/features/dashboard/components/DashExportModal/template.html similarity index 100% rename from public/app/features/dashboard/export/export_modal.html rename to public/app/features/dashboard/components/DashExportModal/template.html diff --git a/public/app/features/dashboard/dashlinks/module.ts b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts similarity index 99% rename from public/app/features/dashboard/dashlinks/module.ts rename to public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts index c951538d45d..a08e438a46c 100644 --- a/public/app/features/dashboard/dashlinks/module.ts +++ b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts @@ -1,6 +1,6 @@ import angular from 'angular'; import _ from 'lodash'; -import { iconMap } from './editor'; +import { iconMap } from './DashLinksEditorCtrl'; function dashLinksContainer() { return { diff --git a/public/app/features/dashboard/dashlinks/editor.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts similarity index 90% rename from public/app/features/dashboard/dashlinks/editor.ts rename to public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts index 482052469db..398ad757bf3 100644 --- a/public/app/features/dashboard/dashlinks/editor.ts +++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts @@ -11,7 +11,7 @@ export let iconMap = { cloud: 'fa-cloud', }; -export class DashLinkEditorCtrl { +export class DashLinksEditorCtrl { dashboard: any; iconMap: any; mode: any; @@ -65,8 +65,8 @@ export class DashLinkEditorCtrl { function dashLinksEditor() { return { restrict: 'E', - controller: DashLinkEditorCtrl, - templateUrl: 'public/app/features/dashboard/dashlinks/editor.html', + controller: DashLinksEditorCtrl, + templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html', bindToController: true, controllerAs: 'ctrl', scope: { diff --git a/public/app/features/dashboard/dashlinks/editor.html b/public/app/features/dashboard/components/DashLinks/editor.html similarity index 100% rename from public/app/features/dashboard/dashlinks/editor.html rename to public/app/features/dashboard/components/DashLinks/editor.html diff --git a/public/app/features/dashboard/components/DashLinks/index.ts b/public/app/features/dashboard/components/DashLinks/index.ts new file mode 100644 index 00000000000..ef118d4a84c --- /dev/null +++ b/public/app/features/dashboard/components/DashLinks/index.ts @@ -0,0 +1,2 @@ +export { DashLinksContainerCtrl } from './DashLinksContainerCtrl'; +export { DashLinksEditorCtrl } from './DashLinksEditorCtrl'; diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts similarity index 92% rename from public/app/features/dashboard/dashnav/dashnav.ts rename to public/app/features/dashboard/components/DashNav/DashNavCtrl.ts index 1c83b2d0bdb..d7305b948dc 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts @@ -1,7 +1,7 @@ import moment from 'moment'; import angular from 'angular'; import { appEvents, NavModel } from 'app/core/core'; -import { DashboardModel } from '../dashboard_model'; +import { DashboardModel } from '../../dashboard_model'; export class DashNavCtrl { dashboard: DashboardModel; @@ -60,7 +60,7 @@ export class DashNavCtrl { modalScope.dashboard = this.dashboard; appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/partials/shareModal.html', + src: 'public/app/features/dashboard/components/ShareModal/template.html', scope: modalScope, }); } @@ -107,7 +107,7 @@ export class DashNavCtrl { export function dashNavDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html', + templateUrl: 'public/app/features/dashboard/components/DashNav/template.html', controller: DashNavCtrl, bindToController: true, controllerAs: 'ctrl', diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts new file mode 100644 index 00000000000..854e32b24d2 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -0,0 +1 @@ +export { DashNavCtrl } from './DashNavCtrl'; diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/components/DashNav/template.html similarity index 100% rename from public/app/features/dashboard/dashnav/dashnav.html rename to public/app/features/dashboard/components/DashNav/template.html diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx similarity index 97% rename from public/app/features/dashboard/permissions/DashboardPermissions.tsx rename to public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx index 265d11161ea..ce6a866ce57 100644 --- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx +++ b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx @@ -8,11 +8,11 @@ import { addDashboardPermission, removeDashboardPermission, updateDashboardPermission, -} from '../state/actions'; +} from '../../state/actions'; import PermissionList from 'app/core/components/PermissionList/PermissionList'; import AddPermission from 'app/core/components/PermissionList/AddPermission'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; -import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; export interface Props { dashboardId: number; diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts similarity index 97% rename from public/app/features/dashboard/settings/settings.ts rename to public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts index 1e8d96a54cb..a0eb5c8c6b3 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts @@ -1,5 +1,5 @@ import { coreModule, appEvents, contextSrv } from 'app/core/core'; -import { DashboardModel } from '../dashboard_model'; +import { DashboardModel } from '../../dashboard_model'; import $ from 'jquery'; import _ from 'lodash'; import angular from 'angular'; @@ -230,7 +230,7 @@ export class SettingsCtrl { export function dashboardSettings() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/settings/settings.html', + templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html', controller: SettingsCtrl, bindToController: true, controllerAs: 'ctrl', diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts new file mode 100644 index 00000000000..f81b8cdbc67 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -0,0 +1 @@ +export { SettingsCtrl } from './SettingsCtrl'; diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/components/DashboardSettings/template.html similarity index 98% rename from public/app/features/dashboard/settings/settings.html rename to public/app/features/dashboard/components/DashboardSettings/template.html index 46d84a7a2fd..97002f7bf92 100644 --- a/public/app/features/dashboard/settings/settings.html +++ b/public/app/features/dashboard/components/DashboardSettings/template.html @@ -51,7 +51,8 @@ on-change="ctrl.onFolderChange($folder)" enable-create-new="true" is-valid-selection="true" - label-class="width-7"> + label-class="width-7" + dashboard-id="ctrl.dashboard.id"> diff --git a/public/app/features/dashboard/export_data/export_data_modal.ts b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts similarity index 92% rename from public/app/features/dashboard/export_data/export_data_modal.ts rename to public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts index 460f80079d9..f87daa94ee7 100644 --- a/public/app/features/dashboard/export_data/export_data_modal.ts +++ b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts @@ -31,7 +31,7 @@ export class ExportDataModalCtrl { export function exportDataModal() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/export_data/export_data_modal.html', + templateUrl: 'public/app/features/dashboard/components/ExportDataModal/template.html', controller: ExportDataModalCtrl, controllerAs: 'ctrl', scope: { diff --git a/public/app/features/dashboard/components/ExportDataModal/index.ts b/public/app/features/dashboard/components/ExportDataModal/index.ts new file mode 100644 index 00000000000..6df4fd00434 --- /dev/null +++ b/public/app/features/dashboard/components/ExportDataModal/index.ts @@ -0,0 +1 @@ +export { ExportDataModalCtrl } from './ExportDataModalCtrl'; diff --git a/public/app/features/dashboard/export_data/export_data_modal.html b/public/app/features/dashboard/components/ExportDataModal/template.html similarity index 100% rename from public/app/features/dashboard/export_data/export_data_modal.html rename to public/app/features/dashboard/components/ExportDataModal/template.html diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts similarity index 90% rename from public/app/features/dashboard/folder_picker/folder_picker.ts rename to public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts index 80651fecb7e..93d43d36038 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts @@ -21,6 +21,7 @@ export class FolderPickerCtrl { hasValidationError: boolean; validationError: any; isEditor: boolean; + dashboardId?: number; /** @ngInject */ constructor(private backendSrv, private validationSrv, private contextSrv) { @@ -144,7 +145,13 @@ export class FolderPickerCtrl { if (this.isEditor) { folder = rootFolder; } else { - folder = result.length > 0 ? result[0] : resetFolder; + // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard + const isPersistedDashBoard = this.dashboardId ? true : false; + if (isPersistedDashBoard) { + folder = resetFolder; + } else { + folder = result.length > 0 ? result[0] : resetFolder; + } } } @@ -161,7 +168,7 @@ export class FolderPickerCtrl { export function folderPicker() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html', + templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html', controller: FolderPickerCtrl, bindToController: true, controllerAs: 'ctrl', @@ -176,6 +183,7 @@ export function folderPicker() { exitFolderCreation: '&', enableCreateNew: '@', enableReset: '@', + dashboardId: ' { diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts similarity index 96% rename from public/app/features/dashboard/save_as_modal.ts rename to public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts index 4649bc18f9f..6a470785fdb 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts @@ -25,7 +25,8 @@ const template = ` enter-folder-creation="ctrl.onEnterFolderCreation()" exit-folder-creation="ctrl.onExitFolderCreation()" enable-create-new="true" - label-class="width-7"> + label-class="width-7" + dashboard-id="ctrl.clone.id">
diff --git a/public/app/features/dashboard/specs/save_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts similarity index 97% rename from public/app/features/dashboard/specs/save_modal.test.ts rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts index 669ae43a0ff..f973c1b8e63 100644 --- a/public/app/features/dashboard/specs/save_modal.test.ts +++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts @@ -1,4 +1,4 @@ -import { SaveDashboardModalCtrl } from '../save_modal'; +import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl'; const setup = (timeChanged, variableValuesChanged, cb) => { const dash = { diff --git a/public/app/features/dashboard/save_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts similarity index 100% rename from public/app/features/dashboard/save_modal.ts rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts diff --git a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts similarity index 87% rename from public/app/features/dashboard/specs/save_provisioned_modal.test.ts rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts index a3ab27a984f..86048e861bd 100644 --- a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts +++ b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts @@ -1,4 +1,4 @@ -import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal'; +import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl'; describe('SaveProvisionedDashboardModalCtrl', () => { const json = { diff --git a/public/app/features/dashboard/save_provisioned_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts similarity index 100% rename from public/app/features/dashboard/save_provisioned_modal.ts rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts diff --git a/public/app/features/dashboard/components/SaveModals/index.ts b/public/app/features/dashboard/components/SaveModals/index.ts new file mode 100644 index 00000000000..afab0796d28 --- /dev/null +++ b/public/app/features/dashboard/components/SaveModals/index.ts @@ -0,0 +1,2 @@ +export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl'; +export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl'; diff --git a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts similarity index 96% rename from public/app/features/dashboard/specs/share_modal_ctrl.test.ts rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts index 70d301ed5ff..3181231cb53 100644 --- a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts +++ b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts @@ -1,7 +1,6 @@ -import '../shareModalCtrl'; -import { ShareModalCtrl } from '../shareModalCtrl'; import config from 'app/core/config'; -import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv'; +import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; +import { ShareModalCtrl } from './ShareModalCtrl'; describe('ShareModalCtrl', () => { const ctx = { diff --git a/public/app/features/dashboard/shareModalCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts similarity index 100% rename from public/app/features/dashboard/shareModalCtrl.ts rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts similarity index 100% rename from public/app/features/dashboard/share_snapshot_ctrl.ts rename to public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts new file mode 100644 index 00000000000..3f27d5a1ba3 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/index.ts @@ -0,0 +1,2 @@ +export { ShareModalCtrl } from './ShareModalCtrl'; +export { ShareSnapshotCtrl } from './ShareSnapshotCtrl'; diff --git a/public/app/features/dashboard/partials/shareModal.html b/public/app/features/dashboard/components/ShareModal/template.html similarity index 100% rename from public/app/features/dashboard/partials/shareModal.html rename to public/app/features/dashboard/components/ShareModal/template.html diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts similarity index 86% rename from public/app/features/dashboard/submenu/submenu.ts rename to public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts index 184d29facee..502e467ad2b 100644 --- a/public/app/features/dashboard/submenu/submenu.ts +++ b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts @@ -1,7 +1,7 @@ import angular from 'angular'; import _ from 'lodash'; -export class SubmenuCtrl { +export class SubMenuCtrl { annotations: any; variables: any; dashboard: any; @@ -29,8 +29,8 @@ export class SubmenuCtrl { export function submenuDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/submenu/submenu.html', - controller: SubmenuCtrl, + templateUrl: 'public/app/features/dashboard/components/SubMenu/template.html', + controller: SubMenuCtrl, bindToController: true, controllerAs: 'ctrl', scope: { diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts new file mode 100644 index 00000000000..1790aa66782 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/index.ts @@ -0,0 +1 @@ +export { SubMenuCtrl } from './SubMenuCtrl'; diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/components/SubMenu/template.html similarity index 100% rename from public/app/features/dashboard/submenu/submenu.html rename to public/app/features/dashboard/components/SubMenu/template.html diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts similarity index 95% rename from public/app/features/dashboard/timepicker/timepicker.ts rename to public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts index c89e49b54b3..0c388c27f8d 100644 --- a/public/app/features/dashboard/timepicker/timepicker.ts +++ b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts @@ -159,7 +159,7 @@ export class TimePickerCtrl { export function settingsDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/timepicker/settings.html', + templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html', controller: TimePickerCtrl, bindToController: true, controllerAs: 'ctrl', @@ -172,7 +172,7 @@ export function settingsDirective() { export function timePickerDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/timepicker/timepicker.html', + templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html', controller: TimePickerCtrl, bindToController: true, controllerAs: 'ctrl', @@ -185,5 +185,5 @@ export function timePickerDirective() { angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective); angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective); -import { inputDateDirective } from './input_date'; +import { inputDateDirective } from './validation'; 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 new file mode 100644 index 00000000000..ca6e2792c43 --- /dev/null +++ b/public/app/features/dashboard/components/TimePicker/index.ts @@ -0,0 +1 @@ +export { TimePickerCtrl } from './TimePickerCtrl'; diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/components/TimePicker/settings.html similarity index 100% rename from public/app/features/dashboard/timepicker/settings.html rename to public/app/features/dashboard/components/TimePicker/settings.html diff --git a/public/app/features/dashboard/timepicker/timepicker.html b/public/app/features/dashboard/components/TimePicker/template.html similarity index 100% rename from public/app/features/dashboard/timepicker/timepicker.html rename to public/app/features/dashboard/components/TimePicker/template.html diff --git a/public/app/features/dashboard/timepicker/input_date.ts b/public/app/features/dashboard/components/TimePicker/validation.ts similarity index 100% rename from public/app/features/dashboard/timepicker/input_date.ts rename to public/app/features/dashboard/components/TimePicker/validation.ts diff --git a/public/app/features/dashboard/unsaved_changes_modal.ts b/public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts similarity index 100% rename from public/app/features/dashboard/unsaved_changes_modal.ts rename to public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts diff --git a/public/app/features/dashboard/components/UnsavedChangesModal/index.ts b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts new file mode 100644 index 00000000000..43943f06694 --- /dev/null +++ b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts @@ -0,0 +1 @@ +export { UnsavedChangesModalCtrl } from './UnsavedChangesModalCtrl'; diff --git a/public/app/features/dashboard/specs/history_ctrl.test.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts similarity index 98% rename from public/app/features/dashboard/specs/history_ctrl.test.ts rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts index 632f3489dae..2b257e148f5 100644 --- a/public/app/features/dashboard/specs/history_ctrl.test.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { HistoryListCtrl } from 'app/features/dashboard/history/history'; -import { versions, compare, restore } from './history_mocks'; +import { HistoryListCtrl } from './HistoryListCtrl'; +import { versions, compare, restore } from './__mocks__/history'; import $q from 'q'; describe('HistoryListCtrl', () => { diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts similarity index 96% rename from public/app/features/dashboard/history/history.ts rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts index 3563ccc7766..b8632e2eeae 100644 --- a/public/app/features/dashboard/history/history.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts @@ -1,12 +1,10 @@ -import './history_srv'; - import _ from 'lodash'; import angular from 'angular'; import moment from 'moment'; import locationUtil from 'app/core/utils/location_util'; -import { DashboardModel } from '../dashboard_model'; -import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv'; +import { DashboardModel } from '../../dashboard_model'; +import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './HistorySrv'; export class HistoryListCtrl { appending: boolean; @@ -200,7 +198,7 @@ export class HistoryListCtrl { export function dashboardHistoryDirective() { return { restrict: 'E', - templateUrl: 'public/app/features/dashboard/history/history.html', + templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html', controller: HistoryListCtrl, bindToController: true, controllerAs: 'ctrl', diff --git a/public/app/features/dashboard/specs/history_srv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts similarity index 90% rename from public/app/features/dashboard/specs/history_srv.test.ts rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts index 1e2bd57a221..75766060e7f 100644 --- a/public/app/features/dashboard/specs/history_srv.test.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -1,7 +1,6 @@ -import '../history/history_srv'; -import { versions, restore } from './history_mocks'; -import { HistorySrv } from '../history/history_srv'; -import { DashboardModel } from '../dashboard_model'; +import { versions, restore } from './__mocks__/history'; +import { HistorySrv } from './HistorySrv'; +import { DashboardModel } from '../../dashboard_model'; jest.mock('app/core/store'); describe('historySrv', () => { diff --git a/public/app/features/dashboard/history/history_srv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts similarity index 96% rename from public/app/features/dashboard/history/history_srv.ts rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.ts index 7f7dc950de3..d52f3ab879c 100644 --- a/public/app/features/dashboard/history/history_srv.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import coreModule from 'app/core/core_module'; -import { DashboardModel } from '../dashboard_model'; +import { DashboardModel } from '../../dashboard_model'; export interface HistoryListOpts { limit: number; diff --git a/public/app/features/dashboard/specs/history_mocks.ts b/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts similarity index 100% rename from public/app/features/dashboard/specs/history_mocks.ts rename to public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts new file mode 100644 index 00000000000..138de434bf3 --- /dev/null +++ b/public/app/features/dashboard/components/VersionHistory/index.ts @@ -0,0 +1,2 @@ +export { HistoryListCtrl } from './HistoryListCtrl'; +export { HistorySrv } from './HistorySrv'; diff --git a/public/app/features/dashboard/history/history.html b/public/app/features/dashboard/components/VersionHistory/template.html similarity index 100% rename from public/app/features/dashboard/history/history.html rename to public/app/features/dashboard/components/VersionHistory/template.html diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 6611a728803..5c4480dbad5 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -22,7 +22,6 @@ export class DashboardCtrl { private keybindingSrv, private timeSrv, private variableSrv, - private alertingSrv, private dashboardSrv, private unsavedChangesSrv, private dashboardViewStateSrv, @@ -54,7 +53,6 @@ export class DashboardCtrl { // init services this.timeSrv.init(dashboard); - this.alertingSrv.init(dashboard, data.alerts); this.annotationsSrv.init(dashboard); // template values service needs to initialize completely before diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index f0e97162d43..cfff64cb042 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; import { importPluginModule } from 'app/features/plugins/plugin_loader'; -import { AddPanelPanel } from './AddPanelPanel'; +import { AddPanelWidget } from '../components/AddPanelWidget'; import { getPanelPluginNotFound } from './PanelPluginNotFound'; import { DashboardRow } from './DashboardRow'; import { PanelChrome } from './PanelChrome'; @@ -53,7 +53,7 @@ export class DashboardPanel extends PureComponent { } renderAddPanel() { - return ; + return ; } onPluginTypeChanged = (plugin: PanelPlugin) => { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index dbf441adaa8..4f5a74f820b 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -3,7 +3,7 @@ import Remarkable from 'remarkable'; import { Tooltip } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/panel_model'; import templateSrv from 'app/features/templating/template_srv'; -import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv'; +import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv'; enum InfoModes { diff --git a/public/app/features/dashboard/folder_permissions_ctrl.ts b/public/app/features/dashboard/folder_permissions_ctrl.ts deleted file mode 100644 index 4ab91acb3d9..00000000000 --- a/public/app/features/dashboard/folder_permissions_ctrl.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FolderPageLoader } from './folder_page_loader'; - -export class FolderPermissionsCtrl { - navModel: any; - folderId: number; - uid: string; - dashboard: any; - meta: any; - - /** @ngInject */ - constructor(private backendSrv, navModelSrv, private $routeParams, $location) { - if (this.$routeParams.uid) { - this.uid = $routeParams.uid; - - new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => { - if ($location.path() !== folder.meta.url) { - $location.path(`${folder.meta.url}/permissions`).replace(); - } - - this.dashboard = folder.dashboard; - this.meta = folder.meta; - }); - } - } -} diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts new file mode 100644 index 00000000000..efa54f0ee07 --- /dev/null +++ b/public/app/features/dashboard/index.ts @@ -0,0 +1,33 @@ +import './dashboard_ctrl'; +import './time_srv'; +import './dashgrid/DashboardGridDirective'; + +// Services +import './services/DashboardViewStateSrv'; +import './services/UnsavedChangesSrv'; +import './services/DashboardLoaderSrv'; +import './services/DashboardSrv'; + +// Components +import './components/DashLinks'; +import './components/DashExportModal'; +import './components/DashNav'; +import './components/ExportDataModal'; +import './components/FolderPicker'; +import './components/VersionHistory'; +import './components/DashboardSettings'; +import './components/SubMenu'; +import './components/TimePicker'; +import './components/UnsavedChangesModal'; +import './components/SaveModals'; +import './components/ShareModal'; +import './components/AdHocFilters'; +import './components/RowOptions'; + +import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions'; + +// angular wrappers +import { react2AngularDirective } from 'app/core/utils/react2angular'; + +react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']); + diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx index 540b6a8353e..2651ab0608c 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -166,6 +166,7 @@ export class QueryEditorRow extends PureComponent { onDisableQuery = () => { this.props.query.hide = !this.props.query.hide; + this.onExecuteQuery(); this.forceUpdate(); }; diff --git a/public/app/features/dashboard/partials/folder_permissions.html b/public/app/features/dashboard/partials/folder_permissions.html deleted file mode 100644 index be44c1051f2..00000000000 --- a/public/app/features/dashboard/partials/folder_permissions.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
- -
diff --git a/public/app/features/dashboard/partials/folder_settings.html b/public/app/features/dashboard/partials/folder_settings.html deleted file mode 100644 index 8e819be5fe8..00000000000 --- a/public/app/features/dashboard/partials/folder_settings.html +++ /dev/null @@ -1,23 +0,0 @@ - - -
-

Folder Settings

- -
-
-
- - -
-
- - -
-
-
-
diff --git a/public/app/features/dashboard/partials/inspector.html b/public/app/features/dashboard/partials/inspector.html deleted file mode 100644 index b30bce3c5fe..00000000000 --- a/public/app/features/dashboard/partials/inspector.html +++ /dev/null @@ -1,82 +0,0 @@ - - diff --git a/public/app/features/dashboard/specs/change_tracker.test.ts b/public/app/features/dashboard/services/ChangeTracker.test.ts similarity index 97% rename from public/app/features/dashboard/specs/change_tracker.test.ts rename to public/app/features/dashboard/services/ChangeTracker.test.ts index e7f8ce977b1..dfc9b3fa03f 100644 --- a/public/app/features/dashboard/specs/change_tracker.test.ts +++ b/public/app/features/dashboard/services/ChangeTracker.test.ts @@ -1,4 +1,4 @@ -import { ChangeTracker } from 'app/features/dashboard/change_tracker'; +import { ChangeTracker } from './ChangeTracker'; import { contextSrv } from 'app/core/services/context_srv'; import { DashboardModel } from '../dashboard_model'; import { PanelModel } from '../panel_model'; diff --git a/public/app/features/dashboard/change_tracker.ts b/public/app/features/dashboard/services/ChangeTracker.ts similarity index 98% rename from public/app/features/dashboard/change_tracker.ts rename to public/app/features/dashboard/services/ChangeTracker.ts index aa71ac2e306..ef3d456db48 100644 --- a/public/app/features/dashboard/change_tracker.ts +++ b/public/app/features/dashboard/services/ChangeTracker.ts @@ -1,6 +1,6 @@ import angular from 'angular'; import _ from 'lodash'; -import { DashboardModel } from './dashboard_model'; +import { DashboardModel } from '../dashboard_model'; export class ChangeTracker { current: any; diff --git a/public/app/features/dashboard/dashboard_loader_srv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts similarity index 100% rename from public/app/features/dashboard/dashboard_loader_srv.ts rename to public/app/features/dashboard/services/DashboardLoaderSrv.ts diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/services/DashboardSrv.ts similarity index 98% rename from public/app/features/dashboard/dashboard_srv.ts rename to public/app/features/dashboard/services/DashboardSrv.ts index d5695a577c5..67a4938c6aa 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -1,5 +1,5 @@ import coreModule from 'app/core/core_module'; -import { DashboardModel } from './dashboard_model'; +import { DashboardModel } from '../dashboard_model'; import locationUtil from 'app/core/utils/location_util'; export class DashboardSrv { diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts similarity index 79% rename from public/app/features/dashboard/specs/viewstate_srv.test.ts rename to public/app/features/dashboard/services/DashboardViewStateSrv.test.ts index f9963afbf85..20215017e1d 100644 --- a/public/app/features/dashboard/specs/viewstate_srv.test.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts @@ -1,7 +1,5 @@ -//import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common'; -import 'app/features/dashboard/view_state_srv'; import config from 'app/core/config'; -import { DashboardViewState } from '../view_state_srv'; +import { DashboardViewStateSrv } from './DashboardViewStateSrv'; import { DashboardModel } from '../dashboard_model'; describe('when updating view state', () => { @@ -33,7 +31,7 @@ describe('when updating view state', () => { location.search = jest.fn(() => { return { fullscreen: true, edit: true, panelId: 1 }; }); - viewState = new DashboardViewState($scope, location, {}); + viewState = new DashboardViewStateSrv($scope, location, {}); }); it('should update querystring and view state', () => { @@ -55,12 +53,11 @@ describe('when updating view state', () => { describe('to fullscreen false', () => { beforeEach(() => { - viewState = new DashboardViewState($scope, location, {}); + viewState = new DashboardViewStateSrv($scope, location, {}); }); it('should remove params from query string', () => { viewState.update({ fullscreen: true, panelId: 1, edit: true }); viewState.update({ fullscreen: false }); - expect(viewState.dashboard.meta.fullscreen).toBe(false); expect(viewState.state.fullscreen).toBe(null); }); }); diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts similarity index 91% rename from public/app/features/dashboard/view_state_srv.ts rename to public/app/features/dashboard/services/DashboardViewStateSrv.ts index ff12d26233d..816b6d8bd2d 100644 --- a/public/app/features/dashboard/view_state_srv.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts @@ -2,11 +2,11 @@ import angular from 'angular'; import _ from 'lodash'; import config from 'app/core/config'; import appEvents from 'app/core/app_events'; -import { DashboardModel } from './dashboard_model'; +import { DashboardModel } from '../dashboard_model'; // represents the transient view state // like fullscreen panel & edit -export class DashboardViewState { +export class DashboardViewStateSrv { state: any; panelScopes: any; $scope: any; @@ -72,7 +72,6 @@ export class DashboardViewState { } _.extend(this.state, state); - this.dashboard.meta.fullscreen = this.state.fullscreen; if (!this.state.fullscreen) { this.state.fullscreen = null; @@ -117,10 +116,20 @@ export class DashboardViewState { } syncState() { - if (this.dashboard.meta.fullscreen) { + if (this.state.fullscreen) { const panel = this.dashboard.getPanelById(this.state.panelId); if (!panel) { + this.state.fullscreen = null; + this.state.panelId = null; + this.state.edit = null; + + this.update(this.state); + + setTimeout(() => { + appEvents.emit('alert-error', ['Error', 'Panel not found']); + }, 100); + return; } @@ -168,7 +177,7 @@ export class DashboardViewState { export function dashboardViewStateSrv($location, $timeout) { return { create: $scope => { - return new DashboardViewState($scope, $location, $timeout); + return new DashboardViewStateSrv($scope, $location, $timeout); }, }; } diff --git a/public/app/features/dashboard/unsaved_changes_srv.ts b/public/app/features/dashboard/services/UnsavedChangesSrv.ts similarity index 89% rename from public/app/features/dashboard/unsaved_changes_srv.ts rename to public/app/features/dashboard/services/UnsavedChangesSrv.ts index f0a8bf40501..2691cc6ebf8 100644 --- a/public/app/features/dashboard/unsaved_changes_srv.ts +++ b/public/app/features/dashboard/services/UnsavedChangesSrv.ts @@ -1,5 +1,5 @@ import angular from 'angular'; -import { ChangeTracker } from './change_tracker'; +import { ChangeTracker } from './ChangeTracker'; /** @ngInject */ export function unsavedChangesSrv(this: any, $rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) { diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index 00c960bdfaa..cfbe094125f 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -80,7 +80,7 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => { export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => { appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/partials/shareModal.html', + src: 'public/app/features/dashboard/components/ShareModal/template.html', model: { dashboard: dashboard, panel: panel, diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b6f57a76004..20ab8ee67b9 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -9,8 +9,6 @@ import { AutoSizer } from 'react-virtualized'; import store from 'app/core/store'; // Components -import { DataSourceSelectItem } from '@grafana/ui/src/types'; -import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { Alert } from './Error'; import ErrorBoundary from './ErrorBoundary'; import GraphContainer from './GraphContainer'; @@ -21,18 +19,13 @@ import TimePicker, { parseTime } from './TimePicker'; // Actions import { - changeDatasource, changeSize, changeTime, - clearQueries, initializeExplore, modifyQueries, - runQueries, scanStart, scanStop, setQueries, - splitClose, - splitOpen, } from './state/actions'; // Types @@ -41,27 +34,23 @@ import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/ import { StoreState } from 'app/types'; import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore'; import { Emitter } from 'app/core/utils/emitter'; +import { ExploreToolbar } from './ExploreToolbar'; interface ExploreProps { StartPage?: any; - changeDatasource: typeof changeDatasource; changeSize: typeof changeSize; changeTime: typeof changeTime; - clearQueries: typeof clearQueries; datasourceError: string; datasourceInstance: any; datasourceLoading: boolean | null; datasourceMissing: boolean; - exploreDatasources: DataSourceSelectItem[]; exploreId: ExploreId; initialDatasource?: string; initialQueries: DataQuery[]; initializeExplore: typeof initializeExplore; initialized: boolean; - loading: boolean; modifyQueries: typeof modifyQueries; range: RawTimeRange; - runQueries: typeof runQueries; scanner?: RangeScanner; scanning?: boolean; scanRange?: RawTimeRange; @@ -69,8 +58,6 @@ interface ExploreProps { scanStop: typeof scanStop; setQueries: typeof setQueries; split: boolean; - splitClose: typeof splitClose; - splitOpen: typeof splitOpen; showingStartPage?: boolean; supportsGraph: boolean | null; supportsLogs: boolean | null; @@ -145,10 +132,6 @@ export class Explore extends React.PureComponent { this.el = el; }; - onChangeDatasource = async option => { - this.props.changeDatasource(this.props.exploreId, option.value); - }; - onChangeTime = (range: TimeRange, changedByScanner?: boolean) => { if (this.props.scanning && !changedByScanner) { this.onStopScanning(); @@ -156,23 +139,11 @@ export class Explore extends React.PureComponent { this.props.changeTime(this.props.exploreId, range); }; - onClickClear = () => { - this.props.clearQueries(this.props.exploreId); - }; - - onClickCloseSplit = () => { - this.props.splitClose(); - }; - // Use this in help pages to set page to a single query onClickExample = (query: DataQuery) => { this.props.setQueries(this.props.exploreId, [query]); }; - onClickSplit = () => { - this.props.splitOpen(); - }; - onClickLabel = (key: string, value: string) => { this.onModifyQueries({ type: 'ADD_FILTER', key, value }); }; @@ -204,10 +175,6 @@ export class Explore extends React.PureComponent { this.props.scanStop(this.props.exploreId); }; - onSubmit = () => { - this.props.runQueries(this.props.exploreId); - }; - render() { const { StartPage, @@ -215,11 +182,8 @@ export class Explore extends React.PureComponent { datasourceError, datasourceLoading, datasourceMissing, - exploreDatasources, exploreId, - loading, initialQueries, - range, showingStartPage, split, supportsGraph, @@ -227,64 +191,10 @@ export class Explore extends React.PureComponent { supportsTable, } = this.props; const exploreClass = split ? 'explore explore-split' : 'explore'; - const selectedDatasource = datasourceInstance - ? exploreDatasources.find(d => d.name === datasourceInstance.name) - : undefined; return (
-
- {exploreId === 'left' ? ( - - ) : ( - <> -
-
- -
- - )} - {!datasourceMissing ? ( -
- -
- ) : null} -
- {exploreId === 'left' && !split ? ( -
- -
- ) : null} - -
- -
-
- -
-
+ {datasourceLoading ?
Loading datasource...
: null} {datasourceMissing ? (
Please add a datasource that supports Explore (e.g., Prometheus).
@@ -341,30 +251,24 @@ function mapStateToProps(state: StoreState, { exploreId }) { datasourceInstance, datasourceLoading, datasourceMissing, - exploreDatasources, initialDatasource, initialQueries, initialized, - queryTransactions, range, showingStartPage, supportsGraph, supportsLogs, supportsTable, } = item; - const loading = queryTransactions.some(qt => !qt.done); return { StartPage, datasourceError, datasourceInstance, datasourceLoading, datasourceMissing, - exploreDatasources, initialDatasource, initialQueries, initialized, - loading, - queryTransactions, range, showingStartPage, split, @@ -375,18 +279,13 @@ function mapStateToProps(state: StoreState, { exploreId }) { } const mapDispatchToProps = { - changeDatasource, changeSize, changeTime, - clearQueries, initializeExplore, modifyQueries, - runQueries, scanStart, scanStop, setQueries, - splitClose, - splitOpen, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore)); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx new file mode 100644 index 00000000000..35f06d11c81 --- /dev/null +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -0,0 +1,191 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; + +import { ExploreId } from 'app/types/explore'; +import { DataSourceSelectItem, RawTimeRange, TimeRange } from '@grafana/ui'; +import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; +import { StoreState } from 'app/types/store'; +import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions'; +import TimePicker from './TimePicker'; + +enum IconSide { + left = 'left', + right = 'right', +} + +const createResponsiveButton = (options: { + splitted: boolean; + title: string; + onClick: () => void; + buttonClassName?: string; + iconClassName?: string; + iconSide?: IconSide; +}) => { + const defaultOptions = { + iconSide: IconSide.left, + }; + const props = { ...options, defaultOptions }; + const { title, onClick, buttonClassName, iconClassName, splitted, iconSide } = props; + + return ( + + ); +}; + +interface OwnProps { + exploreId: ExploreId; + timepickerRef: React.RefObject; + onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void; +} + +interface StateProps { + datasourceMissing: boolean; + exploreDatasources: DataSourceSelectItem[]; + loading: boolean; + range: RawTimeRange; + selectedDatasource: DataSourceSelectItem; + splitted: boolean; +} + +interface DispatchProps { + changeDatasource: typeof changeDatasource; + clearAll: typeof clearQueries; + runQuery: typeof runQueries; + closeSplit: typeof splitClose; + split: typeof splitOpen; +} + +type Props = StateProps & DispatchProps & OwnProps; + +export class UnConnectedExploreToolbar extends PureComponent { + constructor(props) { + super(props); + } + + onChangeDatasource = async option => { + this.props.changeDatasource(this.props.exploreId, option.value); + }; + + onClearAll = () => { + this.props.clearAll(this.props.exploreId); + }; + + onRunQuery = () => { + this.props.runQuery(this.props.exploreId); + }; + + render() { + const { + datasourceMissing, + exploreDatasources, + exploreId, + loading, + range, + selectedDatasource, + splitted, + timepickerRef, + } = this.props; + + return ( +
+
+
+
+ {exploreId === 'left' && ( + + + Explore + + )} +
+
+ {exploreId === 'right' && ( + + + + )} +
+
+
+
+
+ {!datasourceMissing ? ( +
+
+ +
+
+ ) : null} + {exploreId === 'left' && !splitted ? ( +
+ {createResponsiveButton({ + splitted, + title: 'Split', + onClick: this.props.split, + iconClassName: 'fa fa-fw fa-columns icon-margin-right', + iconSide: IconSide.left, + })} +
+ ) : null} +
+ +
+
+ +
+
+ {createResponsiveButton({ + splitted, + title: 'Run Query', + onClick: this.onRunQuery, + buttonClassName: 'navbar-button--primary', + iconClassName: loading ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon', + iconSide: IconSide.right, + })} +
+
+
+
+ ); + } +} + +const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => { + const splitted = state.explore.split; + const exploreItem = state.explore[exploreId]; + const { datasourceInstance, datasourceMissing, exploreDatasources, queryTransactions, range } = exploreItem; + const selectedDatasource = datasourceInstance + ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name) + : undefined; + const loading = queryTransactions.some(qt => !qt.done); + + return { + datasourceMissing, + exploreDatasources, + loading, + range, + selectedDatasource, + splitted, + }; +}; + +const mapDispatchToProps: DispatchProps = { + changeDatasource, + clearAll: clearQueries, + runQuery: runQueries, + closeSplit: splitClose, + split: splitOpen, +}; + +export const ExploreToolbar = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedExploreToolbar)); diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx index e2610bcc781..c3a92f4007e 100644 --- a/public/app/features/explore/GraphContainer.tsx +++ b/public/app/features/explore/GraphContainer.tsx @@ -30,6 +30,11 @@ export class GraphContainer extends PureComponent { render() { const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props; const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + + if (!graphResult) { + return null; + } + return ( LogRowModel[]; + label: string; + plain?: boolean; + value: string; + onClickLabel?: (label: string, value: string) => void; +} + +interface State { + showStats: boolean; + stats: LogLabelStatsModel[]; +} + +export class LogLabel extends PureComponent { + state = { + stats: null, + showStats: false, + }; + + onClickClose = () => { + this.setState({ showStats: false }); + }; + + onClickLabel = () => { + const { onClickLabel, label, value } = this.props; + if (onClickLabel) { + onClickLabel(label, value); + } + }; + + onClickStats = () => { + this.setState(state => { + if (state.showStats) { + return { showStats: false, stats: null }; + } + const allRows = this.props.getRows(); + const stats = calculateLogsLabelStats(allRows, this.props.label); + return { showStats: true, stats }; + }); + }; + + render() { + const { getRows, label, plain, value } = this.props; + const { showStats, stats } = this.state; + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + {!plain && ( + + )} + {!plain && getRows && } + {showStats && ( + + + + )} + + ); + } +} diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx new file mode 100644 index 00000000000..b0bd69170c5 --- /dev/null +++ b/public/app/features/explore/LogLabelStats.tsx @@ -0,0 +1,72 @@ +import React, { PureComponent } from 'react'; +import classnames from 'classnames'; +import { LogLabelStatsModel } from 'app/core/logs_model'; + +function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) { + const { active, count, proportion, value } = logLabelStatsModel; + const percent = `${Math.round(proportion * 100)}%`; + const barStyle = { width: percent }; + const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); + + return ( +
+
+
{value}
+
{count}
+
{percent}
+
+
+
+
+
+ ); +} + +const STATS_ROW_LIMIT = 5; + +interface Props { + stats: LogLabelStatsModel[]; + label: string; + value: string; + rowCount: number; + onClickClose: () => void; +} + +export class LogLabelStats extends PureComponent { + render() { + const { label, rowCount, stats, value, onClickClose } = this.props; + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find(row => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find(row => row.value === value); + otherRows = otherRows.filter(row => row.value !== value); + } + + const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); + const topCount = topRows.reduce((sum, row) => sum + row.count, 0); + const total = topCount + otherCount; + const otherProportion = otherCount / total; + + return ( +
+
+ + {label}: {total} of {rowCount} rows have that label + + +
+
+ {topRows.map(stat => )} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
+ ); + } +} diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx index 7675fb13152..7105a2a5370 100644 --- a/public/app/features/explore/LogLabels.tsx +++ b/public/app/features/explore/LogLabels.tsx @@ -1,147 +1,20 @@ import React, { PureComponent } from 'react'; -import classnames from 'classnames'; -import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model'; +import { LogsStreamLabels, LogRowModel } from 'app/core/logs_model'; +import { LogLabel } from './LogLabel'; -function StatsRow({ active, count, proportion, value }: LogsLabelStat) { - const percent = `${Math.round(proportion * 100)}%`; - const barStyle = { width: percent }; - const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); - - return ( -
-
-
{value}
-
{count}
-
{percent}
-
-
-
-
-
- ); -} - -const STATS_ROW_LIMIT = 5; -export class Stats extends PureComponent<{ - stats: LogsLabelStat[]; - label: string; - value: string; - rowCount: number; - onClickClose: () => void; -}> { - render() { - const { label, rowCount, stats, value, onClickClose } = this.props; - const topRows = stats.slice(0, STATS_ROW_LIMIT); - let activeRow = topRows.find(row => row.value === value); - let otherRows = stats.slice(STATS_ROW_LIMIT); - const insertActiveRow = !activeRow; - // Remove active row from other to show extra - if (insertActiveRow) { - activeRow = otherRows.find(row => row.value === value); - otherRows = otherRows.filter(row => row.value !== value); - } - const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); - const topCount = topRows.reduce((sum, row) => sum + row.count, 0); - const total = topCount + otherCount; - const otherProportion = otherCount / total; - - return ( -
-
- - {label}: {total} of {rowCount} rows have that label - - -
-
- {topRows.map(stat => )} - {insertActiveRow && activeRow && } - {otherCount > 0 && ( - - )} -
-
- ); - } -} - -class Label extends PureComponent< - { - getRows?: () => LogRow[]; - label: string; - plain?: boolean; - value: string; - onClickLabel?: (label: string, value: string) => void; - }, - { showStats: boolean; stats: LogsLabelStat[] } -> { - state = { - stats: null, - showStats: false, - }; - - onClickClose = () => { - this.setState({ showStats: false }); - }; - - onClickLabel = () => { - const { onClickLabel, label, value } = this.props; - if (onClickLabel) { - onClickLabel(label, value); - } - }; - - onClickStats = () => { - this.setState(state => { - if (state.showStats) { - return { showStats: false, stats: null }; - } - const allRows = this.props.getRows(); - const stats = calculateLogsLabelStats(allRows, this.props.label); - return { showStats: true, stats }; - }); - }; - - render() { - const { getRows, label, plain, value } = this.props; - const { showStats, stats } = this.state; - const tooltip = `${label}: ${value}`; - return ( - - - {value} - - {!plain && ( - - )} - {!plain && getRows && } - {showStats && ( - - - - )} - - ); - } -} - -export default class LogLabels extends PureComponent<{ - getRows?: () => LogRow[]; +interface Props { + getRows?: () => LogRowModel[]; labels: LogsStreamLabels; plain?: boolean; onClickLabel?: (label: string, value: string) => void; -}> { +} + +export class LogLabels extends PureComponent { render() { const { getRows, labels, onClickLabel, plain } = this.props; return Object.keys(labels).map(key => ( -