diff --git a/.circleci/config.yml b/.circleci/config.yml index f6d66daab84..7b5a9e7923b 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 @@ -164,7 +164,7 @@ jobs: 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 +233,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 +265,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a691465626..67acea4e149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,34 +3,42 @@ ### 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](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`. +* **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/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/conf/defaults.ini b/conf/defaults.ini index 37e1ee2c7df..6fc4cf2e4de 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 diff --git a/conf/sample.ini b/conf/sample.ini index 96b92db6f48..0f1c02dc231 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 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/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/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index eb50944ad35..12b5ff8062e 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -7,10 +7,10 @@ interface Props { autoHide?: boolean; autoHideTimeout?: number; autoHideDuration?: number; - autoMaxHeight?: string; + autoHeightMax?: string; hideTracksWhenNotNeeded?: boolean; scrollTop?: number; - setScrollTop: (value: React.MouseEvent) => void; + setScrollTop: (event: any) => void; autoHeightMin?: number | string; } @@ -20,13 +20,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: () => {}, - autoHeightMin: '0' + hideTracksWhenNotNeeded: false, + autoHeightMin: '0', + autoHeightMax: '100%', }; private ref: React.RefObject; @@ -45,7 +45,7 @@ export class CustomScrollbar extends PureComponent { } else { ref.scrollTop(this.props.scrollTop); } - } + } } componentDidMount() { @@ -57,16 +57,30 @@ export class CustomScrollbar extends PureComponent { } render() { - const { customClassName, children, autoMaxHeight } = this.props; + const { + customClassName, + children, + autoHeightMax, + autoHeightMin, + setScrollTop, + autoHide, + autoHideTimeout, + hideTracksWhenNotNeeded, + } = this.props; return (
} 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/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/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/dashboard.go b/pkg/api/dashboard.go index 5959c230fb9..2789b0bf51e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { "id": 123123, "gridPos": map[string]interface{}{ "x": 0, - "y": 0, + "y": 3, "w": 24, "h": 4, }, 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 1e4bc96cd7f..660a00ba41d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -83,9 +83,6 @@ var ( // Security settings. SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string DisableGravatar bool EmailCodeValidMinutes int DataProxyWhiteList map[string]bool @@ -224,6 +221,13 @@ type Cfg struct { EnableAlphaPanels bool DisableSanitizeHtml bool EnterpriseLicensePath string + + LoginCookieName string + LoginCookieMaxDays int + LoginCookieRotation int + LoginDeleteExpiredTokensAfterDays int + + SecurityHTTPSCookies bool } type CommandLineArgs struct { @@ -547,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) @@ -587,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 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 4b930d605e5..db2172039c6 100644 --- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx +++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx @@ -10,7 +10,7 @@ const SideMenuDropDown: FC = props => { return (
  • - + {link.text}
  • 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 0e1a1486422..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,7 +8,9 @@ exports[`Render should render children 1`] = `
  • - + @@ -51,7 +53,9 @@ exports[`Render should render component 1`] = `
  • - + 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/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/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/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/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/dashgrid/RowOptions.ts b/public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts similarity index 100% rename from public/app/features/dashboard/dashgrid/RowOptions.ts rename to public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts diff --git a/public/app/features/dashboard/components/RowOptions/index.ts b/public/app/features/dashboard/components/RowOptions/index.ts new file mode 100644 index 00000000000..626e4cd65b3 --- /dev/null +++ b/public/app/features/dashboard/components/RowOptions/index.ts @@ -0,0 +1 @@ +export { RowOptionsCtrl } from './RowOptionsCtrl'; diff --git a/public/app/features/dashboard/partials/row_options.html b/public/app/features/dashboard/components/RowOptions/template.html similarity index 100% rename from public/app/features/dashboard/partials/row_options.html rename to public/app/features/dashboard/components/RowOptions/template.html diff --git a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts index dd8dac31dde..3181231cb53 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts +++ b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts @@ -1,5 +1,5 @@ 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', () => { 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 6b6f81fc579..01f790f73ee 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/index.ts b/public/app/features/dashboard/index.ts index 79831cd4015..efa54f0ee07 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -1,9 +1,6 @@ import './dashboard_ctrl'; import './time_srv'; -import './repeat_option/repeat_option'; import './dashgrid/DashboardGridDirective'; -import './dashgrid/RowOptions'; -import './panellinks/module'; // Services import './services/DashboardViewStateSrv'; @@ -25,6 +22,7 @@ import './components/UnsavedChangesModal'; import './components/SaveModals'; import './components/ShareModal'; import './components/AdHocFilters'; +import './components/RowOptions'; import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions'; 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/services/DashboardViewStateSrv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts index aee6746ff36..20215017e1d 100644 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts @@ -58,7 +58,6 @@ describe('when updating view state', () => { 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/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts index cb9794d6abb..816b6d8bd2d 100644 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts @@ -72,7 +72,6 @@ export class DashboardViewStateSrv { } _.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 DashboardViewStateSrv { } 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; } 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/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 24b8b8f5b16..d27213cea34 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -73,6 +73,7 @@ export class QueryField extends React.PureComponent { - this.setState({ - suggestions: [], - typeaheadIndex: 0, - typeaheadPrefix: '', - typeaheadContext: null, - }); - this.resetTimer = null; + if (this.mounted) { + this.setState({ + suggestions: [], + typeaheadIndex: 0, + typeaheadPrefix: '', + typeaheadContext: null, + }); + this.resetTimer = null; + } }; handleBlur = () => { diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index 8476c6b2b27..38c3f2e7498 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -293,6 +293,7 @@ export default class TimePicker extends PureComponent
    diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 7ea8f228af8..aca2e6d8cbd 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -7,14 +7,16 @@ import { StoreState } from 'app/types'; import { ExploreId, ExploreUrlState } from 'app/types/explore'; import { parseUrlState } from 'app/core/utils/explore'; -import { initializeExploreSplit } from './state/actions'; +import { initializeExploreSplit, resetExplore } from './state/actions'; import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; +import { CustomScrollbar } from '@grafana/ui'; interface WrapperProps { initializeExploreSplit: typeof initializeExploreSplit; split: boolean; updateLocation: typeof updateLocation; + resetExplore: typeof resetExplore; urlStates: { [key: string]: string }; } @@ -41,20 +43,28 @@ export class Wrapper extends Component { } } + componentWillUnmount() { + this.props.resetExplore(); + } + render() { const { split } = this.props; const { leftState, rightState } = this.urlStates; return ( -
    - - - - {split && ( - - - - )} +
    + +
    + + + + {split && ( + + + + )} +
    +
    ); } @@ -69,6 +79,7 @@ const mapStateToProps = (state: StoreState) => { const mapDispatchToProps = { initializeExploreSplit, updateLocation, + resetExplore, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper)); diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 0de3981b40e..d768da6db6b 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -1,6 +1,6 @@ // Types import { Emitter } from 'app/core/core'; -import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi } from '@grafana/ui/src/types'; +import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi } from '@grafana/ui/src/types'; import { ExploreId, ExploreItemState, @@ -42,6 +42,7 @@ export enum ActionTypes { ToggleLogs = 'explore/TOGGLE_LOGS', ToggleTable = 'explore/TOGGLE_TABLE', UpdateDatasourceInstance = 'explore/UPDATE_DATASOURCE_INSTANCE', + ResetExplore = 'explore/RESET_EXPLORE', } export interface AddQueryRowAction { @@ -279,6 +280,11 @@ export interface UpdateDatasourceInstanceAction { }; } +export interface ResetExploreAction { + type: ActionTypes.ResetExplore; + payload: {}; +} + export type Action = | AddQueryRowAction | ChangeQueryAction @@ -307,4 +313,5 @@ export type Action = | ToggleGraphAction | ToggleLogsAction | ToggleTableAction - | UpdateDatasourceInstanceAction; + | UpdateDatasourceInstanceAction + | ResetExploreAction; diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index c653f5492f8..35e81bb8e5b 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -783,3 +783,12 @@ export function toggleTable(exploreId: ExploreId): ThunkResult { } }; } + +/** + * Resets state for explore. + */ +export function resetExplore(): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.ResetExplore, payload: {} }); + }; +} diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index f65f2e26ee6..d3cc81f96a8 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -436,25 +436,19 @@ export const itemReducer = (state, action: Action): ExploreItemState => { export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => { switch (action.type) { case ActionTypes.SplitClose: { - return { - ...state, - split: false, - }; + return { ...state, split: false }; } case ActionTypes.SplitOpen: { - return { - ...state, - split: true, - right: action.payload.itemState, - }; + return { ...state, split: true, right: action.payload.itemState }; } case ActionTypes.InitializeExploreSplit: { - return { - ...state, - split: true, - }; + return { ...state, split: true }; + } + + case ActionTypes.ResetExplore: { + return initialExploreState; } } diff --git a/public/app/features/manage-dashboards/CreateFolderCtrl.ts b/public/app/features/folders/CreateFolderCtrl.ts similarity index 96% rename from public/app/features/manage-dashboards/CreateFolderCtrl.ts rename to public/app/features/folders/CreateFolderCtrl.ts index 99b2e8d4853..db70c2a18a2 100644 --- a/public/app/features/manage-dashboards/CreateFolderCtrl.ts +++ b/public/app/features/folders/CreateFolderCtrl.ts @@ -1,7 +1,7 @@ import appEvents from 'app/core/app_events'; import locationUtil from 'app/core/utils/location_util'; -export class CreateFolderCtrl { +export default class CreateFolderCtrl { title = ''; navModel: any; titleTouched = false; @@ -38,3 +38,4 @@ export class CreateFolderCtrl { }); } } + diff --git a/public/app/features/manage-dashboards/FolderDashboardsCtrl.ts b/public/app/features/folders/FolderDashboardsCtrl.ts similarity index 93% rename from public/app/features/manage-dashboards/FolderDashboardsCtrl.ts rename to public/app/features/folders/FolderDashboardsCtrl.ts index 6241472432c..95ff355937b 100644 --- a/public/app/features/manage-dashboards/FolderDashboardsCtrl.ts +++ b/public/app/features/folders/FolderDashboardsCtrl.ts @@ -1,7 +1,7 @@ import { FolderPageLoader } from './services/FolderPageLoader'; import locationUtil from 'app/core/utils/location_util'; -export class FolderDashboardsCtrl { +export default class FolderDashboardsCtrl { navModel: any; folderId: number; uid: string; @@ -23,3 +23,4 @@ export class FolderDashboardsCtrl { } } } + diff --git a/public/app/features/dashboard/partials/create_folder.html b/public/app/features/folders/partials/create_folder.html similarity index 100% rename from public/app/features/dashboard/partials/create_folder.html rename to public/app/features/folders/partials/create_folder.html diff --git a/public/app/features/dashboard/partials/folder_dashboards.html b/public/app/features/folders/partials/folder_dashboards.html similarity index 100% rename from public/app/features/dashboard/partials/folder_dashboards.html rename to public/app/features/folders/partials/folder_dashboards.html diff --git a/public/app/features/manage-dashboards/services/FolderPageLoader.ts b/public/app/features/folders/services/FolderPageLoader.ts similarity index 100% rename from public/app/features/manage-dashboards/services/FolderPageLoader.ts rename to public/app/features/folders/services/FolderPageLoader.ts diff --git a/public/app/features/manage-dashboards/DashboardImportCtrl.ts b/public/app/features/manage-dashboards/DashboardImportCtrl.ts index 455fa682edd..d2c6584d13d 100644 --- a/public/app/features/manage-dashboards/DashboardImportCtrl.ts +++ b/public/app/features/manage-dashboards/DashboardImportCtrl.ts @@ -232,3 +232,5 @@ export class DashboardImportCtrl { this.gnetInfo = ''; } } + +export default DashboardImportCtrl; diff --git a/public/app/features/manage-dashboards/index.ts b/public/app/features/manage-dashboards/index.ts index c3830ee6546..9d7c2bbc811 100644 --- a/public/app/features/manage-dashboards/index.ts +++ b/public/app/features/manage-dashboards/index.ts @@ -8,14 +8,8 @@ export * from './components/UploadDashboard'; // Controllers import { DashboardListCtrl } from './DashboardListCtrl'; import { SnapshotListCtrl } from './SnapshotListCtrl'; -import { FolderDashboardsCtrl } from './FolderDashboardsCtrl'; -import { DashboardImportCtrl } from './DashboardImportCtrl'; -import { CreateFolderCtrl } from './CreateFolderCtrl'; import coreModule from 'app/core/core_module'; coreModule.controller('DashboardListCtrl', DashboardListCtrl); coreModule.controller('SnapshotListCtrl', SnapshotListCtrl); -coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl); -coreModule.controller('DashboardImportCtrl', DashboardImportCtrl); -coreModule.controller('CreateFolderCtrl', CreateFolderCtrl); diff --git a/public/app/features/dashboard/partials/dashboard_import.html b/public/app/features/manage-dashboards/partials/dashboard_import.html similarity index 100% rename from public/app/features/dashboard/partials/dashboard_import.html rename to public/app/features/manage-dashboards/partials/dashboard_import.html diff --git a/public/app/features/panel/all.ts b/public/app/features/panel/all.ts index d461b491897..8bc2822a77c 100644 --- a/public/app/features/panel/all.ts +++ b/public/app/features/panel/all.ts @@ -4,3 +4,5 @@ import './solo_panel_ctrl'; import './query_ctrl'; import './panel_editor_tab'; import './query_editor_row'; +import './repeat_option'; +import './panellinks/module'; diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index f68423315d7..2f1ef72cedd 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -290,17 +290,4 @@ export class PanelCtrl { html += '
    '; return sanitize(html); } - - openInspector() { - const modalScope = this.$scope.$new(); - modalScope.panel = this.panel; - modalScope.dashboard = this.dashboard; - modalScope.panelInfoHtml = this.getInfoContent({ mode: 'inspector' }); - - modalScope.inspector = $.extend(true, {}, this.inspector); - this.publishAppEvent('show-modal', { - src: 'public/app/features/dashboard/partials/inspector.html', - scope: modalScope, - }); - } } diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index f503aa4386d..1fd0b129720 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -192,11 +192,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo); scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo); - cornerInfoElem.on('click', () => { - infoDrop.close(); - scope.$apply(ctrl.openInspector.bind(ctrl)); - }); - elem.on('mouseenter', mouseEnter); elem.on('mouseleave', mouseLeave); diff --git a/public/app/features/dashboard/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts similarity index 100% rename from public/app/features/dashboard/panellinks/link_srv.ts rename to public/app/features/panel/panellinks/link_srv.ts diff --git a/public/app/features/dashboard/panellinks/module.html b/public/app/features/panel/panellinks/module.html similarity index 100% rename from public/app/features/dashboard/panellinks/module.html rename to public/app/features/panel/panellinks/module.html diff --git a/public/app/features/dashboard/panellinks/module.ts b/public/app/features/panel/panellinks/module.ts similarity index 100% rename from public/app/features/dashboard/panellinks/module.ts rename to public/app/features/panel/panellinks/module.ts diff --git a/public/app/features/dashboard/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts similarity index 100% rename from public/app/features/dashboard/panellinks/specs/link_srv.test.ts rename to public/app/features/panel/panellinks/specs/link_srv.test.ts diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/panel/repeat_option.ts similarity index 100% rename from public/app/features/dashboard/repeat_option/repeat_option.ts rename to public/app/features/panel/repeat_option.ts diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts index 4288b5f3928..30faffea3be 100644 --- a/public/app/features/templating/specs/template_srv.test.ts +++ b/public/app/features/templating/specs/template_srv.test.ts @@ -469,6 +469,11 @@ describe('templateSrv', () => { name: 'empty_on_init', current: { value: '', text: '' }, }, + { + type: 'custom', + name: 'foo', + current: { value: 'constructor', text: 'constructor' }, + } ]); _templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m'); _templateSrv.updateTemplateData(); @@ -483,6 +488,12 @@ describe('templateSrv', () => { const target = _templateSrv.replaceWithText('Hello $empty_on_init'); expect(target).toBe('Hello '); }); + + it('should not return a string representation of a constructor property', () => { + const target = _templateSrv.replaceWithText('$foo'); + expect(target).not.toBe('function Object() { [native code] }'); + expect(target).toBe('constructor'); + }); }); describe('built in interval variables', () => { diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 07656924c9c..2f8068137e5 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -254,7 +254,9 @@ export class TemplateSrv { return match; } - return this.grafanaVariables[variable.current.value] || variable.current.text; + const value = this.grafanaVariables[variable.current.value]; + + return typeof(value) === 'string' ? value : variable.current.text; }); } diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index b8e24616f0a..2618120e1fb 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import $ from 'jquery'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.gauge'; -import 'app/features/dashboard/panellinks/link_srv'; +import 'app/features/panel/panellinks/link_srv'; import kbn from 'app/core/utils/kbn'; import config from 'app/core/config'; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 8552d0510a9..e7381740435 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -10,6 +10,9 @@ import ApiKeys from 'app/features/api-keys/ApiKeysPage'; import PluginListPage from 'app/features/plugins/PluginListPage'; import FolderSettingsPage from 'app/features/folders/FolderSettingsPage'; import FolderPermissions from 'app/features/folders/FolderPermissions'; +import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl'; +import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl'; +import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl'; import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage'; import NewDataSourcePage from '../features/datasources/NewDataSourcePage'; import UsersListPage from 'app/features/users/UsersListPage'; @@ -66,8 +69,8 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { pageClass: 'page-dashboard', }) .when('/dashboard/import', { - templateUrl: 'public/app/features/dashboard/partials/dashboard_import.html', - controller: 'DashboardImportCtrl', + templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html', + controller: DashboardImportCtrl, controllerAs: 'ctrl', }) .when('/datasources', { @@ -100,8 +103,8 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { controllerAs: 'ctrl', }) .when('/dashboards/folder/new', { - templateUrl: 'public/app/features/dashboard/partials/create_folder.html', - controller: 'CreateFolderCtrl', + templateUrl: 'public/app/features/folders/partials/create_folder.html', + controller: CreateFolderCtrl, controllerAs: 'ctrl', }) .when('/dashboards/f/:uid/:slug/permissions', { @@ -117,8 +120,8 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }, }) .when('/dashboards/f/:uid/:slug', { - templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html', - controller: 'FolderDashboardsCtrl', + templateUrl: 'public/app/features/folders/partials/folder_dashboards.html', + controller: FolderDashboardsCtrl, controllerAs: 'ctrl', }) .when('/dashboards/f/:uid', { diff --git a/public/dashboards/home.json b/public/dashboards/home.json index 17795d64aa3..f2c441053bb 100644 --- a/public/dashboards/home.json +++ b/public/dashboards/home.json @@ -10,6 +10,23 @@ "id": null, "links": [], "panels": [ + { + "content": "
    \n Home Dashboard\n
    ", + "editable": true, + "id": 1, + "links": [], + "mode": "html", + "style": {}, + "title": "", + "transparent": true, + "type": "text", + "gridPos": { + "w": 24, + "h": 3, + "x": 0, + "y": 0 + } + }, { "folderId": 0, "headings": true, @@ -28,7 +45,7 @@ "w": 12, "h": 17, "x": 0, - "y": 1 + "y": 6 } }, { @@ -43,7 +60,7 @@ "w": 12, "h": 17, "x": 12, - "y": 1 + "y": 6 } } ], diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 9e74b343b2d..739ccb6c412 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -39,6 +39,7 @@ @import 'layout/page'; // COMPONENTS +@import '../app/features/dashboard/components/AddPanelWidget/AddPanelWidget'; @import 'components/scrollbar'; @import 'components/cards'; @import 'components/buttons'; @@ -58,7 +59,6 @@ @import 'components/panel_table'; @import 'components/panel_text'; @import 'components/panel_heatmap'; -@import 'components/panel_add_panel'; @import 'components/panel_logs'; @import 'components/settings_permissions'; @import 'components/tagsinput'; diff --git a/public/sass/components/_sidemenu.scss b/public/sass/components/_sidemenu.scss index 10565a4e0c7..44941540598 100644 --- a/public/sass/components/_sidemenu.scss +++ b/public/sass/components/_sidemenu.scss @@ -149,12 +149,19 @@ color: #ebedf2; } -.side-menu-header > a { +.side-menu-header-link { // Removes left-brand-border-gradient from link + color: #ebedf2 !important; border: none !important; padding: 0 !important; } +.dropdown-menu--sidemenu > li > .side-menu-header-link:hover { + // Makes sure it looks good on light theme + color: #fff !important; + background-color: $side-menu-item-hover-bg !important; +} + .sidemenu-subtitle { padding: 0.5rem 1rem 0.5rem; font-size: $font-size-sm; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index abd13a10368..db542530885 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -1,182 +1,326 @@ -.explore { - flex: 1 1 auto; +.icon-margin-right { + margin-right: 0.25em; +} - &-container { - padding: $dashboard-padding; +.icon-margin-left { + margin-left: 0.25em; +} + +.run-icon { + transform: rotate(90deg); +} + +.timepicker { + display: flex; +} + +.timepicker-rangestring { + margin-left: 0.5em; +} + +.datasource-picker { + .ds-picker { + min-width: 200px; + max-width: 200px; + } +} + +.sidemenu-open { + .explore-toolbar-header { + padding: 0; + margin-left: 0; } - &-wrapper { - display: flex; - - > .explore-split { - width: 50%; + .explore-toolbar-header-title { + .navbar-page-btn { + padding-left: 0; } } +} - // Push split button a bit - .explore-first-button { - margin-left: 15px; +.explore-toolbar { + background: inherit; + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + height: auto; + padding: 0px $dashboard-padding; + border-bottom: 1px solid #0000; + transition-duration: 0.35s; + transition-timing-function: ease-in-out; + transition-property: box-shadow, border-bottom; +} + +.explore-toolbar-item { + position: relative; + align-self: center; +} + +.explore-toolbar.splitted { + .explore-toolbar-item { + flex: 1 1 100%; } - .explore-panel { - margin-top: $panel-margin; + .explore-toolbar-content-item:first-child { + padding-left: 0; + margin-right: auto; } +} - .explore-panel__body { - padding: $panel-padding; - } +.explore-toolbar-item:last-child { + flex: auto; +} - .explore-panel__header { - padding: $panel-padding; - padding-top: 5px; - padding-bottom: 0; - display: flex; - cursor: pointer; - margin-bottom: 5px; - transition: all 0.1s linear; - } +.explore-toolbar-header { + display: flex; + flex: 1 1 0; + flex-flow: row nowrap; + font-size: 18px; + min-height: 55px; + line-height: 55px; + justify-content: space-between; + margin-left: $panel-margin * 3; +} - .explore-panel__header-label { - font-weight: 500; - margin-right: $panel-margin; - font-size: $font-size-h6; - box-shadow: $text-shadow-faint; - } +.explore-toolbar-header { + justify-content: space-between; + align-items: center; +} - .explore-panel__header-buttons { - margin-right: $panel-margin; - font-size: $font-size-lg; - line-height: $font-size-h6; - } - - // Make sure wrap buttons around on small screens - .navbar { - flex-wrap: wrap; - height: auto; - } +.explore-toolbar-header-title { + color: darken($link-color, 5%); .navbar-page-btn { - margin-right: 1rem; + padding-left: $dashboard-padding; + } - // Explore icon in header - .fa { - font-size: 100%; - opacity: 0.75; - margin-right: 0.5em; + .fa { + font-size: 100%; + opacity: 0.75; + margin-right: 0.5em; + } +} + +.explore-toolbar-header-close { + margin-left: auto; +} + +.explore-toolbar-content { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; +} + +.explore-toolbar-content-item { + padding: 10px 2px; +} + +.explore-toolbar-content-item:first-child { + padding-left: $dashboard-padding; + margin-right: auto; +} + +@media only screen and (max-width: 1545px) { + .explore-toolbar.splitted { + .timepicker-rangestring { + display: none; } } +} - // Toggle mode - .navbar-button.active { - color: $btn-active-text-color; - background-color: $btn-active-bg; - } - - .navbar-button--no-icon { - line-height: 18px; - } - - .result-options { - margin: 2 * $panel-margin 0; - } - - .time-series-disclaimer { - width: 300px; - margin: $panel-margin auto; - padding: 10px 0; - border-radius: $border-radius; - text-align: center; - background-color: $panel-bg; - - .disclaimer-icon { - color: $yellow; - margin-right: $panel-margin/2; - } - - .show-all-time-series { - cursor: pointer; - color: $external-link-color; - } - } - - .navbar .elapsed-time { - position: absolute; - left: 0; - right: 0; - top: 3.5rem; - text-align: center; - font-size: 0.8rem; - } - - .graph-legend { - flex-wrap: wrap; - } - - .explore-panel__loader { - height: 2px; - position: relative; - overflow: hidden; - background: none; - margin: $panel-margin / 2; - transition: background-color 1s ease; - } - - .explore-panel__loader--active { - background: $text-color-faint; - } - - .explore-panel__loader--active:after { - content: ' '; - display: block; - width: 25%; - top: 0; - top: -50%; - height: 250%; - position: absolute; - animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67); - animation-iteration-count: 100; - background: $blue; - } - - @keyframes loader { - from { - left: -25%; - } - to { - left: 100%; - } - } - - .datasource-picker { - min-width: 200px; - } - +@media only screen and (max-width: 1070px) { .timepicker { - display: flex; - - &-rangestring { - margin-left: 0.5em; + .timepicker-rangestring { + display: none; } } - .run-icon { - margin-left: 0.25em; - transform: rotate(90deg); + .explore-toolbar-content { + justify-content: flex-start; } - .relative { - position: relative; + .explore-toolbar.splitted { + .explore-toolbar-content-item { + padding: 2px 0; + margin: 0; + } } - .link { - text-decoration: underline; + .explore-toolbar-content-item { + padding: 2px 2px; } } +@media only screen and (max-width: 803px) { + .sidemenu-open { + .explore-toolbar-header-title { + .navbar-page-btn { + padding-left: 0; + margin-left: 0; + } + } + } + + .explore-toolbar-header-title { + .navbar-page-btn { + padding-left: 0; + margin-left: $dashboard-padding; + } + } + + .btn-title { + display: none; + } +} + +@media only screen and (max-width: 702px) { + .explore-toolbar-content-item:first-child { + padding-left: 2px; + margin-right: 0; + } +} + +@media only screen and (max-width: 544px) { + .sidemenu-open { + .explore-toolbar-header-title { + .navbar-page-btn { + padding-left: 0; + margin-left: $dashboard-padding; + } + } + } + + .explore-toolbar-header-title { + .navbar-page-btn { + padding-left: 0; + margin-left: $dashboard-padding; + } + } +} + +.explore { + flex: 1 1 auto; +} + .explore + .explore { border-left: 1px dotted $table-border; } +.explore-container { + padding: $dashboard-padding; +} + +.explore-wrapper { + display: flex; + + > .explore-split { + width: 50%; + } +} + +.explore-panel { + margin-top: $panel-margin; +} + +.explore-panel__body { + padding: $panel-padding; +} + +.explore-panel__header { + padding: $panel-padding; + padding-top: 5px; + padding-bottom: 0; + display: flex; + cursor: pointer; + margin-bottom: 5px; + transition: all 0.1s linear; +} + +.explore-panel__header-label { + font-weight: 500; + margin-right: $panel-margin; + font-size: $font-size-h6; + box-shadow: $text-shadow-faint; +} + +.explore-panel__header-buttons { + margin-right: $panel-margin; + font-size: $font-size-lg; + line-height: $font-size-h6; +} + +.result-options { + margin: 2 * $panel-margin 0; +} + +.time-series-disclaimer { + width: 300px; + margin: $panel-margin auto; + padding: 10px 0; + border-radius: $border-radius; + text-align: center; + background-color: $panel-bg; + + .disclaimer-icon { + color: $yellow; + margin-right: $panel-margin/2; + } + + .show-all-time-series { + cursor: pointer; + color: $external-link-color; + } +} + +.navbar .elapsed-time { + position: absolute; + left: 0; + right: 0; + top: 3.5rem; + text-align: center; + font-size: 0.8rem; +} + +.graph-legend { + flex-wrap: wrap; +} + +.explore-panel__loader { + height: 2px; + position: relative; + overflow: hidden; + background: none; + margin: $panel-margin / 2; + transition: background-color 1s ease; +} + +.explore-panel__loader--active { + background: $text-color-faint; +} + +.explore-panel__loader--active:after { + content: ' '; + display: block; + width: 25%; + top: 0; + top: -50%; + height: 250%; + position: absolute; + animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67); + animation-iteration-count: 100; + background: $blue; +} + +@keyframes loader { + from { + left: -25%; + } + to { + left: 100%; + } +} + .query-row { display: flex; position: relative; diff --git a/public/vendor/flot/jquery.flot.pie.js b/public/vendor/flot/jquery.flot.pie.js index 6553c8ea3a8..dee47e6e504 100644 --- a/public/vendor/flot/jquery.flot.pie.js +++ b/public/vendor/flot/jquery.flot.pie.js @@ -73,6 +73,7 @@ More detail and specific examples can be found in the included HTML file. centerLeft = null, centerTop = null, processed = false, + options = null, ctx = null; // interactive variables diff --git a/scripts/build/publish.go b/scripts/build/publish.go index d5b19877724..0cb776e2b99 100644 --- a/scripts/build/publish.go +++ b/scripts/build/publish.go @@ -15,7 +15,7 @@ import ( "time" ) -var apiUrl = flag.String("apiUrl", "https://grafana.com/api", "api url") +var apiURL = flag.String("apiUrl", "https://grafana.com/api", "api url") var apiKey = flag.String("apiKey", "", "api key") var version = "" var versionRe = regexp.MustCompile(`grafana-(.*)(\.|_)(arm64|armhfp|aarch64|armv7|darwin|linux|windows|x86_64)`) @@ -56,8 +56,8 @@ func main() { Stable: false, Nightly: true, Beta: false, - WhatsNewUrl: "", - ReleaseNotesUrl: "", + WhatsNewURL: "", + ReleaseNotesURL: "", Builds: builds, } @@ -118,7 +118,7 @@ func mapPackage(path string, name string, shaBytes []byte) (build, error) { return build{ Os: os, Arch: arch, - Url: "https://s3-us-west-2.amazonaws.com/grafana-releases/master/" + name, + URL: "https://s3-us-west-2.amazonaws.com/grafana-releases/master/" + name, Sha256: string(shaBytes), }, nil } @@ -148,7 +148,7 @@ func packageWalker(path string, f os.FileInfo, err error) error { func postRequest(url string, obj interface{}, desc string) { jsonBytes, _ := json.Marshal(obj) - req, _ := http.NewRequest(http.MethodPost, (*apiUrl)+url, bytes.NewReader(jsonBytes)) + req, _ := http.NewRequest(http.MethodPost, (*apiURL)+url, bytes.NewReader(jsonBytes)) req.Header.Add("Authorization", "Bearer "+(*apiKey)) req.Header.Add("Content-Type", "application/json") @@ -181,14 +181,14 @@ type release struct { Stable bool `json:"stable"` Beta bool `json:"beta"` Nightly bool `json:"nightly"` - WhatsNewUrl string `json:"whatsNewUrl"` - ReleaseNotesUrl string `json:"releaseNotesUrl"` + WhatsNewURL string `json:"whatsNewUrl"` + ReleaseNotesURL string `json:"releaseNotesUrl"` Builds []build `json:"-"` } type build struct { Os string `json:"os"` - Url string `json:"url"` + URL string `json:"url"` Sha256 string `json:"sha256"` Arch string `json:"arch"` } diff --git a/scripts/build/release_publisher/externalrelease.go b/scripts/build/release_publisher/externalrelease.go index 992cba38f90..64e879cace4 100644 --- a/scripts/build/release_publisher/externalrelease.go +++ b/scripts/build/release_publisher/externalrelease.go @@ -14,10 +14,10 @@ type releaseFromExternalContent struct { artifactConfigurations []buildArtifact } -func (re releaseFromExternalContent) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) { +func (re releaseFromExternalContent) prepareRelease(baseArchiveURL, whatsNewURL string, releaseNotesURL string, nightly bool) (*release, error) { version := re.rawVersion[1:] beta := strings.Contains(version, "beta") - var rt ReleaseType + var rt releaseType if beta { rt = BETA } else if nightly { @@ -28,11 +28,11 @@ func (re releaseFromExternalContent) prepareRelease(baseArchiveUrl, whatsNewUrl builds := []build{} for _, ba := range re.artifactConfigurations { - sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", ba.getUrl(baseArchiveUrl, version, rt))) + sha256, err := re.getter.getContents(fmt.Sprintf("%s.sha256", ba.getURL(baseArchiveURL, version, rt))) if err != nil { return nil, err } - builds = append(builds, newBuild(baseArchiveUrl, ba, version, rt, sha256)) + builds = append(builds, newBuild(baseArchiveURL, ba, version, rt, sha256)) } r := release{ @@ -41,8 +41,8 @@ func (re releaseFromExternalContent) prepareRelease(baseArchiveUrl, whatsNewUrl Stable: rt.stable(), Beta: rt.beta(), Nightly: rt.nightly(), - WhatsNewUrl: whatsNewUrl, - ReleaseNotesUrl: releaseNotesUrl, + WhatsNewURL: whatsNewURL, + ReleaseNotesURL: releaseNotesURL, Builds: builds, } return &r, nil @@ -52,9 +52,9 @@ type urlGetter interface { getContents(url string) (string, error) } -type getHttpContents struct{} +type getHTTPContents struct{} -func (getHttpContents) getContents(url string) (string, error) { +func (getHTTPContents) getContents(url string) (string, error) { response, err := http.Get(url) if err != nil { return "", err diff --git a/scripts/build/release_publisher/localrelease.go b/scripts/build/release_publisher/localrelease.go index 4f4575c4ff4..332654ee625 100644 --- a/scripts/build/release_publisher/localrelease.go +++ b/scripts/build/release_publisher/localrelease.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "github.com/pkg/errors" "io/ioutil" "log" "os" @@ -10,6 +9,8 @@ import ( "regexp" "strings" "time" + + "github.com/pkg/errors" ) type releaseLocalSources struct { @@ -17,11 +18,11 @@ type releaseLocalSources struct { artifactConfigurations []buildArtifact } -func (r releaseLocalSources) prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) { +func (r releaseLocalSources) prepareRelease(baseArchiveURL, whatsNewURL string, releaseNotesURL string, nightly bool) (*release, error) { if !nightly { - return nil, errors.New("Local releases only supported for nightly builds.") + return nil, errors.New("Local releases only supported for nightly builds") } - buildData := r.findBuilds(baseArchiveUrl) + buildData := r.findBuilds(baseArchiveURL) rel := release{ Version: buildData.version, @@ -29,8 +30,8 @@ func (r releaseLocalSources) prepareRelease(baseArchiveUrl, whatsNewUrl string, Stable: false, Beta: false, Nightly: nightly, - WhatsNewUrl: whatsNewUrl, - ReleaseNotesUrl: releaseNotesUrl, + WhatsNewURL: whatsNewURL, + ReleaseNotesURL: releaseNotesURL, Builds: buildData.builds, } @@ -42,13 +43,13 @@ type buildData struct { builds []build } -func (r releaseLocalSources) findBuilds(baseArchiveUrl string) buildData { +func (r releaseLocalSources) findBuilds(baseArchiveURL string) buildData { data := buildData{} - filepath.Walk(r.path, createBuildWalker(r.path, &data, r.artifactConfigurations, baseArchiveUrl)) + filepath.Walk(r.path, createBuildWalker(r.path, &data, r.artifactConfigurations, baseArchiveURL)) return data } -func createBuildWalker(path string, data *buildData, archiveTypes []buildArtifact, baseArchiveUrl string) func(path string, f os.FileInfo, err error) error { +func createBuildWalker(path string, data *buildData, archiveTypes []buildArtifact, baseArchiveURL string) func(path string, f os.FileInfo, err error) error { return func(path string, f os.FileInfo, err error) error { if err != nil { log.Printf("error: %v", err) @@ -73,7 +74,7 @@ func createBuildWalker(path string, data *buildData, archiveTypes []buildArtifac data.version = version data.builds = append(data.builds, build{ Os: archive.os, - Url: archive.getUrl(baseArchiveUrl, version, NIGHTLY), + URL: archive.getURL(baseArchiveURL, version, NIGHTLY), Sha256: string(shaBytes), Arch: archive.arch, }) @@ -90,5 +91,5 @@ func grabVersion(name string, suffix string) (string, error) { return string(match[2]), nil } - return "", errors.New("No version found.") + return "", errors.New("No version found") } diff --git a/scripts/build/release_publisher/main.go b/scripts/build/release_publisher/main.go index 90acb2d3e62..6e1c8f782f0 100644 --- a/scripts/build/release_publisher/main.go +++ b/scripts/build/release_publisher/main.go @@ -9,8 +9,8 @@ import ( func main() { var version string - var whatsNewUrl string - var releaseNotesUrl string + var whatsNewURL string + var releaseNotesURL string var dryRun bool var enterprise bool var fromLocal bool @@ -18,8 +18,8 @@ func main() { var apiKey string flag.StringVar(&version, "version", "", "Grafana version (ex: --version v5.2.0-beta1)") - flag.StringVar(&whatsNewUrl, "wn", "", "What's new url (ex: --wn http://docs.grafana.org/guides/whats-new-in-v5-2/)") - flag.StringVar(&releaseNotesUrl, "rn", "", "Grafana version (ex: --rn https://community.grafana.com/t/release-notes-v5-2-x/7894)") + flag.StringVar(&whatsNewURL, "wn", "", "What's new url (ex: --wn http://docs.grafana.org/guides/whats-new-in-v5-2/)") + flag.StringVar(&releaseNotesURL, "rn", "", "Grafana version (ex: --rn https://community.grafana.com/t/release-notes-v5-2-x/7894)") flag.StringVar(&apiKey, "apikey", "", "Grafana.com API key (ex: --apikey ABCDEF)") flag.BoolVar(&dryRun, "dry-run", false, "--dry-run") flag.BoolVar(&enterprise, "enterprise", false, "--enterprise") @@ -37,7 +37,7 @@ func main() { if dryRun { log.Println("Dry-run has been enabled.") } - var baseUrl string + var baseURL string var builder releaseBuilder var product string @@ -46,7 +46,7 @@ func main() { if enterprise { product = "grafana-enterprise" - baseUrl = createBaseUrl(archiveProviderRoot, "enterprise", product, nightly) + baseURL = createBaseURL(archiveProviderRoot, "enterprise", product, nightly) var err error buildArtifacts, err = filterBuildArtifacts([]artifactFilter{ {os: "deb", arch: "amd64"}, @@ -61,7 +61,7 @@ func main() { } else { product = "grafana" - baseUrl = createBaseUrl(archiveProviderRoot, "oss", product, nightly) + baseURL = createBaseURL(archiveProviderRoot, "oss", product, nightly) } if fromLocal { @@ -72,7 +72,7 @@ func main() { } } else { builder = releaseFromExternalContent{ - getter: getHttpContents{}, + getter: getHTTPContents{}, rawVersion: version, artifactConfigurations: buildArtifacts, } @@ -80,18 +80,18 @@ func main() { p := publisher{ apiKey: apiKey, - apiUri: "https://grafana.com/api", + apiURI: "https://grafana.com/api", product: product, dryRun: dryRun, enterprise: enterprise, - baseArchiveUrl: baseUrl, + baseArchiveURL: baseURL, builder: builder, } - if err := p.doRelease(whatsNewUrl, releaseNotesUrl, nightly); err != nil { + if err := p.doRelease(whatsNewURL, releaseNotesURL, nightly); err != nil { log.Fatalf("error: %v", err) } } -func createBaseUrl(root string, bucketName string, product string, nightly bool) string { +func createBaseURL(root string, bucketName string, product string, nightly bool) string { var subPath string if nightly { subPath = "master" diff --git a/scripts/build/release_publisher/publisher.go b/scripts/build/release_publisher/publisher.go index 1d93c1e306e..77b7e18b724 100644 --- a/scripts/build/release_publisher/publisher.go +++ b/scripts/build/release_publisher/publisher.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/pkg/errors" "io/ioutil" "log" "net/http" @@ -14,20 +13,20 @@ import ( type publisher struct { apiKey string - apiUri string + apiURI string product string dryRun bool enterprise bool - baseArchiveUrl string + baseArchiveURL string builder releaseBuilder } type releaseBuilder interface { - prepareRelease(baseArchiveUrl, whatsNewUrl string, releaseNotesUrl string, nightly bool) (*release, error) + prepareRelease(baseArchiveURL, whatsNewURL string, releaseNotesURL string, nightly bool) (*release, error) } -func (p *publisher) doRelease(whatsNewUrl string, releaseNotesUrl string, nightly bool) error { - currentRelease, err := p.builder.prepareRelease(p.baseArchiveUrl, whatsNewUrl, releaseNotesUrl, nightly) +func (p *publisher) doRelease(whatsNewURL string, releaseNotesURL string, nightly bool) error { + currentRelease, err := p.builder.prepareRelease(p.baseArchiveURL, whatsNewURL, releaseNotesURL, nightly) if err != nil { return err } @@ -62,23 +61,26 @@ func (p *publisher) postRelease(r *release) error { return nil } -type ReleaseType int +type releaseType int const ( - STABLE ReleaseType = iota + 1 + // STABLE is a release type constant + STABLE releaseType = iota + 1 + // BETA is a release type constant BETA + // NIGHTLY is a release type constant NIGHTLY ) -func (rt ReleaseType) beta() bool { +func (rt releaseType) beta() bool { return rt == BETA } -func (rt ReleaseType) stable() bool { +func (rt releaseType) stable() bool { return rt == STABLE } -func (rt ReleaseType) nightly() bool { +func (rt releaseType) nightly() bool { return rt == NIGHTLY } @@ -88,7 +90,7 @@ type buildArtifact struct { urlPostfix string } -func (t buildArtifact) getUrl(baseArchiveUrl, version string, releaseType ReleaseType) string { +func (t buildArtifact) getURL(baseArchiveURL, version string, releaseType releaseType) string { prefix := "-" rhelReleaseExtra := "" @@ -100,7 +102,7 @@ func (t buildArtifact) getUrl(baseArchiveUrl, version string, releaseType Releas rhelReleaseExtra = "-1" } - url := strings.Join([]string{baseArchiveUrl, prefix, version, rhelReleaseExtra, t.urlPostfix}, "") + url := strings.Join([]string{baseArchiveURL, prefix, version, rhelReleaseExtra, t.urlPostfix}, "") return url } @@ -181,23 +183,23 @@ func filterBuildArtifacts(filters []artifactFilter) ([]buildArtifact, error) { } if !matched { - return nil, errors.New(fmt.Sprintf("No buildArtifact for os=%v, arch=%v", f.os, f.arch)) + return nil, fmt.Errorf("No buildArtifact for os=%v, arch=%v", f.os, f.arch) } } return artifacts, nil } -func newBuild(baseArchiveUrl string, ba buildArtifact, version string, rt ReleaseType, sha256 string) build { +func newBuild(baseArchiveURL string, ba buildArtifact, version string, rt releaseType, sha256 string) build { return build{ Os: ba.os, - Url: ba.getUrl(baseArchiveUrl, version, rt), + URL: ba.getURL(baseArchiveURL, version, rt), Sha256: sha256, Arch: ba.arch, } } -func (p *publisher) apiUrl(url string) string { - return fmt.Sprintf("%s/%s%s", p.apiUri, p.product, url) +func (p *publisher) apiURL(url string) string { + return fmt.Sprintf("%s/%s%s", p.apiURI, p.product, url) } func (p *publisher) postRequest(url string, obj interface{}, desc string) error { @@ -207,12 +209,12 @@ func (p *publisher) postRequest(url string, obj interface{}, desc string) error } if p.dryRun { - log.Println(fmt.Sprintf("POST to %s:", p.apiUrl(url))) + log.Println(fmt.Sprintf("POST to %s:", p.apiURL(url))) log.Println(string(jsonBytes)) return nil } - req, err := http.NewRequest(http.MethodPost, p.apiUrl(url), bytes.NewReader(jsonBytes)) + req, err := http.NewRequest(http.MethodPost, p.apiURL(url), bytes.NewReader(jsonBytes)) if err != nil { return err } @@ -254,14 +256,14 @@ type release struct { Stable bool `json:"stable"` Beta bool `json:"beta"` Nightly bool `json:"nightly"` - WhatsNewUrl string `json:"whatsNewUrl"` - ReleaseNotesUrl string `json:"releaseNotesUrl"` + WhatsNewURL string `json:"whatsNewUrl"` + ReleaseNotesURL string `json:"releaseNotesUrl"` Builds []build `json:"-"` } type build struct { Os string `json:"os"` - Url string `json:"url"` + URL string `json:"url"` Sha256 string `json:"sha256"` Arch string `json:"arch"` } diff --git a/scripts/build/release_publisher/publisher_test.go b/scripts/build/release_publisher/publisher_test.go index 2aea55d5ee1..bd2bbdce60e 100644 --- a/scripts/build/release_publisher/publisher_test.go +++ b/scripts/build/release_publisher/publisher_test.go @@ -7,69 +7,69 @@ func TestPreparingReleaseFromRemote(t *testing.T) { cases := []struct { version string expectedVersion string - whatsNewUrl string - relNotesUrl string + whatsNewURL string + relNotesURL string nightly bool expectedBeta bool expectedStable bool expectedArch string expectedOs string - expectedUrl string - baseArchiveUrl string + expectedURL string + baseArchiveURL string buildArtifacts []buildArtifact }{ { version: "v5.2.0-beta1", expectedVersion: "5.2.0-beta1", - whatsNewUrl: "https://whatsnews.foo/", - relNotesUrl: "https://relnotes.foo/", + whatsNewURL: "https://whatsnews.foo/", + relNotesURL: "https://relnotes.foo/", nightly: false, expectedBeta: true, expectedStable: false, expectedArch: "amd64", expectedOs: "linux", - expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.0-beta1.linux-amd64.tar.gz", - baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.0-beta1.linux-amd64.tar.gz", + baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", buildArtifacts: []buildArtifact{{"linux", "amd64", ".linux-amd64.tar.gz"}}, }, { version: "v5.2.3", expectedVersion: "5.2.3", - whatsNewUrl: "https://whatsnews.foo/", - relNotesUrl: "https://relnotes.foo/", + whatsNewURL: "https://whatsnews.foo/", + relNotesURL: "https://relnotes.foo/", nightly: false, expectedBeta: false, expectedStable: true, expectedArch: "amd64", expectedOs: "rhel", - expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.3-1.x86_64.rpm", - baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.2.3-1.x86_64.rpm", + baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm"}}, }, { version: "v5.4.0-pre1asdf", expectedVersion: "5.4.0-pre1asdf", - whatsNewUrl: "https://whatsnews.foo/", - relNotesUrl: "https://relnotes.foo/", + whatsNewURL: "https://whatsnews.foo/", + relNotesURL: "https://relnotes.foo/", nightly: true, expectedBeta: false, expectedStable: false, expectedArch: "amd64", expectedOs: "rhel", - expectedUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0-pre1asdf.x86_64.rpm", - baseArchiveUrl: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", + expectedURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.4.0-pre1asdf.x86_64.rpm", + baseArchiveURL: "https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana", buildArtifacts: []buildArtifact{{"rhel", "amd64", ".x86_64.rpm"}}, }, } for _, test := range cases { builder := releaseFromExternalContent{ - getter: mockHttpGetter{}, + getter: mockHTTPGetter{}, rawVersion: test.version, artifactConfigurations: test.buildArtifacts, } - rel, _ := builder.prepareRelease(test.baseArchiveUrl, test.whatsNewUrl, test.relNotesUrl, test.nightly) + rel, _ := builder.prepareRelease(test.baseArchiveURL, test.whatsNewURL, test.relNotesURL, test.nightly) if rel.Beta != test.expectedBeta || rel.Stable != test.expectedStable { t.Errorf("%s should have been tagged as beta=%v, stable=%v.", test.version, test.expectedBeta, test.expectedStable) @@ -93,21 +93,21 @@ func TestPreparingReleaseFromRemote(t *testing.T) { t.Errorf("Expected os to be %v, but it was %v", test.expectedOs, build.Os) } - if build.Url != test.expectedUrl { - t.Errorf("Expected url to be %v, but it was %v", test.expectedUrl, build.Url) + if build.URL != test.expectedURL { + t.Errorf("Expected url to be %v, but it was %v", test.expectedURL, build.URL) } } } -type mockHttpGetter struct{} +type mockHTTPGetter struct{} -func (mockHttpGetter) getContents(url string) (string, error) { +func (mockHTTPGetter) getContents(url string) (string, error) { return url, nil } func TestPreparingReleaseFromLocal(t *testing.T) { - whatsNewUrl := "https://whatsnews.foo/" - relNotesUrl := "https://relnotes.foo/" + whatsNewURL := "https://whatsnews.foo/" + relNotesURL := "https://relnotes.foo/" expectedVersion := "5.4.0-123pre1" expectedBuilds := 4 @@ -118,17 +118,17 @@ func TestPreparingReleaseFromLocal(t *testing.T) { artifactConfigurations: completeBuildArtifactConfigurations, } - relAll, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewUrl, relNotesUrl, true) + relAll, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewURL, relNotesURL, true) if relAll.Stable || !relAll.Nightly { t.Error("Expected a nightly release but wasn't.") } - if relAll.ReleaseNotesUrl != relNotesUrl { - t.Errorf("expected releaseNotesUrl to be %s, but it was %s", relNotesUrl, relAll.ReleaseNotesUrl) + if relAll.ReleaseNotesURL != relNotesURL { + t.Errorf("expected releaseNotesURL to be %s, but it was %s", relNotesURL, relAll.ReleaseNotesURL) } - if relAll.WhatsNewUrl != whatsNewUrl { - t.Errorf("expected whatsNewUrl to be %s, but it was %s", whatsNewUrl, relAll.WhatsNewUrl) + if relAll.WhatsNewURL != whatsNewURL { + t.Errorf("expected whatsNewURL to be %s, but it was %s", whatsNewURL, relAll.WhatsNewURL) } if relAll.Beta { @@ -155,7 +155,7 @@ func TestPreparingReleaseFromLocal(t *testing.T) { }}, } - relOne, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewUrl, relNotesUrl, true) + relOne, _ := builder.prepareRelease("https://s3-us-west-2.amazonaws.com/grafana-enterprise-releases/master/grafana-enterprise", whatsNewURL, relNotesURL, true) if len(relOne.Builds) != 1 { t.Errorf("Expected 1 artifact, but was %v", len(relOne.Builds))