From 7db848f153f083b5efafc4c3bae177eeafb99f8b Mon Sep 17 00:00:00 2001 From: bugficks Date: Tue, 15 Jan 2019 13:29:56 +0100 Subject: [PATCH 001/144] [Feature request] MySQL SSL CA in datasource connector https://github.com/grafana/grafana/issues/8570 --- pkg/tsdb/mysql/mysql.go | 44 ++++++++++++ .../datasource/mysql/partials/config.html | 68 ++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 35b03e489a0..e713b87e265 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -6,6 +6,10 @@ import ( "reflect" "strconv" "strings" + "errors" + + "crypto/x509" + "crypto/tls" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" @@ -32,6 +36,46 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin datasource.Url, datasource.Database, ) + + var tlsSkipVerify, tlsAuth, tlsAuthWithCACert bool + if datasource.JsonData != nil { + tlsAuth = datasource.JsonData.Get("tlsAuth").MustBool(false) + tlsAuthWithCACert = datasource.JsonData.Get("tlsAuthWithCACert").MustBool(false) + tlsSkipVerify = datasource.JsonData.Get("tlsSkipVerify").MustBool(false) + } + + if tlsAuth || tlsAuthWithCACert { + + secureJsonData := datasource.SecureJsonData.Decrypt() + tlsConfig := tls.Config { + InsecureSkipVerify: tlsSkipVerify, + } + + if tlsAuthWithCACert && len(secureJsonData["tlsCACert"]) > 0 { + + caPool := x509.NewCertPool() + if ok := caPool.AppendCertsFromPEM([]byte(secureJsonData["tlsCACert"])); !ok { + return nil, errors.New("Failed to parse TLS CA PEM certificate") + } + + tlsConfig.RootCAs = caPool + } + + if tlsAuth { + certs, err := tls.X509KeyPair([]byte(secureJsonData["tlsClientCert"]), []byte(secureJsonData["tlsClientKey"])) + if err != nil { + return nil, err + } + clientCert := make([]tls.Certificate, 0, 1) + clientCert = append(clientCert, certs) + + tlsConfig.Certificates = clientCert + } + + mysql.RegisterTLSConfig(datasource.Name, &tlsConfig) + cnnstr += "&tls=" + datasource.Name + } + logger.Debug("getEngine", "connection", cnnstr) config := tsdb.SqlQueryEndpointConfiguration{ diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html index a35633c626a..5f3ba5c1286 100644 --- a/public/app/plugins/datasource/mysql/partials/config.html +++ b/public/app/plugins/datasource/mysql/partials/config.html @@ -1,4 +1,3 @@ -

MySQL Connection

@@ -22,6 +21,72 @@
+ +
+
+ + +
+
+ +
+
+ +
+
+
TLS Auth Details
+ TLS Certs are encrypted and stored in the Grafana database. +
+
+
+
+ +
+
+ +
+ +
+ + reset +
+
+
+ +
+
+
+ +
+
+ +
+
+ + reset +
+
+ +
+
+ +
+
+ +
+
+ + reset +
+
+
+
Connection limits @@ -84,4 +149,3 @@

- From f31fe495e977cd9fe1c585e25221a423ff9a7c71 Mon Sep 17 00:00:00 2001 From: bugficks Date: Tue, 15 Jan 2019 13:54:25 +0100 Subject: [PATCH 002/144] fix go fmt --- pkg/tsdb/mysql/mysql.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index e713b87e265..82e7cac27f0 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -2,14 +2,14 @@ package mysql import ( "database/sql" + "errors" "fmt" "reflect" "strconv" "strings" - "errors" - "crypto/x509" "crypto/tls" + "crypto/x509" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" @@ -47,7 +47,7 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin if tlsAuth || tlsAuthWithCACert { secureJsonData := datasource.SecureJsonData.Decrypt() - tlsConfig := tls.Config { + tlsConfig := tls.Config{ InsecureSkipVerify: tlsSkipVerify, } From 7df5e3cebf06b39c0007bca76c9e86254fc7bc5a Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 28 Jan 2019 19:37:19 +0100 Subject: [PATCH 003/144] extract tls auth settings directive from datasource http settings directive --- public/app/features/all.ts | 1 + .../datasources/partials/http_settings.html | 52 +------------- .../partials/tls_auth_settings.html | 62 +++++++++++++++++ .../settings/TlsAuthSettingsCtrl.ts | 10 +++ .../datasource/mysql/partials/config.html | 68 +++---------------- 5 files changed, 84 insertions(+), 109 deletions(-) create mode 100644 public/app/features/datasources/partials/tls_auth_settings.html create mode 100644 public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts diff --git a/public/app/features/all.ts b/public/app/features/all.ts index 83146596ea0..d5e684e4a4e 100644 --- a/public/app/features/all.ts +++ b/public/app/features/all.ts @@ -12,3 +12,4 @@ import './manage-dashboards'; import './teams/CreateTeamCtrl'; import './profile/all'; import './datasources/settings/HttpSettingsCtrl'; +import './datasources/settings/TlsAuthSettingsCtrl'; diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html index 521e2d3cdc6..b6f2c4fc0dd 100644 --- a/public/app/features/datasources/partials/http_settings.html +++ b/public/app/features/datasources/partials/http_settings.html @@ -101,53 +101,5 @@ -
-
-
TLS Auth Details
- TLS Certs are encrypted and stored in the Grafana database. -
-
-
-
- -
-
- -
- -
- - reset -
-
-
- -
-
-
- -
-
- -
-
- - reset -
-
- -
-
- -
-
- -
-
- - reset -
-
-
-
- + + \ No newline at end of file diff --git a/public/app/features/datasources/partials/tls_auth_settings.html b/public/app/features/datasources/partials/tls_auth_settings.html new file mode 100644 index 00000000000..c852e8ec70c --- /dev/null +++ b/public/app/features/datasources/partials/tls_auth_settings.html @@ -0,0 +1,62 @@ +
+
+
TLS Auth Details
+ TLS Certs are encrypted and stored in the Grafana database. +
+
+
+
+
+ +
+ +
+ + reset +
+
+
+ +
+
+
+
+ +
+
+ + reset +
+
+ +
+
+
+ +
+
+ + reset +
+
+
+
diff --git a/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts new file mode 100644 index 00000000000..7c21fab404c --- /dev/null +++ b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts @@ -0,0 +1,10 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceTlsAuthSettings', () => { + return { + scope: { + current: '=', + }, + templateUrl: 'public/app/features/datasources/partials/tls_auth_settings.html', + }; +}); diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html index 5f3ba5c1286..8221a06e1ee 100644 --- a/public/app/plugins/datasource/mysql/partials/config.html +++ b/public/app/plugins/datasource/mysql/partials/config.html @@ -24,70 +24,20 @@
- - + +
- +
-
-
-
TLS Auth Details
- TLS Certs are encrypted and stored in the Grafana database. -
-
-
-
- -
-
- -
- -
- - reset -
-
-
- -
-
-
- -
-
- -
-
- - reset -
-
- -
-
- -
-
- -
-
- - reset -
-
-
-
- + + Connection limits From f157c19e16cdc970542867ec77eb5a61fe5f11ad Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 28 Jan 2019 19:38:56 +0100 Subject: [PATCH 004/144] extract parsing of datasource tls config to method --- pkg/models/datasource_cache.go | 48 +++++++++++++++++++++------------- pkg/tsdb/mysql/mysql.go | 43 ++++-------------------------- 2 files changed, 35 insertions(+), 56 deletions(-) diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index 66ba66e4d39..1c895514ace 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -46,19 +46,16 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { return t.Transport, nil } - var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool - if ds.JsonData != nil { - tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false) - tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) - tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false) + tlsConfig, err := ds.GetTLSConfig() + if err != nil { + return nil, err } + tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient + transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: tlsSkipVerify, - Renegotiation: tls.RenegotiateFreelyAsClient, - }, - Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, @@ -70,6 +67,26 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { IdleConnTimeout: 90 * time.Second, } + ptc.cache[ds.Id] = cachedTransport{ + Transport: transport, + updated: ds.Updated, + } + + return transport, nil +} + +func (ds *DataSource) GetTLSConfig() (*tls.Config, error) { + var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool + if ds.JsonData != nil { + tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false) + tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) + tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false) + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: tlsSkipVerify, + } + if tlsClientAuth || tlsAuthWithCACert { decrypted := ds.SecureJsonData.Decrypt() if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 { @@ -78,7 +95,7 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { if !ok { return nil, errors.New("Failed to parse TLS CA PEM certificate") } - transport.TLSClientConfig.RootCAs = caPool + tlsConfig.RootCAs = caPool } if tlsClientAuth { @@ -86,14 +103,9 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { if err != nil { return nil, err } - transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + tlsConfig.Certificates = []tls.Certificate{cert} } } - ptc.cache[ds.Id] = cachedTransport{ - Transport: transport, - updated: ds.Updated, - } - - return transport, nil + return tlsConfig, nil } diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 82e7cac27f0..d451150f1de 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -2,15 +2,11 @@ package mysql import ( "database/sql" - "errors" "fmt" "reflect" "strconv" "strings" - "crypto/tls" - "crypto/x509" - "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" "github.com/grafana/grafana/pkg/log" @@ -37,42 +33,13 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin datasource.Database, ) - var tlsSkipVerify, tlsAuth, tlsAuthWithCACert bool - if datasource.JsonData != nil { - tlsAuth = datasource.JsonData.Get("tlsAuth").MustBool(false) - tlsAuthWithCACert = datasource.JsonData.Get("tlsAuthWithCACert").MustBool(false) - tlsSkipVerify = datasource.JsonData.Get("tlsSkipVerify").MustBool(false) + tlsConfig, err := datasource.GetTLSConfig() + if err != nil { + return nil, err } - if tlsAuth || tlsAuthWithCACert { - - secureJsonData := datasource.SecureJsonData.Decrypt() - tlsConfig := tls.Config{ - InsecureSkipVerify: tlsSkipVerify, - } - - if tlsAuthWithCACert && len(secureJsonData["tlsCACert"]) > 0 { - - caPool := x509.NewCertPool() - if ok := caPool.AppendCertsFromPEM([]byte(secureJsonData["tlsCACert"])); !ok { - return nil, errors.New("Failed to parse TLS CA PEM certificate") - } - - tlsConfig.RootCAs = caPool - } - - if tlsAuth { - certs, err := tls.X509KeyPair([]byte(secureJsonData["tlsClientCert"]), []byte(secureJsonData["tlsClientKey"])) - if err != nil { - return nil, err - } - clientCert := make([]tls.Certificate, 0, 1) - clientCert = append(clientCert, certs) - - tlsConfig.Certificates = clientCert - } - - mysql.RegisterTLSConfig(datasource.Name, &tlsConfig) + if tlsConfig.RootCAs != nil || len(tlsConfig.Certificates) > 0 { + mysql.RegisterTLSConfig(datasource.Name, tlsConfig) cnnstr += "&tls=" + datasource.Name } From 60f700a1d217593ee1bca29d0be12328b21e0fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 2 Feb 2019 19:23:19 +0100 Subject: [PATCH 005/144] wip: dashboard react --- .../dashboard/containers/DashboardCtrl.ts | 6 - .../dashboard/containers/DashboardPage.tsx | 138 ++++++++++++++++++ .../features/dashboard/state/initDashboard.ts | 5 + public/app/routes/routes.ts | 9 +- 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 public/app/features/dashboard/containers/DashboardPage.tsx create mode 100644 public/app/features/dashboard/state/initDashboard.ts diff --git a/public/app/features/dashboard/containers/DashboardCtrl.ts b/public/app/features/dashboard/containers/DashboardCtrl.ts index 74795315504..0151f8f7331 100644 --- a/public/app/features/dashboard/containers/DashboardCtrl.ts +++ b/public/app/features/dashboard/containers/DashboardCtrl.ts @@ -31,12 +31,6 @@ export class DashboardCtrl { // temp hack due to way dashboards are loaded // can't use controllerAs on route yet $scope.ctrl = this; - - // TODO: break out settings view to separate view & controller - this.editTab = 0; - - // funcs called from React component bindings and needs this binding - this.getPanelContainer = this.getPanelContainer.bind(this); } setupDashboard(data) { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx new file mode 100644 index 00000000000..54eed34fc29 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -0,0 +1,138 @@ +// Libraries +import React, { Component } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +// Utils & Services +import locationUtil from 'app/core/utils/location_util'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { createErrorNotification } from 'app/core/copy/appNotification'; + +// Components +import { LoadingPlaceholder } from '@grafana/ui'; + +// Redux +import { updateLocation } from 'app/core/actions'; +import { notifyApp } from 'app/core/actions'; + +// Types +import { StoreState } from 'app/types'; +import { DashboardModel } from 'app/features/dashboard/state'; + +interface Props { + panelId: string; + urlUid?: string; + urlSlug?: string; + urlType?: string; + $scope: any; + $injector: any; + updateLocation: typeof updateLocation; + notifyApp: typeof notifyApp; +} + +interface State { + dashboard: DashboardModel | null; + notFound: boolean; +} + +export class DashboardPage extends Component { + state: State = { + dashboard: null, + notFound: false, + }; + + async componentDidMount() { + const { $injector, urlUid, urlType, urlSlug } = this.props; + + // handle old urls with no uid + if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { + this.redirectToNewUrl(); + return; + } + + const loaderSrv = $injector.get('dashboardLoaderSrv'); + const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + + try { + this.initDashboard(dashDTO); + } catch (err) { + this.props.notifyApp(createErrorNotification('Failed to init dashboard', err.toString())); + console.log(err); + } + } + + redirectToNewUrl() { + getBackendSrv() + .getDashboardBySlug(this.props.urlSlug) + .then(res => { + if (res) { + const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); + this.props.updateLocation(url); + } + }); + } + + initDashboard(dashDTO: any) { + const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + + // init services + this.timeSrv.init(dashboard); + this.annotationsSrv.init(dashboard); + + // template values service needs to initialize completely before + // the rest of the dashboard can load + this.variableSrv + .init(dashboard) + // template values failes are non fatal + .catch(this.onInitFailed.bind(this, 'Templating init failed', false)) + // continue + .finally(() => { + this.dashboard = dashboard; + this.dashboard.processRepeats(); + this.dashboard.updateSubmenuVisibility(); + this.dashboard.autoFitPanels(window.innerHeight); + + this.unsavedChangesSrv.init(dashboard, this.$scope); + + // TODO refactor ViewStateSrv + this.$scope.dashboard = dashboard; + this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope); + + this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); + this.setWindowTitleAndTheme(); + + appEvents.emit('dashboard-initialized', dashboard); + }) + .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); + + this.setState({ dashboard }); + } + + render() { + const { notFound, dashboard } = this.state; + + if (notFound) { + return
Dashboard not found
; + } + + if (!dashboard) { + return ; + } + + return
title: {dashboard.title}
; + } +} + +const mapStateToProps = (state: StoreState) => ({ + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + panelId: state.location.query.panelId, +}); + +const mapDispatchToProps = { + updateLocation, + notifyApp, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts new file mode 100644 index 00000000000..3b2307b3ccc --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -0,0 +1,5 @@ + + +export function initDashboard(dashboard: DashboardModel, $injector: any, $scope: any) { + +} diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 0f4c09a9c77..cdd9ed89a08 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -20,6 +20,7 @@ import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards' import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage'; import OrgDetailsPage from '../features/org/OrgDetailsPage'; import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage'; +import DashboardPage from '../features/dashboard/containers/DashboardPage'; import config from 'app/core/config'; /** @ngInject */ @@ -34,10 +35,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { pageClass: 'page-dashboard', }) .when('/d/:uid/:slug', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d/:uid', { templateUrl: 'public/app/partials/dashboard.html', From d86e773c756806bb826af50c347709bce265a65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 2 Feb 2019 22:43:19 +0100 Subject: [PATCH 006/144] wip: minor progress --- .../dashboard/containers/DashboardPage.tsx | 128 +++++++----------- .../app/features/dashboard/state/actions.ts | 41 +++--- .../features/dashboard/state/initDashboard.ts | 67 ++++++++- .../features/dashboard/state/reducers.test.ts | 4 +- .../app/features/dashboard/state/reducers.ts | 33 +++-- public/app/types/dashboard.ts | 17 ++- 6 files changed, 171 insertions(+), 119 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 54eed34fc29..0e3e1058660 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -3,21 +3,16 @@ import React, { Component } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -// Utils & Services -import locationUtil from 'app/core/utils/location_util'; -import { getBackendSrv } from 'app/core/services/backend_srv'; -import { createErrorNotification } from 'app/core/copy/appNotification'; - // Components import { LoadingPlaceholder } from '@grafana/ui'; // Redux -import { updateLocation } from 'app/core/actions'; -import { notifyApp } from 'app/core/actions'; +import { initDashboard } from '../state/initDashboard'; // Types import { StoreState } from 'app/types'; import { DashboardModel } from 'app/features/dashboard/state'; +import { DashboardLoadingState } from 'app/types/dashboard'; interface Props { panelId: string; @@ -26,8 +21,9 @@ interface Props { urlType?: string; $scope: any; $injector: any; - updateLocation: typeof updateLocation; - notifyApp: typeof notifyApp; + initDashboard: typeof initDashboard; + loadingState: DashboardLoadingState; + dashboard: DashboardModel; } interface State { @@ -42,81 +38,54 @@ export class DashboardPage extends Component { }; async componentDidMount() { - const { $injector, urlUid, urlType, urlSlug } = this.props; + this.props.initDashboard({ + injector: this.props.$injector, + scope: this.props.$scope, + urlSlug: this.props.urlSlug, + urlUid: this.props.urlUid, + urlType: this.props.urlType, + }) - // handle old urls with no uid - if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { - this.redirectToNewUrl(); - return; - } - - const loaderSrv = $injector.get('dashboardLoaderSrv'); - const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); - - try { - this.initDashboard(dashDTO); - } catch (err) { - this.props.notifyApp(createErrorNotification('Failed to init dashboard', err.toString())); - console.log(err); - } + // const { $injector, urlUid, urlType, urlSlug } = this.props; + // + // // handle old urls with no uid + // if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { + // this.redirectToNewUrl(); + // return; + // } + // + // const loaderSrv = $injector.get('dashboardLoaderSrv'); + // const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + // + // try { + // this.initDashboard(dashDTO); + // } catch (err) { + // this.props.notifyApp(createErrorNotification('Failed to init dashboard', err.toString())); + // console.log(err); + // } } - redirectToNewUrl() { - getBackendSrv() - .getDashboardBySlug(this.props.urlSlug) - .then(res => { - if (res) { - const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); - this.props.updateLocation(url); - } - }); - } - - initDashboard(dashDTO: any) { - const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); - - // init services - this.timeSrv.init(dashboard); - this.annotationsSrv.init(dashboard); - - // template values service needs to initialize completely before - // the rest of the dashboard can load - this.variableSrv - .init(dashboard) - // template values failes are non fatal - .catch(this.onInitFailed.bind(this, 'Templating init failed', false)) - // continue - .finally(() => { - this.dashboard = dashboard; - this.dashboard.processRepeats(); - this.dashboard.updateSubmenuVisibility(); - this.dashboard.autoFitPanels(window.innerHeight); - - this.unsavedChangesSrv.init(dashboard, this.$scope); - - // TODO refactor ViewStateSrv - this.$scope.dashboard = dashboard; - this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope); - - this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); - this.setWindowTitleAndTheme(); - - appEvents.emit('dashboard-initialized', dashboard); - }) - .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); - - this.setState({ dashboard }); - } + // redirectToNewUrl() { + // getBackendSrv() + // .getDashboardBySlug(this.props.urlSlug) + // .then(res => { + // if (res) { + // const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); + // this.props.updateLocation(url); + // } + // }); + // } + // + // initDashboard(dashDTO: any) { + // const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + // this.setState({ dashboard }); + // } render() { - const { notFound, dashboard } = this.state; - - if (notFound) { - return
Dashboard not found
; - } + const { loadingState, dashboard } = this.props; if (!dashboard) { - return ; + return ; } return
title: {dashboard.title}
; @@ -128,11 +97,12 @@ const mapStateToProps = (state: StoreState) => ({ urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, panelId: state.location.query.panelId, + loadingState: state.dashboard.loadingState, + dashboard: state.dashboard as DashboardModel, }); const mapDispatchToProps = { - updateLocation, - notifyApp, + initDashboard }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 4dcf0a925b7..1bb29dc3ad5 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,8 +1,18 @@ +// Libaries import { StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; + +// Services & Utils import { getBackendSrv } from 'app/core/services/backend_srv'; -import appEvents from 'app/core/app_events'; +import { actionCreatorFactory } from 'app/core/redux'; +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; + +// Actions import { loadPluginDashboards } from '../../plugins/state/actions'; +import { notifyApp } from 'app/core/actions'; + +// Types import { DashboardAcl, DashboardAclDTO, @@ -10,30 +20,14 @@ import { DashboardAclUpdateDTO, NewDashboardAclItem, } from 'app/types/acl'; +import { DashboardLoadingState } from 'app/types/dashboard'; -export enum ActionTypes { - LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS', - LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS', -} +export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); +export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_LOADING_STATE').create(); -export interface LoadDashboardPermissionsAction { - type: ActionTypes.LoadDashboardPermissions; - payload: DashboardAcl[]; -} +export type Action = ActionOf; -export interface LoadStarredDashboardsAction { - type: ActionTypes.LoadStarredDashboards; - payload: DashboardAcl[]; -} - -export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction; - -type ThunkResult = ThunkAction; - -export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({ - type: ActionTypes.LoadDashboardPermissions, - payload: items, -}); +export type ThunkResult = ThunkAction; export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { @@ -124,7 +118,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar export function importDashboard(data, dashboardTitle: string): ThunkResult { return async dispatch => { await getBackendSrv().post('/api/dashboards/import', data); - appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]); + dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle))); dispatch(loadPluginDashboards()); }; } @@ -135,3 +129,4 @@ export function removeDashboard(uri: string): ThunkResult { dispatch(loadPluginDashboards()); }; } + diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 3b2307b3ccc..124d03eee4a 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,5 +1,70 @@ +// Libaries +import { StoreState } from 'app/types'; +import { ThunkAction } from 'redux-thunk'; +// Services & Utils +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { createErrorNotification } from 'app/core/copy/appNotification'; -export function initDashboard(dashboard: DashboardModel, $injector: any, $scope: any) { +// Actions +import { updateLocation } from 'app/core/actions'; +import { notifyApp } from 'app/core/actions'; +import locationUtil from 'app/core/utils/location_util'; +import { setDashboardLoadingState, ThunkResult } from './actions'; +// Types +import { DashboardLoadingState } from 'app/types/dashboard'; +import { DashboardModel } from './DashboardModel'; + +export interface InitDashboardArgs { + injector: any; + scope: any; + urlUid?: string; + urlSlug?: string; + urlType?: string; +} + +export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: InitDashboardArgs): ThunkResult { + return async dispatch => { + const loaderSrv = injector.get('dashboardLoaderSrv'); + + dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + + try { + // fetch dashboard from api + const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + // set initializing state + dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing)); + // create model + const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + // init services + + injector.get('timeSrv').init(dashboard); + injector.get('annotationsSrv').init(dashboard); + + // template values service needs to initialize completely before + // the rest of the dashboard can load + injector.get('variableSrv').init(dashboard) + .catch(err => { + dispatch(notifyApp(createErrorNotification('Templating init failed'))); + }) + .finally(() => { + + dashboard.processRepeats(); + dashboard.updateSubmenuVisibility(); + dashboard.autoFitPanels(window.innerHeight); + + injector.get('unsavedChangesSrv').init(dashboard, scope); + + scope.dashboard = dashboard; + injector.get('dashboardViewStateSrv').create(scope); + injector.get('keybindingSrv').setupDashboardBindings(scope, dashboard); + }) + .catch(err => { + dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + }); + } catch (err) { + dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + } + }; } diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index ced8866aad8..ea3353ce741 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -1,4 +1,4 @@ -import { Action, ActionTypes } from './actions'; +import { Action } from './actions'; import { OrgRole, PermissionLevel, DashboardState } from 'app/types'; import { initialState, dashboardReducer } from './reducers'; @@ -8,7 +8,7 @@ describe('dashboard reducer', () => { beforeEach(() => { const action: Action = { - type: ActionTypes.LoadDashboardPermissions, + type: 'LOAD_DASHBOARD_PERMISSIONS', payload: [ { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 8a79a6c9f77..bd13446b090 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,21 +1,30 @@ -import { DashboardState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { DashboardState, DashboardLoadingState } from 'app/types/dashboard'; +import { loadDashboardPermissions, setDashboardLoadingState } from './actions'; +import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; export const initialState: DashboardState = { + loadingState: DashboardLoadingState.NotStarted, + dashboard: null, permissions: [], }; -export const dashboardReducer = (state = initialState, action: Action): DashboardState => { - switch (action.type) { - case ActionTypes.LoadDashboardPermissions: - return { - ...state, - permissions: processAclItems(action.payload), - }; - } - return state; -}; +export const dashboardReducer = reducerFactory(initialState) + .addMapper({ + filter: loadDashboardPermissions, + mapper: (state, action) => ({ + ...state, + permissions: processAclItems(action.payload), + }), + }) + .addMapper({ + filter: setDashboardLoadingState, + mapper: (state, action) => ({ + ...state, + loadingState: action.payload + }), + }) + .create() export default { dashboard: dashboardReducer, diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index d33405c985e..df9a2e53548 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,5 +1,18 @@ import { DashboardAcl } from './acl'; -export interface DashboardState { - permissions: DashboardAcl[]; +export interface Dashboard { +} + +export enum DashboardLoadingState { + NotStarted = 'Not started', + Fetching = 'Fetching', + Initializing = 'Initializing', + Error = 'Error', + Done = 'Done', +} + +export interface DashboardState { + dashboard: Dashboard | null; + loadingState: DashboardLoadingState; + permissions: DashboardAcl[] | null; } From 83937f59c008343e7e1d000a088807bccb476e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 2 Feb 2019 23:01:48 +0100 Subject: [PATCH 007/144] wip: dashboard in react starting to work --- .../dashboard/containers/DashboardPage.tsx | 40 +-------- .../app/features/dashboard/state/actions.ts | 3 +- .../features/dashboard/state/initDashboard.ts | 86 +++++++++++-------- .../app/features/dashboard/state/reducers.ts | 11 ++- public/app/types/dashboard.ts | 4 +- 5 files changed, 66 insertions(+), 78 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0e3e1058660..c0d5c4d4730 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; // Components import { LoadingPlaceholder } from '@grafana/ui'; +import { DashboardGrid } from '../dashgrid/DashboardGrid'; // Redux import { initDashboard } from '../state/initDashboard'; @@ -45,42 +46,8 @@ export class DashboardPage extends Component { urlUid: this.props.urlUid, urlType: this.props.urlType, }) - - // const { $injector, urlUid, urlType, urlSlug } = this.props; - // - // // handle old urls with no uid - // if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { - // this.redirectToNewUrl(); - // return; - // } - // - // const loaderSrv = $injector.get('dashboardLoaderSrv'); - // const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); - // - // try { - // this.initDashboard(dashDTO); - // } catch (err) { - // this.props.notifyApp(createErrorNotification('Failed to init dashboard', err.toString())); - // console.log(err); - // } } - // redirectToNewUrl() { - // getBackendSrv() - // .getDashboardBySlug(this.props.urlSlug) - // .then(res => { - // if (res) { - // const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); - // this.props.updateLocation(url); - // } - // }); - // } - // - // initDashboard(dashDTO: any) { - // const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); - // this.setState({ dashboard }); - // } - render() { const { loadingState, dashboard } = this.props; @@ -88,7 +55,8 @@ export class DashboardPage extends Component { return ; } - return
title: {dashboard.title}
; + console.log(dashboard); + return } } @@ -98,7 +66,7 @@ const mapStateToProps = (state: StoreState) => ({ urlType: state.location.routeParams.type, panelId: state.location.query.panelId, loadingState: state.dashboard.loadingState, - dashboard: state.dashboard as DashboardModel, + dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 1bb29dc3ad5..14721cdbe96 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -20,10 +20,11 @@ import { DashboardAclUpdateDTO, NewDashboardAclItem, } from 'app/types/acl'; -import { DashboardLoadingState } from 'app/types/dashboard'; +import { DashboardLoadingState, MutableDashboard } from 'app/types/dashboard'; export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_LOADING_STATE').create(); +export const setDashboardModel = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); export type Action = ActionOf; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 124d03eee4a..d20f9ae1cf8 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,16 +1,11 @@ -// Libaries -import { StoreState } from 'app/types'; -import { ThunkAction } from 'redux-thunk'; - // Services & Utils -import { getBackendSrv } from 'app/core/services/backend_srv'; import { createErrorNotification } from 'app/core/copy/appNotification'; // Actions import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; -import { setDashboardLoadingState, ThunkResult } from './actions'; +import { setDashboardLoadingState, ThunkResult, setDashboardModel } from './actions'; // Types import { DashboardLoadingState } from 'app/types/dashboard'; @@ -30,41 +25,58 @@ export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: Ini dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + let dashDTO = null; + try { // fetch dashboard from api - const dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); - // set initializing state - dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing)); - // create model - const dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); - // init services - - injector.get('timeSrv').init(dashboard); - injector.get('annotationsSrv').init(dashboard); - - // template values service needs to initialize completely before - // the rest of the dashboard can load - injector.get('variableSrv').init(dashboard) - .catch(err => { - dispatch(notifyApp(createErrorNotification('Templating init failed'))); - }) - .finally(() => { - - dashboard.processRepeats(); - dashboard.updateSubmenuVisibility(); - dashboard.autoFitPanels(window.innerHeight); - - injector.get('unsavedChangesSrv').init(dashboard, scope); - - scope.dashboard = dashboard; - injector.get('dashboardViewStateSrv').create(scope); - injector.get('keybindingSrv').setupDashboardBindings(scope, dashboard); - }) - .catch(err => { - dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); - }); + dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + console.log(err); + return; } + + // set initializing state + dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing)); + + // create model + let dashboard: DashboardModel; + try { + dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + } catch (err) { + dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + console.log(err); + return; + } + + // init services + injector.get('timeSrv').init(dashboard); + injector.get('annotationsSrv').init(dashboard); + + // template values service needs to initialize completely before + // the rest of the dashboard can load + try { + await injector.get('variableSrv').init(dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Templating init failed', err.toString()))); + console.log(err); + } + + try { + dashboard.processRepeats(); + dashboard.updateSubmenuVisibility(); + dashboard.autoFitPanels(window.innerHeight); + + injector.get('unsavedChangesSrv').init(dashboard, scope); + + scope.dashboard = dashboard; + injector.get('dashboardViewStateSrv').create(scope); + injector.get('keybindingSrv').setupDashboardBindings(scope, dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Dashboard init failed', err.toString()))); + console.log(err); + } + + dispatch(setDashboardModel(dashboard)); }; } diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index bd13446b090..5cfc879a1a4 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,11 +1,11 @@ import { DashboardState, DashboardLoadingState } from 'app/types/dashboard'; -import { loadDashboardPermissions, setDashboardLoadingState } from './actions'; +import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel } from './actions'; import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; export const initialState: DashboardState = { loadingState: DashboardLoadingState.NotStarted, - dashboard: null, + model: null, permissions: [], }; @@ -24,6 +24,13 @@ export const dashboardReducer = reducerFactory(initialState) loadingState: action.payload }), }) + .addMapper({ + filter: setDashboardModel, + mapper: (state, action) => ({ + ...state, + model: action.payload + }), + }) .create() export default { diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index df9a2e53548..bdea5b04bac 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,6 +1,6 @@ import { DashboardAcl } from './acl'; -export interface Dashboard { +export interface MutableDashboard { } export enum DashboardLoadingState { @@ -12,7 +12,7 @@ export enum DashboardLoadingState { } export interface DashboardState { - dashboard: Dashboard | null; + model: MutableDashboard | null; loadingState: DashboardLoadingState; permissions: DashboardAcl[] | null; } From 8dec74689d2361febda5647d29f3ff5f46e0007c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 10:55:58 +0100 Subject: [PATCH 008/144] Dashboard settings starting to work --- public/app/core/app_events.ts | 3 +- .../dashboard/components/DashNav/DashNav.tsx | 144 ++++++++++++++++++ .../dashboard/components/DashNav/index.ts | 2 + .../DashboardSettings/DashboardSettings.tsx | 36 +++++ .../components/DashboardSettings/index.ts | 1 + .../dashboard/containers/DashboardPage.tsx | 100 ++++++++++-- .../features/dashboard/state/initDashboard.ts | 1 + .../app/features/dashboard/state/reducers.ts | 2 +- .../sass/components/_dashboard_settings.scss | 3 + 9 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 public/app/features/dashboard/components/DashNav/DashNav.tsx create mode 100644 public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx diff --git a/public/app/core/app_events.ts b/public/app/core/app_events.ts index 6af7913167b..1951fd87001 100644 --- a/public/app/core/app_events.ts +++ b/public/app/core/app_events.ts @@ -1,4 +1,5 @@ import { Emitter } from './utils/emitter'; -const appEvents = new Emitter(); +export const appEvents = new Emitter(); + export default appEvents; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx new file mode 100644 index 00000000000..e1fb70e5d68 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -0,0 +1,144 @@ +// Libaries +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; + +// Utils & Services +import { appEvents } from 'app/core/app_events'; + +// State +import { updateLocation } from 'app/core/actions'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel | null; + updateLocation: typeof updateLocation; +} + +export class DashNav extends PureComponent { + onOpenSearch = () => { + appEvents.emit('show-dash-search'); + }; + + onAddPanel = () => {}; + onOpenSettings = () => { + this.props.updateLocation({ + query: { + editview: 'settings', + }, + partial: true, + }) + }; + + renderLoadingState() { + return ( + + ); + } + + render() { + let { dashboard } = this.props; + + if (!dashboard) { + return this.renderLoadingState(); + } + + const haveFolder = dashboard.meta.folderId > 0; + const { canEdit, canSave, folderTitle, showSettings } = dashboard.meta; + + return ( +
+ + +
+ {/* + + */} + +
+ {canEdit && ( + + )} + + {showSettings && ( + + )} + + { + // + // + // + // + // + // + // + // + // + // + // + //
+ // + // + // + // + // + // + } +
+
+ ); + } +} + +const mapStateToProps = () => ({ +}); + +const mapDispatchToProps = { + updateLocation +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DashNav); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts index 854e32b24d2..cfa9003cd8a 100644 --- a/public/app/features/dashboard/components/DashNav/index.ts +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -1 +1,3 @@ export { DashNavCtrl } from './DashNavCtrl'; +import DashNav from './DashNav'; +export { DashNav }; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx new file mode 100644 index 00000000000..8a92c0d69eb --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -0,0 +1,36 @@ +// Libaries +import React, { PureComponent } from 'react'; + +// Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel | null; +} + +export class DashboardSettings extends PureComponent { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
this.element = element} />; + } +} diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts index f81b8cdbc67..0a89feada33 100644 --- a/public/app/features/dashboard/components/DashboardSettings/index.ts +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -1 +1,2 @@ export { SettingsCtrl } from './SettingsCtrl'; +export { DashboardSettings } from './DashboardSettings'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index c0d5c4d4730..9b088b4735f 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -1,14 +1,19 @@ // Libraries -import React, { Component } from 'react'; +import $ from 'jquery'; +import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; +import classNames from 'classnames'; // Components import { LoadingPlaceholder } from '@grafana/ui'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; +import { DashNav } from '../components/DashNav'; +import { DashboardSettings } from '../components/DashboardSettings'; // Redux import { initDashboard } from '../state/initDashboard'; +import { setDashboardModel } from '../state/actions'; // Types import { StoreState } from 'app/types'; @@ -20,22 +25,23 @@ interface Props { urlUid?: string; urlSlug?: string; urlType?: string; + editview: string; $scope: any; $injector: any; initDashboard: typeof initDashboard; + setDashboardModel: typeof setDashboardModel; loadingState: DashboardLoadingState; dashboard: DashboardModel; } interface State { - dashboard: DashboardModel | null; - notFound: boolean; + isSettingsOpening: boolean; } -export class DashboardPage extends Component { +export class DashboardPage extends PureComponent { state: State = { - dashboard: null, - notFound: false, + isSettingsOpening: false, + isSettingsOpen: false, }; async componentDidMount() { @@ -45,18 +51,82 @@ export class DashboardPage extends Component { urlSlug: this.props.urlSlug, urlUid: this.props.urlUid, urlType: this.props.urlType, - }) + }); + } + + componentDidUpdate(prevProps: Props) { + const { dashboard, editview } = this.props; + + // when dashboard has loaded subscribe to somme events + if (prevProps.dashboard === null && dashboard) { + dashboard.events.on('view-mode-changed', this.onViewModeChanged); + + // set initial fullscreen class state + this.setPanelFullscreenClass(); + } + + if (!prevProps.editview && editview) { + this.setState({ isSettingsOpening: true }); + setTimeout(() => { + this.setState({ isSettingsOpening: false}); + }, 10); + } + } + + onViewModeChanged = () => { + this.setPanelFullscreenClass(); + }; + + setPanelFullscreenClass() { + $('body').toggleClass('panel-in-fullscreen', this.props.dashboard.meta.fullscreen === true); + } + + componentWillUnmount() { + if (this.props.dashboard) { + this.props.dashboard.destroy(); + this.props.setDashboardModel(null); + } + } + + renderLoadingState() { + return ; + } + + renderDashboard() { + const { dashboard, editview } = this.props; + + const classes = classNames({ + 'dashboard-container': true, + 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled + }); + + return ( +
+ {dashboard && editview && } + +
+ +
+
+ ); } render() { - const { loadingState, dashboard } = this.props; + const { dashboard, editview } = this.props; + const { isSettingsOpening } = this.state; - if (!dashboard) { - return ; - } + const classes = classNames({ + 'dashboard-page--settings-opening': isSettingsOpening, + 'dashboard-page--settings-open': !isSettingsOpening && editview, + }); - console.log(dashboard); - return + return ( +
+ + {!dashboard && this.renderLoadingState()} + {dashboard && this.renderDashboard()} +
+ ); } } @@ -65,12 +135,14 @@ const mapStateToProps = (state: StoreState) => ({ urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, panelId: state.location.query.panelId, + editview: state.location.query.editview, loadingState: state.dashboard.loadingState, dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { - initDashboard + initDashboard, + setDashboardModel }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index d20f9ae1cf8..10d7164fbff 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -67,6 +67,7 @@ export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: Ini dashboard.updateSubmenuVisibility(); dashboard.autoFitPanels(window.innerHeight); + // init unsaved changes tracking injector.get('unsavedChangesSrv').init(dashboard, scope); scope.dashboard = dashboard; diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 5cfc879a1a4..2f4e5df5c14 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -31,7 +31,7 @@ export const dashboardReducer = reducerFactory(initialState) model: action.payload }), }) - .create() + .create(); export default { dashboard: dashboardReducer, diff --git a/public/sass/components/_dashboard_settings.scss b/public/sass/components/_dashboard_settings.scss index 5e17e025196..38883b7c80e 100644 --- a/public/sass/components/_dashboard_settings.scss +++ b/public/sass/components/_dashboard_settings.scss @@ -16,6 +16,9 @@ opacity: 1; transition: opacity 300ms ease-in-out; } + .dashboard-container { + display: none; + } } .dashboard-settings__content { From cba2ca55319cfb7a3bb009548b8f87c839a7e4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 12:29:47 +0100 Subject: [PATCH 009/144] Url state -> dashboard model state sync starting to work --- .../dashboard/components/DashNav/DashNav.tsx | 3 + .../components/DashNav/template.html | 2 - .../dashboard/containers/DashboardPage.tsx | 124 +++++++++++++----- .../dashboard/dashgrid/DashboardGrid.tsx | 41 ++++-- .../services/DashboardViewStateSrv.ts | 30 ----- public/app/routes/GrafanaCtrl.ts | 2 + public/app/types/dashboard.ts | 4 + public/views/index-template.html | 2 +- 8 files changed, 128 insertions(+), 80 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index e1fb70e5d68..f9df483bf5f 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -13,6 +13,9 @@ import { DashboardModel } from '../../state/DashboardModel'; export interface Props { dashboard: DashboardModel | null; + editview: string; + isEditing: boolean; + isFullscreen: boolean; updateLocation: typeof updateLocation; } diff --git a/public/app/features/dashboard/components/DashNav/template.html b/public/app/features/dashboard/components/DashNav/template.html index e50a8cd0bff..7e53267cbfd 100644 --- a/public/app/features/dashboard/components/DashNav/template.html +++ b/public/app/features/dashboard/components/DashNav/template.html @@ -55,7 +55,5 @@
- - diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 9b088b4735f..281916acb14 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -5,6 +5,9 @@ import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import classNames from 'classnames'; +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; + // Components import { LoadingPlaceholder } from '@grafana/ui'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; @@ -14,34 +17,45 @@ import { DashboardSettings } from '../components/DashboardSettings'; // Redux import { initDashboard } from '../state/initDashboard'; import { setDashboardModel } from '../state/actions'; +import { updateLocation } from 'app/core/actions'; +import { notifyApp } from 'app/core/actions'; // Types import { StoreState } from 'app/types'; -import { DashboardModel } from 'app/features/dashboard/state'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardLoadingState } from 'app/types/dashboard'; interface Props { - panelId: string; urlUid?: string; urlSlug?: string; urlType?: string; - editview: string; + editview?: string; + urlPanelId?: string; $scope: any; $injector: any; - initDashboard: typeof initDashboard; - setDashboardModel: typeof setDashboardModel; + urlEdit: boolean; + urlFullscreen: boolean; loadingState: DashboardLoadingState; dashboard: DashboardModel; + initDashboard: typeof initDashboard; + setDashboardModel: typeof setDashboardModel; + notifyApp: typeof notifyApp; + updateLocation: typeof updateLocation; } interface State { isSettingsOpening: boolean; + isEditing: boolean; + isFullscreen: boolean; + fullscreenPanel: PanelModel | null; } export class DashboardPage extends PureComponent { state: State = { isSettingsOpening: false, - isSettingsOpen: false, + isEditing: false, + isFullscreen: false, + fullscreenPanel: null, }; async componentDidMount() { @@ -55,30 +69,66 @@ export class DashboardPage extends PureComponent { } componentDidUpdate(prevProps: Props) { - const { dashboard, editview } = this.props; + const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props; - // when dashboard has loaded subscribe to somme events - if (prevProps.dashboard === null && dashboard) { - dashboard.events.on('view-mode-changed', this.onViewModeChanged); - - // set initial fullscreen class state - this.setPanelFullscreenClass(); + if (!dashboard) { + return; } + // handle animation states when opening dashboard settings if (!prevProps.editview && editview) { this.setState({ isSettingsOpening: true }); setTimeout(() => { - this.setState({ isSettingsOpening: false}); + this.setState({ isSettingsOpening: false }); }, 10); } + + // // when dashboard has loaded subscribe to somme events + // if (prevProps.dashboard === null) { + // // set initial fullscreen class state + // this.setPanelFullscreenClass(); + // } + + // Sync url state with model + if (urlFullscreen !== dashboard.meta.isFullscreen || urlEdit !== dashboard.meta.isEditing) { + // entering fullscreen/edit mode + if (urlPanelId) { + const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); + + if (panel) { + dashboard.setViewMode(panel, urlFullscreen, urlEdit); + this.setState({ isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel }); + } else { + this.handleFullscreenPanelNotFound(urlPanelId); + } + } else { + // handle leaving fullscreen mode + if (this.state.fullscreenPanel) { + dashboard.setViewMode(this.state.fullscreenPanel, urlFullscreen, urlEdit); + } + this.setState({ isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: null }); + } + + this.setPanelFullscreenClass(urlFullscreen); + } } - onViewModeChanged = () => { - this.setPanelFullscreenClass(); - }; + handleFullscreenPanelNotFound(urlPanelId: string) { + // Panel not found + this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); + // Clear url state + this.props.updateLocation({ + query: { + edit: null, + fullscreen: null, + panelId: null, + }, + partial: true + }); + } - setPanelFullscreenClass() { - $('body').toggleClass('panel-in-fullscreen', this.props.dashboard.meta.fullscreen === true); + setPanelFullscreenClass(isFullscreen: boolean) { + $('body').toggleClass('panel-in-fullscreen', isFullscreen); } componentWillUnmount() { @@ -94,10 +144,11 @@ export class DashboardPage extends PureComponent { renderDashboard() { const { dashboard, editview } = this.props; + const { isEditing, isFullscreen } = this.state; const classes = classNames({ 'dashboard-container': true, - 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled + 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled, }); return ( @@ -105,7 +156,7 @@ export class DashboardPage extends PureComponent { {dashboard && editview && }
- +
); @@ -113,7 +164,7 @@ export class DashboardPage extends PureComponent { render() { const { dashboard, editview } = this.props; - const { isSettingsOpening } = this.state; + const { isSettingsOpening, isEditing, isFullscreen } = this.state; const classes = classNames({ 'dashboard-page--settings-opening': isSettingsOpening, @@ -122,7 +173,7 @@ export class DashboardPage extends PureComponent { return (
- + {!dashboard && this.renderLoadingState()} {dashboard && this.renderDashboard()}
@@ -130,19 +181,26 @@ export class DashboardPage extends PureComponent { } } -const mapStateToProps = (state: StoreState) => ({ - urlUid: state.location.routeParams.uid, - urlSlug: state.location.routeParams.slug, - urlType: state.location.routeParams.type, - panelId: state.location.query.panelId, - editview: state.location.query.editview, - loadingState: state.dashboard.loadingState, - dashboard: state.dashboard.model as DashboardModel, -}); +const mapStateToProps = (state: StoreState) => { + console.log('state location', state.location.query); + return { + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + editview: state.location.query.editview, + urlPanelId: state.location.query.panelId, + urlFullscreen: state.location.query.fullscreen === true, + urlEdit: state.location.query.edit === true, + loadingState: state.dashboard.loadingState, + dashboard: state.dashboard.model as DashboardModel, + }; +}; const mapDispatchToProps = { initDashboard, - setDashboardModel + setDashboardModel, + notifyApp, + updateLocation, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 658bfad3816..27f699ff3e6 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +// Libaries +import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; +import classNames from 'classnames'; +import sizeMe from 'react-sizeme'; + +// Types import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; -import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; let lastGridWidth = 1200; let ignoreNextWidthChange = false; @@ -76,19 +79,18 @@ function GridWrapper({ const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); -export interface DashboardGridProps { +export interface Props { dashboard: DashboardModel; + isEditing: boolean; + isFullscreen: boolean; } -export class DashboardGrid extends React.Component { +export class DashboardGrid extends PureComponent { gridToPanelMap: any; panelMap: { [id: string]: PanelModel }; - constructor(props: DashboardGridProps) { - super(props); - - // subscribe to dashboard events - const dashboard = this.props.dashboard; + componentDidMount() { + const { dashboard } = this.props; dashboard.on('panel-added', this.triggerForceUpdate); dashboard.on('panel-removed', this.triggerForceUpdate); dashboard.on('repeats-processed', this.triggerForceUpdate); @@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component { dashboard.on('row-expanded', this.triggerForceUpdate); } + componentWillUnmount() { + const { dashboard } = this.props; + dashboard.off('panel-added', this.triggerForceUpdate); + dashboard.off('panel-removed', this.triggerForceUpdate); + dashboard.off('repeats-processed', this.triggerForceUpdate); + dashboard.off('view-mode-changed', this.onViewModeChanged); + dashboard.off('row-collapsed', this.triggerForceUpdate); + dashboard.off('row-expanded', this.triggerForceUpdate); + } + buildLayout() { const layout = []; this.panelMap = {}; @@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component { onViewModeChanged = () => { ignoreNextWidthChange = true; - this.forceUpdate(); } updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => { @@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component { } render() { + const { dashboard, isFullscreen } = this.props; + return ( {this.renderPanels()} diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts index fc38c3b241f..f5a68d6f647 100644 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts @@ -98,8 +98,6 @@ export class DashboardViewStateSrv { if (fromRouteUpdated !== true) { this.$location.search(this.serializeToUrl()); } - - this.syncState(); } toggleCollapsedPanelRow(panelId) { @@ -115,34 +113,6 @@ export class DashboardViewStateSrv { } } - syncState() { - 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; - } - - if (!panel.fullscreen) { - this.enterFullscreen(panel); - } else if (this.dashboard.meta.isEditing !== this.state.edit) { - this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit); - } - } else if (this.fullscreenPanel) { - this.leaveFullscreen(); - } - } - leaveFullscreen() { const panel = this.fullscreenPanel; diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 70bdf49e5e4..817e6452f44 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -165,6 +165,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop for (const drop of Drop.drops) { drop.destroy(); } + + appEvents.emit('hide-dash-search'); }); // handle kiosk mode diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index bdea5b04bac..713cd28efb1 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,6 +1,10 @@ import { DashboardAcl } from './acl'; export interface MutableDashboard { + meta: { + fullscreen: boolean; + isEditing: boolean; + } } export enum DashboardLoadingState { diff --git a/public/views/index-template.html b/public/views/index-template.html index a1c955d45d6..770ab74eccc 100644 --- a/public/views/index-template.html +++ b/public/views/index-template.html @@ -189,7 +189,7 @@ - +
From 2cb1733c59af3643bda1b03ca187c230120f9c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 14:53:42 +0100 Subject: [PATCH 010/144] wip: progress --- .../dashboard/components/DashNav/DashNav.tsx | 99 ++++++++++++------- .../dashboard/containers/DashboardPage.tsx | 16 ++- .../app/features/dashboard/state/actions.ts | 3 +- .../features/dashboard/state/initDashboard.ts | 60 ++++++++--- public/app/routes/routes.ts | 24 +++-- public/app/types/dashboard.ts | 2 +- 6 files changed, 138 insertions(+), 66 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index f9df483bf5f..79d4da94aca 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -16,6 +16,7 @@ export interface Props { editview: string; isEditing: boolean; isFullscreen: boolean; + $injector: any; updateLocation: typeof updateLocation; } @@ -25,13 +26,29 @@ export class DashNav extends PureComponent { }; onAddPanel = () => {}; + + onClose = () => { + this.props.updateLocation({ + query: { editview: null, panelId: null, edit: null, fullscreen: null }, + partial: true, + }); + }; + onOpenSettings = () => { this.props.updateLocation({ - query: { - editview: 'settings', - }, + query: { editview: 'settings' }, partial: true, - }) + }); + }; + + onStarDashboard = () => { + const { $injector, dashboard } = this.props; + const dashboardSrv = $injector.get('dashboardSrv'); + + dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => { + dashboard.meta.isStarred = newState; + this.forceUpdate(); + }); }; renderLoadingState() { @@ -48,15 +65,16 @@ export class DashNav extends PureComponent { ); } + render() { - let { dashboard } = this.props; + const { dashboard, isFullscreen, editview } = this.props; if (!dashboard) { return this.renderLoadingState(); } const haveFolder = dashboard.meta.folderId > 0; - const { canEdit, canSave, folderTitle, showSettings } = dashboard.meta; + const { canEdit, canStar, canSave, folderTitle, showSettings, isStarred } = dashboard.meta; return (
@@ -95,53 +113,66 @@ export class DashNav extends PureComponent { )} + {canStar && ( + + )} + { - // // // + // + // - // - // // // - // - // + // + // // // - //
- // - // - // - // - // - // + // + // + // + // + // } + {(isFullscreen || editview) && ( +
+ +
+ )}
); } } -const mapStateToProps = () => ({ -}); +const mapStateToProps = () => ({}); const mapDispatchToProps = { - updateLocation + updateLocation, }; export default connect(mapStateToProps, mapDispatchToProps)(DashNav); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 281916acb14..9f0f1cdff5f 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -60,8 +60,8 @@ export class DashboardPage extends PureComponent { async componentDidMount() { this.props.initDashboard({ - injector: this.props.$injector, - scope: this.props.$scope, + $injector: this.props.$injector, + $scope: this.props.$scope, urlSlug: this.props.urlSlug, urlUid: this.props.urlUid, urlType: this.props.urlType, @@ -123,7 +123,7 @@ export class DashboardPage extends PureComponent { fullscreen: null, panelId: null, }, - partial: true + partial: true, }); } @@ -163,7 +163,7 @@ export class DashboardPage extends PureComponent { } render() { - const { dashboard, editview } = this.props; + const { dashboard, editview, $injector } = this.props; const { isSettingsOpening, isEditing, isFullscreen } = this.state; const classes = classNames({ @@ -173,7 +173,13 @@ export class DashboardPage extends PureComponent { return (
- + {!dashboard && this.renderLoadingState()} {dashboard && this.renderDashboard()}
diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 14721cdbe96..bc57b8e5f10 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,5 +1,4 @@ // Libaries -import { StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; // Services & Utils @@ -13,6 +12,7 @@ import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; // Types +import { StoreState } from 'app/types'; import { DashboardAcl, DashboardAclDTO, @@ -27,7 +27,6 @@ export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); export type Action = ActionOf; - export type ThunkResult = ThunkAction; export function getDashboardPermissions(id: number): ThunkResult { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 10d7164fbff..f7e23238b7c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,5 +1,6 @@ // Services & Utils import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getBackendSrv } from 'app/core/services/backend_srv'; // Actions import { updateLocation } from 'app/core/actions'; @@ -12,24 +13,53 @@ import { DashboardLoadingState } from 'app/types/dashboard'; import { DashboardModel } from './DashboardModel'; export interface InitDashboardArgs { - injector: any; - scope: any; + $injector: any; + $scope: any; urlUid?: string; urlSlug?: string; urlType?: string; } -export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: InitDashboardArgs): ThunkResult { - return async dispatch => { - const loaderSrv = injector.get('dashboardLoaderSrv'); +async function redirectToNewUrl(slug: string, dispatch: any) { + const res = await getBackendSrv().getDashboardBySlug(slug); - dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + if (res) { + const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); + dispatch(updateLocation(url)); + } +} + +export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: InitDashboardArgs): ThunkResult { + return async dispatch => { + // handle old urls with no uid + if (!urlUid && urlSlug) { + redirectToNewUrl(urlSlug, dispatch); + return; + } let dashDTO = null; + // set fetching state + dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + try { - // fetch dashboard from api - dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + // if no uid or slug, load home dashboard + if (!urlUid && !urlSlug) { + dashDTO = await getBackendSrv().get('/api/dashboards/home'); + + if (dashDTO.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); + dispatch(updateLocation({ path: newUrl })); + return; + } else { + dashDTO.meta.canSave = false; + dashDTO.meta.canShare = false; + dashDTO.meta.canStar = false; + } + } else { + const loaderSrv = $injector.get('dashboardLoaderSrv'); + dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + } } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); console.log(err); @@ -50,13 +80,13 @@ export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: Ini } // init services - injector.get('timeSrv').init(dashboard); - injector.get('annotationsSrv').init(dashboard); + $injector.get('timeSrv').init(dashboard); + $injector.get('annotationsSrv').init(dashboard); // template values service needs to initialize completely before // the rest of the dashboard can load try { - await injector.get('variableSrv').init(dashboard); + await $injector.get('variableSrv').init(dashboard); } catch (err) { dispatch(notifyApp(createErrorNotification('Templating init failed', err.toString()))); console.log(err); @@ -68,11 +98,11 @@ export function initDashboard({ injector, scope, urlUid, urlSlug, urlType }: Ini dashboard.autoFitPanels(window.innerHeight); // init unsaved changes tracking - injector.get('unsavedChangesSrv').init(dashboard, scope); + $injector.get('unsavedChangesSrv').init(dashboard, $scope); - scope.dashboard = dashboard; - injector.get('dashboardViewStateSrv').create(scope); - injector.get('keybindingSrv').setupDashboardBindings(scope, dashboard); + $scope.dashboard = dashboard; + $injector.get('dashboardViewStateSrv').create($scope); + $injector.get('keybindingSrv').setupDashboardBindings($scope, dashboard); } catch (err) { dispatch(notifyApp(createErrorNotification('Dashboard init failed', err.toString()))); console.log(err); diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index cdd9ed89a08..abe347d689a 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -29,10 +29,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { $routeProvider .when('/', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d/:uid/:slug', { template: '', @@ -43,16 +45,20 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }, }) .when('/d/:uid', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/dashboard/:type/:slug', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d-solo/:uid/:slug', { template: '', diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 713cd28efb1..9b1e750e859 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -4,7 +4,7 @@ export interface MutableDashboard { meta: { fullscreen: boolean; isEditing: boolean; - } + }; } export enum DashboardLoadingState { From 09efa24f281c69998865efc627fa278a50187e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 15:29:14 +0100 Subject: [PATCH 011/144] Added more buttons in dashboard nav --- .../dashboard/components/DashNav/DashNav.tsx | 124 +++++++++++------- .../dashboard/containers/DashboardPage.tsx | 17 ++- .../features/dashboard/state/initDashboard.ts | 2 +- 3 files changed, 95 insertions(+), 48 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 79d4da94aca..e82fa0ba75e 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -12,7 +12,7 @@ import { updateLocation } from 'app/core/actions'; import { DashboardModel } from '../../state/DashboardModel'; export interface Props { - dashboard: DashboardModel | null; + dashboard: DashboardModel; editview: string; isEditing: boolean; isFullscreen: boolean; @@ -25,7 +25,20 @@ export class DashNav extends PureComponent { appEvents.emit('show-dash-search'); }; - onAddPanel = () => {}; + onAddPanel = () => { + const { dashboard } = this.props; + + // Return if the "Add panel" exists already + if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { + return; + } + + dashboard.addPanel({ + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: 'Panel Title', + }); + }; onClose = () => { this.props.updateLocation({ @@ -34,6 +47,16 @@ export class DashNav extends PureComponent { }); }; + onToggleTVMode = () => { + appEvents.emit('toggle-kiosk-mode'); + }; + + onSave = () => { + const { $injector } = this.props; + const dashboardSrv = $injector.get('dashboardSrv'); + dashboardSrv.saveDashboard(); + }; + onOpenSettings = () => { this.props.updateLocation({ query: { editview: 'settings' }, @@ -51,30 +74,25 @@ export class DashNav extends PureComponent { }); }; - renderLoadingState() { - return ( - - ); - } + onOpenShare = () => { + const $rootScope = this.props.$injector.get('$rootScope'); + const modalScope = $rootScope.$new(); + modalScope.tabIndex = 0; + modalScope.dashboard = this.props.dashboard; + appEvents.emit('show-modal', { + src: 'public/app/features/dashboard/components/ShareModal/template.html', + scope: modalScope, + }); + }; render() { const { dashboard, isFullscreen, editview } = this.props; - - if (!dashboard) { - return this.renderLoadingState(); - } + const { canEdit, canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; + const { snapshot } = dashboard; const haveFolder = dashboard.meta.folderId > 0; - const { canEdit, canStar, canSave, folderTitle, showSettings, isStarred } = dashboard.meta; + const snapshotUrl = snapshot && snapshot.originalUrl; return (
@@ -124,34 +142,50 @@ export class DashNav extends PureComponent { )} + {canShare && ( + + )} + + {canSave && ( + + )} + + {snapshotUrl && ( + + + + )} + +
+ +
+ { - // - // - // - // - // - // - // - // - // - // - //
- // - // - // // - // } + {(isFullscreen || editview) && (
-
+
+ +
+ + { + // + } + + {(isFullscreen || editview) && ( +
- - { - // - } - - {(isFullscreen || editview) && ( -
- -
- )} -
+ )} ); } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 929b984a93f..8d96a2eec73 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -1,20 +1,25 @@ +// Libaries import moment from 'moment'; import _ from 'lodash'; -import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; +// Constants +import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; + +// Utils & Services import { Emitter } from 'app/core/utils/emitter'; import { contextSrv } from 'app/core/services/context_srv'; import sortByKeys from 'app/core/utils/sort_by_keys'; +// Types import { PanelModel } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { TimeRange } from '@grafana/ui/src'; export class DashboardModel { id: any; - uid: any; - title: any; + uid: string; + title: string; autoUpdate: any; description: any; tags: any; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 19727cd8ab0..01092617e2f 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -7,6 +7,7 @@ import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; import { setDashboardLoadingState, ThunkResult, setDashboardModel } from './actions'; +import { removePanel } from '../utils/panel'; // Types import { DashboardLoadingState } from 'app/types/dashboard'; @@ -102,7 +103,15 @@ export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: I $scope.dashboard = dashboard; $injector.get('dashboardViewStateSrv').create($scope); - $injector.get('keybindingSrv').setupDashboardBindings($scope, dashboard); + + // dashboard keybindings should not live in core, this needs a bigger refactoring + // So declaring this here so it can depend on the removePanel util function + // Long term onRemovePanel should be handled via react prop callback + const onRemovePanel = (panelId: number) => { + removePanel(dashboard, dashboard.getPanelById(panelId), true); + }; + + $injector.get('keybindingSrv').setupDashboardBindings($scope, dashboard, onRemovePanel); } catch (err) { dispatch(notifyApp(createErrorNotification('Dashboard init failed', err.toString()))); console.log(err); diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 6799b209147..41810ab21c0 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -7,6 +7,7 @@ import { Emitter } from 'app/core/core'; import getFactors from 'app/core/utils/factors'; import { duplicatePanel, + removePanel, copyPanel as copyPanelUtil, editPanelJson as editPanelJsonUtil, sharePanel as sharePanelUtil, @@ -213,9 +214,7 @@ export class PanelCtrl { } removePanel() { - this.publishAppEvent('panel-remove', { - panelId: this.panel.id, - }); + removePanel(this.dashboard, this.panel, true); } editPanelJson() { From d7151e5c887082746dbd42cfcdcb4550a50cee30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 18:25:13 +0100 Subject: [PATCH 013/144] improving dash nav react comp --- .../dashboard/components/DashNav/DashNav.tsx | 47 ++++++++++++------- .../components/DashNav/DashNavButton.tsx | 22 +++++++++ .../features/dashboard/state/initDashboard.ts | 2 +- 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 public/app/features/dashboard/components/DashNav/DashNavButton.tsx diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 4513d15ebb7..d6ee9ae1f68 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -5,6 +5,9 @@ import { connect } from 'react-redux'; // Utils & Services import { appEvents } from 'app/core/app_events'; +// Components +import { DashNavButton } from './DashNavButton'; + // State import { updateLocation } from 'app/core/actions'; @@ -41,10 +44,17 @@ export class DashNav extends PureComponent { }; onClose = () => { - this.props.updateLocation({ - query: { editview: null, panelId: null, edit: null, fullscreen: null }, - partial: true, - }); + if (this.props.editview) { + this.props.updateLocation({ + query: { editview: null }, + partial: true, + }); + } else { + this.props.updateLocation({ + query: { panelId: null, edit: null, fullscreen: null }, + partial: true, + }); + } }; onToggleTVMode = () => { @@ -116,19 +126,12 @@ export class DashNav extends PureComponent {
{canEdit && ( - - )} - - {showSettings && ( - + )} {canStar && ( @@ -171,6 +174,16 @@ export class DashNav extends PureComponent { )} + + {showSettings && ( + + )}
diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx new file mode 100644 index 00000000000..1a98bf961dc --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -0,0 +1,22 @@ +// Libraries +import React, { FunctionComponent } from 'react'; + +// Components +import { Tooltip } from '@grafana/ui'; + +interface Props { + icon: string; + tooltip: string; + classSuffix: string; + onClick: () => void; +} + +export const DashNavButton: FunctionComponent = ({ icon, tooltip, classSuffix, onClick }) => { + return ( + + + + ); +}; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 01092617e2f..612ce46b422 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -33,7 +33,7 @@ async function redirectToNewUrl(slug: string, dispatch: any) { export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: InitDashboardArgs): ThunkResult { return async dispatch => { // handle old urls with no uid - if (!urlUid && urlSlug) { + if (!urlUid && urlSlug && !urlType) { redirectToNewUrl(urlSlug, dispatch); return; } From 1f3fafb198fc47e143e81afaa5bf497e1059f401 Mon Sep 17 00:00:00 2001 From: Paresh Date: Sun, 3 Feb 2019 13:07:33 -0600 Subject: [PATCH 014/144] mssql: pass timerange for template variable queries --- .../app/plugins/datasource/mssql/datasource.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts index 23aa5504d3e..1ede9cc3d1e 100644 --- a/public/app/plugins/datasource/mssql/datasource.ts +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -107,13 +107,24 @@ export class MssqlDatasource { format: 'table', }; + const data = { + queries: [interpolatedQuery], + }; + + if (optionalOptions && optionalOptions.range) { + if (optionalOptions.range.from) { + data['from'] = optionalOptions.range.from.valueOf().toString(); + } + if (optionalOptions.range.to) { + data['to'] = optionalOptions.range.to.valueOf().toString(); + } + } + return this.backendSrv .datasourceRequest({ url: '/api/tsdb/query', method: 'POST', - data: { - queries: [interpolatedQuery], - }, + data: data, }) .then(data => this.responseParser.parseMetricFindQueryResult(refId, data)); } From 0324de37d2caa63fac56746ad0dbcea53bea83a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 20:38:13 +0100 Subject: [PATCH 015/144] refactorings and cleanup --- .../dashboard/components/DashNav/DashNav.tsx | 71 ++++++++----------- .../components/DashNav/DashNavButton.tsx | 21 ++++-- .../features/dashboard/state/initDashboard.ts | 28 ++++++-- 3 files changed, 67 insertions(+), 53 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index d6ee9ae1f68..559e3e1d66f 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -135,61 +135,49 @@ export class DashNav extends PureComponent { )} {canStar && ( - + /> )} {canShare && ( - + /> )} {canSave && ( - + )} {snapshotUrl && ( - - - + /> )} {showSettings && ( - + )}
- +
{ @@ -198,13 +186,12 @@ export class DashNav extends PureComponent { {(isFullscreen || editview) && (
- + />
)} diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx index 1a98bf961dc..505baaf1f5d 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -8,15 +8,26 @@ interface Props { icon: string; tooltip: string; classSuffix: string; - onClick: () => void; + onClick?: () => void; + href?: string; } -export const DashNavButton: FunctionComponent = ({ icon, tooltip, classSuffix, onClick }) => { +export const DashNavButton: FunctionComponent = ({ icon, tooltip, classSuffix, onClick, href }) => { + if (onClick) { + return ( + + + + ); + } + return ( - - + ); }; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 612ce46b422..a39a7fce285 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,6 +1,11 @@ // Services & Utils import { createErrorNotification } from 'app/core/copy/appNotification'; import { getBackendSrv } from 'app/core/services/backend_srv'; +import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; +import { VariableSrv } from 'app/features/templating/variable_srv'; +import { KeybindingSrv } from 'app/core/services/keybindingSrv'; // Actions import { updateLocation } from 'app/core/actions'; @@ -81,13 +86,21 @@ export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: I } // init services - $injector.get('timeSrv').init(dashboard); - $injector.get('annotationsSrv').init(dashboard); + const timeSrv: TimeSrv = $injector.get('timeSrv'); + const annotationsSrv: AnnotationsSrv = $injector.get('annotationsSrv'); + const variableSrv: VariableSrv = $injector.get('variableSrv'); + const keybindingSrv: KeybindingSrv = $injector.get('keybindingSrv'); + const unsavedChangesSrv = $injector.get('unsavedChangesSrv'); + const viewStateSrv = $injector.get('dashboardViewStateSrv'); + const dashboardSrv: DashboardSrv = $injector.get('dashboardSrv'); + + timeSrv.init(dashboard); + annotationsSrv.init(dashboard); // template values service needs to initialize completely before // the rest of the dashboard can load try { - await $injector.get('variableSrv').init(dashboard); + await variableSrv.init(dashboard); } catch (err) { dispatch(notifyApp(createErrorNotification('Templating init failed'))); console.log(err); @@ -99,10 +112,10 @@ export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: I dashboard.autoFitPanels(window.innerHeight); // init unsaved changes tracking - $injector.get('unsavedChangesSrv').init(dashboard, $scope); + unsavedChangesSrv.init(dashboard, $scope); $scope.dashboard = dashboard; - $injector.get('dashboardViewStateSrv').create($scope); + viewStateSrv.create($scope); // dashboard keybindings should not live in core, this needs a bigger refactoring // So declaring this here so it can depend on the removePanel util function @@ -111,12 +124,15 @@ export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: I removePanel(dashboard, dashboard.getPanelById(panelId), true); }; - $injector.get('keybindingSrv').setupDashboardBindings($scope, dashboard, onRemovePanel); + keybindingSrv.setupDashboardBindings($scope, dashboard, onRemovePanel); } catch (err) { dispatch(notifyApp(createErrorNotification('Dashboard init failed', err.toString()))); console.log(err); } + // legacy srv state + dashboardSrv.setCurrent(dashboard); + // set model in redux (even though it's mutable) dispatch(setDashboardModel(dashboard)); }; } From 883f7a164b455d3f5f9b7ff23252d37e6a1a9da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 3 Feb 2019 21:06:07 +0100 Subject: [PATCH 016/144] added time picker --- .../dashboard/components/DashNav/DashNav.tsx | 35 +++++++++++++++---- .../dashboard/containers/DashboardPage.tsx | 25 ++++++------- public/sass/components/_navbar.scss | 2 +- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 559e3e1d66f..66edb149433 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -3,6 +3,7 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; // Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import { appEvents } from 'app/core/app_events'; // Components @@ -24,6 +25,24 @@ export interface Props { } export class DashNav extends PureComponent { + timePickerEl: HTMLElement; + timepickerCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template); + } + + componentWillUnmount() { + if (this.timepickerCmp) { + this.timepickerCmp.destroy(); + } + } + onOpenSearch = () => { appEvents.emit('show-dash-search'); }; @@ -98,7 +117,7 @@ export class DashNav extends PureComponent { render() { const { dashboard, isFullscreen, editview } = this.props; - const { canEdit, canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; + const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; const { snapshot } = dashboard; const haveFolder = dashboard.meta.folderId > 0; @@ -125,7 +144,7 @@ export class DashNav extends PureComponent { */}
- {canEdit && ( + {canSave && ( { )} {showSettings && ( - + )}
@@ -180,9 +203,7 @@ export class DashNav extends PureComponent { /> - { - // - } +
(this.timePickerEl = element)} /> {(isFullscreen || editview) && (
diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index d86d5aea221..be12657a829 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -200,20 +200,17 @@ export class DashboardPage extends PureComponent { } } -const mapStateToProps = (state: StoreState) => { - console.log('state location', state.location.query); - return { - urlUid: state.location.routeParams.uid, - urlSlug: state.location.routeParams.slug, - urlType: state.location.routeParams.type, - editview: state.location.query.editview, - urlPanelId: state.location.query.panelId, - urlFullscreen: state.location.query.fullscreen === true, - urlEdit: state.location.query.edit === true, - loadingState: state.dashboard.loadingState, - dashboard: state.dashboard.model as DashboardModel, - }; -}; +const mapStateToProps = (state: StoreState) => ({ + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + editview: state.location.query.editview, + urlPanelId: state.location.query.panelId, + urlFullscreen: state.location.query.fullscreen === true, + urlEdit: state.location.query.edit === true, + loadingState: state.dashboard.loadingState, + dashboard: state.dashboard.model as DashboardModel, +}); const mapDispatchToProps = { initDashboard, diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss index b3733b694fc..0744ed0dfc7 100644 --- a/public/sass/components/_navbar.scss +++ b/public/sass/components/_navbar.scss @@ -102,7 +102,7 @@ display: flex; align-items: center; justify-content: flex-end; - margin-right: $spacer; + margin-left: 10px; &--close { display: none; From 217468074f6f8f420554cbed203db97921324e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 11:19:45 +0100 Subject: [PATCH 017/144] added submenu, made sure submenu visibility is always up to date --- .../app/features/annotations/editor_ctrl.ts | 7 +++- .../DashLinks/DashLinksEditorCtrl.ts | 4 ++- .../components/DashNav/DashNavCtrl.ts | 2 -- .../dashboard/components/SubMenu/SubMenu.tsx | 36 +++++++++++++++++++ .../dashboard/components/SubMenu/index.ts | 1 + .../dashboard/containers/DashboardPage.tsx | 2 ++ .../dashboard/services/DashboardSrv.ts | 7 ++-- .../app/features/explore/ExploreToolbar.tsx | 4 +-- public/sass/components/_navbar.scss | 3 +- 9 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 public/app/features/dashboard/components/SubMenu/SubMenu.tsx diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index 18b00793ff8..c12e442f6d3 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -2,6 +2,7 @@ import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import coreModule from 'app/core/core_module'; +import { DashboardModel } from 'app/features/dashboard/state'; export class AnnotationsEditorCtrl { mode: any; @@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl { currentAnnotation: any; currentDatasource: any; currentIsNew: any; + dashboard: DashboardModel; annotationDefaults: any = { name: '', @@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl { constructor($scope, private datasourceSrv) { $scope.ctrl = this; + this.dashboard = $scope.dashboard; this.mode = 'list'; this.datasources = datasourceSrv.getAnnotationSources(); - this.annotations = $scope.dashboard.annotations.list; + this.annotations = this.dashboard.annotations.list; this.reset(); this.onColorChange = this.onColorChange.bind(this); @@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl { this.annotations.push(this.currentAnnotation); this.reset(); this.mode = 'list'; + this.dashboard.updateSubmenuVisibility(); } removeAnnotation(annotation) { const index = _.indexOf(this.annotations, annotation); this.annotations.splice(index, 1); + this.dashboard.updateSubmenuVisibility(); } onColorChange(newColor) { diff --git a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts index 398ad757bf3..339c8e7de4c 100644 --- a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts +++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts @@ -1,5 +1,6 @@ import angular from 'angular'; import _ from 'lodash'; +import { DashboardModel } from 'app/features/dashboard/state'; export let iconMap = { 'external link': 'fa-external-link', @@ -12,7 +13,7 @@ export let iconMap = { }; export class DashLinksEditorCtrl { - dashboard: any; + dashboard: DashboardModel; iconMap: any; mode: any; link: any; @@ -40,6 +41,7 @@ export class DashLinksEditorCtrl { addLink() { this.dashboard.links.push(this.link); this.mode = 'list'; + this.dashboard.updateSubmenuVisibility(); } editLink(link) { diff --git a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts index e75c1468a1f..fbf84d354e3 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts +++ b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts @@ -10,8 +10,6 @@ export class DashNavCtrl { /** @ngInject */ constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) { - appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope); - if (this.dashboard.meta.isSnapshot) { const meta = this.dashboard.meta; this.titleTooltip = 'Created:  ' + moment(meta.created).calendar(); diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx new file mode 100644 index 00000000000..caef8f2de38 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -0,0 +1,36 @@ +// Libaries +import React, { PureComponent } from 'react'; + +// Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel | null; +} + +export class SubMenu extends PureComponent { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
this.element = element} />; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts index 1790aa66782..ca113ab75d6 100644 --- a/public/app/features/dashboard/components/SubMenu/index.ts +++ b/public/app/features/dashboard/components/SubMenu/index.ts @@ -1 +1,2 @@ export { SubMenuCtrl } from './SubMenuCtrl'; +export { SubMenu } from './SubMenu'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index be12657a829..e01998f65a9 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -12,6 +12,7 @@ import { createErrorNotification } from 'app/core/copy/appNotification'; import { LoadingPlaceholder } from '@grafana/ui'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; import { DashNav } from '../components/DashNav'; +import { SubMenu } from '../components/SubMenu'; import { DashboardSettings } from '../components/DashboardSettings'; // Redux @@ -192,6 +193,7 @@ export class DashboardPage extends PureComponent { {dashboard && editview && }
+ {dashboard.meta.submenuEnabled && }
diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 03aeb34ed36..7d3dfb68cd8 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -1,12 +1,15 @@ import coreModule from 'app/core/core_module'; -import { DashboardModel } from '../state/DashboardModel'; +import { appEvents } from 'app/core/app_events'; import locationUtil from 'app/core/utils/location_util'; +import { DashboardModel } from '../state/DashboardModel'; export class DashboardSrv { dash: any; /** @ngInject */ - constructor(private backendSrv, private $rootScope, private $location) {} + constructor(private backendSrv, private $rootScope, private $location) { + appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); + } create(dashboard, meta) { return new DashboardModel(dashboard, meta); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 35f06d11c81..228cfb147e8 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -97,10 +97,10 @@ export class UnConnectedExploreToolbar extends PureComponent {
{exploreId === 'left' && ( - + Explore - + )}
diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss index 0744ed0dfc7..0cfa314a985 100644 --- a/public/sass/components/_navbar.scss +++ b/public/sass/components/_navbar.scss @@ -83,8 +83,7 @@ font-size: 19px; line-height: 8px; opacity: 0.75; - margin-right: 8px; - // icon hidden on smaller screens + margin-right: 13px; display: none; } From ae768193e3b78532ff7b2c2dbdbac2e7ff1fff05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 13:49:14 +0100 Subject: [PATCH 018/144] Now handles all dashbord routes --- public/app/core/reducers/location.ts | 4 +- public/app/core/services/bridge_srv.ts | 4 + .../dashboard/containers/DashboardPage.tsx | 8 +- .../services/DashboardViewStateSrv.ts | 12 +-- .../features/dashboard/state/initDashboard.ts | 100 ++++++++++++++---- public/app/routes/ReactContainer.tsx | 1 + public/app/routes/routes.ts | 25 +++-- public/app/types/dashboard.ts | 9 +- public/app/types/location.ts | 5 + 9 files changed, 132 insertions(+), 36 deletions(-) diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 6b39710dcca..c038ab53c9f 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -8,12 +8,13 @@ export const initialState: LocationState = { path: '', query: {}, routeParams: {}, + replace: false, }; export const locationReducer = (state = initialState, action: Action): LocationState => { switch (action.type) { case CoreActionTypes.UpdateLocation: { - const { path, routeParams } = action.payload; + const { path, routeParams, replace } = action.payload; let query = action.payload.query || state.query; if (action.payload.partial) { @@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS path: path || state.path, query: { ...query }, routeParams: routeParams || state.routeParams, + replace: replace === true, }; } } diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts index 37f71946364..8bb828310cf 100644 --- a/public/app/core/services/bridge_srv.ts +++ b/public/app/core/services/bridge_srv.ts @@ -46,6 +46,10 @@ export class BridgeSrv { if (angularUrl !== url) { this.$timeout(() => { this.$location.url(url); + // some state changes should not trigger new browser history + if (state.location.replace) { + this.$location.replace(); + } }); console.log('store updating angular $location.url', url); } diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index e01998f65a9..3fbac681498 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -22,9 +22,8 @@ import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { StoreState } from 'app/types'; +import { StoreState, DashboardLoadingState, DashboardRouteInfo } from 'app/types'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; -import { DashboardLoadingState } from 'app/types/dashboard'; interface Props { urlUid?: string; @@ -32,8 +31,10 @@ interface Props { urlType?: string; editview?: string; urlPanelId?: string; + urlFolderId?: string; $scope: any; $injector: any; + routeInfo: DashboardRouteInfo; urlEdit: boolean; urlFullscreen: boolean; loadingState: DashboardLoadingState; @@ -66,6 +67,8 @@ export class DashboardPage extends PureComponent { urlSlug: this.props.urlSlug, urlUid: this.props.urlUid, urlType: this.props.urlType, + urlFolderId: this.props.urlFolderId, + routeInfo: this.props.routeInfo, }); } @@ -208,6 +211,7 @@ const mapStateToProps = (state: StoreState) => ({ urlType: state.location.routeParams.type, editview: state.location.query.editview, urlPanelId: state.location.query.panelId, + urlFolderId: state.location.query.folderId, urlFullscreen: state.location.query.fullscreen === true, urlEdit: state.location.query.edit === true, loadingState: state.dashboard.loadingState, diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts index f5a68d6f647..aa64a2e93cf 100644 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts @@ -23,10 +23,10 @@ export class DashboardViewStateSrv { self.dashboard = $scope.dashboard; $scope.onAppEvent('$routeUpdate', () => { - const urlState = self.getQueryStringState(); - if (self.needsSync(urlState)) { - self.update(urlState, true); - } + // const urlState = self.getQueryStringState(); + // if (self.needsSync(urlState)) { + // self.update(urlState, true); + // } }); $scope.onAppEvent('panel-change-view', (evt, payload) => { @@ -35,8 +35,8 @@ export class DashboardViewStateSrv { // this marks changes to location during this digest cycle as not to add history item // don't want url changes like adding orgId to add browser history - $location.replace(); - this.update(this.getQueryStringState()); + // $location.replace(); + // this.update(this.getQueryStringState()); } needsSync(urlState) { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index a39a7fce285..d497ef92d1f 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -15,7 +15,7 @@ import { setDashboardLoadingState, ThunkResult, setDashboardModel } from './acti import { removePanel } from '../utils/panel'; // Types -import { DashboardLoadingState } from 'app/types/dashboard'; +import { DashboardLoadingState, DashboardRouteInfo } from 'app/types'; import { DashboardModel } from './DashboardModel'; export interface InitDashboardArgs { @@ -24,6 +24,8 @@ export interface InitDashboardArgs { urlUid?: string; urlSlug?: string; urlType?: string; + urlFolderId: string; + routeInfo: string; } async function redirectToNewUrl(slug: string, dispatch: any) { @@ -35,36 +37,67 @@ async function redirectToNewUrl(slug: string, dispatch: any) { } } -export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: InitDashboardArgs): ThunkResult { - return async dispatch => { - // handle old urls with no uid - if (!urlUid && urlSlug && !urlType) { - redirectToNewUrl(urlSlug, dispatch); - return; - } - +export function initDashboard({ + $injector, + $scope, + urlUid, + urlSlug, + urlType, + urlFolderId, + routeInfo, +}: InitDashboardArgs): ThunkResult { + return async (dispatch, getState) => { let dashDTO = null; // set fetching state dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); try { - // if no uid or slug, load home dashboard - if (!urlUid && !urlSlug) { - dashDTO = await getBackendSrv().get('/api/dashboards/home'); - - if (dashDTO.redirectUri) { - const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); - dispatch(updateLocation({ path: newUrl })); + switch (routeInfo) { + // handle old urls with no uid + case DashboardRouteInfo.Old: { + redirectToNewUrl(urlSlug, dispatch); return; - } else { + } + case DashboardRouteInfo.Home: { + // load home dash + dashDTO = await getBackendSrv().get('/api/dashboards/home'); + + // if user specified a custom home dashboard redirect to that + if (dashDTO.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); + dispatch(updateLocation({ path: newUrl, replace: true })); + return; + } + + // disable some actions on the default home dashboard dashDTO.meta.canSave = false; dashDTO.meta.canShare = false; dashDTO.meta.canStar = false; + break; + } + case DashboardRouteInfo.Normal: { + const loaderSrv = $injector.get('dashboardLoaderSrv'); + dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); + + // check if the current url is correct (might be old slug) + const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); + const currentPath = getState().location.path; + console.log('loading dashboard: currentPath', currentPath); + console.log('loading dashboard: dashboardUrl', dashboardUrl); + + if (dashboardUrl !== currentPath) { + // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. + dispatch(updateLocation({path: dashboardUrl, partial: true, replace: true})); + return; + } + + break; + } + case DashboardRouteInfo.New: { + dashDTO = getNewDashboardModelData(urlFolderId); + break; } - } else { - const loaderSrv = $injector.get('dashboardLoaderSrv'); - dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); } } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); @@ -136,3 +169,30 @@ export function initDashboard({ $injector, $scope, urlUid, urlSlug, urlType }: I dispatch(setDashboardModel(dashboard)); }; } + +function getNewDashboardModelData(urlFolderId?: string): any { + const data = { + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'New dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + }, + ], + }, + }; + + if (urlFolderId) { + data.meta.folderId = parseInt(urlFolderId, 10); + } + + return data; +} diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index 2cad3d828bf..a56c8878fb1 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -44,6 +44,7 @@ export function reactContainer( $injector: $injector, $rootScope: $rootScope, $scope: scope, + routeInfo: $route.current.$$route.routeInfo, }; ReactDOM.render(WrapInProvider(store, component, props), elem[0]); diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index abe347d689a..ecd934cdccf 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -2,6 +2,7 @@ import './dashboard_loaders'; import './ReactContainer'; import { applyRouteRegistrationHandlers } from './registry'; +// Pages import ServerStats from 'app/features/admin/ServerStats'; import AlertRuleList from 'app/features/alerting/AlertRuleList'; import TeamPages from 'app/features/teams/TeamPages'; @@ -23,6 +24,9 @@ import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage'; import DashboardPage from '../features/dashboard/containers/DashboardPage'; import config from 'app/core/config'; +// Types +import { DashboardRouteInfo } from 'app/types'; + /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); @@ -31,6 +35,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/', { template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Home, reloadOnSearch: false, resolve: { component: () => DashboardPage, @@ -39,6 +44,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/d/:uid/:slug', { template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Normal, reloadOnSearch: false, resolve: { component: () => DashboardPage, @@ -48,6 +54,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { template: '', pageClass: 'page-dashboard', reloadOnSearch: false, + routeInfo: DashboardRouteInfo.Normal, resolve: { component: () => DashboardPage, }, @@ -55,6 +62,16 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/dashboard/:type/:slug', { template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Old, + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, + }) + .when('/dashboard/new', { + template: '', + pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.New, reloadOnSearch: false, resolve: { component: () => DashboardPage, @@ -63,6 +80,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/d-solo/:uid/:slug', { template: '', pageClass: 'dashboard-solo', + routeInfo: DashboardRouteInfo.Normal, resolve: { component: () => SoloPanelPage, }, @@ -70,16 +88,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/dashboard-solo/:type/:slug', { template: '', pageClass: 'dashboard-solo', + routeInfo: DashboardRouteInfo.Old, resolve: { component: () => SoloPanelPage, }, }) - .when('/dashboard/new', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'NewDashboardCtrl', - reloadOnSearch: false, - pageClass: 'page-dashboard', - }) .when('/dashboard/import', { templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html', controller: DashboardImportCtrl, diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 9b1e750e859..9b8f539aeb2 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -7,9 +7,16 @@ export interface MutableDashboard { }; } +export enum DashboardRouteInfo { + Old = 'old-dashboard', + Home = 'home-dashboard', + New = 'new-dashboard', + Normal = 'normal-dashboard', +} + export enum DashboardLoadingState { NotStarted = 'Not started', - Fetching = 'Fetching', + Fetching = 'Fetching', Initializing = 'Initializing', Error = 'Error', Done = 'Done', diff --git a/public/app/types/location.ts b/public/app/types/location.ts index 7dcf57f7e02..a47ef05d2be 100644 --- a/public/app/types/location.ts +++ b/public/app/types/location.ts @@ -3,6 +3,10 @@ export interface LocationUpdate { query?: UrlQueryMap; routeParams?: UrlQueryMap; partial?: boolean; + /* + * If true this will replace url state (ie cause no new browser history) + */ + replace?: boolean; } export interface LocationState { @@ -10,6 +14,7 @@ export interface LocationState { path: string; query: UrlQueryMap; routeParams: UrlQueryMap; + replace: boolean; } export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[]; From 7634e0423159485366afe89d3ff67445f3837759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 14:45:13 +0100 Subject: [PATCH 019/144] Fixed template variable value changed handling --- .../components/AdHocFilters/AdHocFiltersCtrl.ts | 6 ++++-- .../dashboard/components/DashboardRow/DashboardRow.tsx | 4 ++-- .../dashboard/components/SubMenu/template.html | 2 +- public/app/features/dashboard/state/DashboardModel.ts | 5 +++++ public/app/features/dashboard/state/initDashboard.ts | 1 - .../app/features/templating/specs/variable_srv.test.ts | 1 - .../templating/specs/variable_srv_init.test.ts | 5 +---- public/app/features/templating/variable_srv.ts | 10 +++++----- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts index 0ceac9ddbba..a7616e0e513 100644 --- a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts +++ b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts @@ -1,10 +1,12 @@ import _ from 'lodash'; import angular from 'angular'; import coreModule from 'app/core/core_module'; +import { DashboardModel } from 'app/features/dashboard/state'; export class AdHocFiltersCtrl { segments: any; variable: any; + dashboard: DashboardModel; removeTagFilterSegment: any; /** @ngInject */ @@ -14,14 +16,13 @@ export class AdHocFiltersCtrl { private $q, private variableSrv, $scope, - private $rootScope ) { this.removeTagFilterSegment = uiSegmentSrv.newSegment({ fake: true, value: '-- remove filter --', }); this.buildSegmentModel(); - this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope); + this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope); } buildSegmentModel() { @@ -171,6 +172,7 @@ export function adHocFiltersComponent() { controllerAs: 'ctrl', scope: { variable: '=', + dashboard: '=', }, }; } diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx index e7778a31fdb..bb63cea90ea 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -18,11 +18,11 @@ export class DashboardRow extends React.Component { collapsed: this.props.panel.collapsed, }; - appEvents.on('template-variable-value-updated', this.onVariableUpdated); + this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated); } componentWillUnmount() { - appEvents.off('template-variable-value-updated', this.onVariableUpdated); + this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated); } onVariableUpdated = () => { diff --git a/public/app/features/dashboard/components/SubMenu/template.html b/public/app/features/dashboard/components/SubMenu/template.html index 5d0f200d862..1ccbfcc915c 100644 --- a/public/app/features/dashboard/components/SubMenu/template.html +++ b/public/app/features/dashboard/components/SubMenu/template.html @@ -7,7 +7,7 @@
- +
diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 8d96a2eec73..ab9d764358c 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -900,4 +900,9 @@ export class DashboardModel { panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1; }); } + + templateVariableValueUpdated() { + this.processRepeats(); + this.events.emit('template-variable-value-updated'); + } } diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index d497ef92d1f..a697a8e77f9 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -91,7 +91,6 @@ export function initDashboard({ dispatch(updateLocation({path: dashboardUrl, partial: true, replace: true})); return; } - break; } case DashboardRouteInfo.New: { diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts index db42df7f516..cf10235f6e8 100644 --- a/public/app/features/templating/specs/variable_srv.test.ts +++ b/public/app/features/templating/specs/variable_srv.test.ts @@ -48,7 +48,6 @@ describe('VariableSrv', function(this: any) { ds.metricFindQuery = () => Promise.resolve(scenario.queryResult); ctx.variableSrv = new VariableSrv( - ctx.$rootScope, $q, ctx.$location, ctx.$injector, diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts index b8cabf711ac..d256ab28c2c 100644 --- a/public/app/features/templating/specs/variable_srv_init.test.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -25,9 +25,6 @@ describe('VariableSrv init', function(this: any) { }; const $injector = {} as any; - const $rootscope = { - $on: () => {}, - }; let ctx = {} as any; @@ -54,7 +51,7 @@ describe('VariableSrv init', function(this: any) { }; // @ts-ignore - ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv, timeSrv); + ctx.variableSrv = new VariableSrv($q, {}, $injector, templateSrv, timeSrv); $injector.instantiate = (variable, model) => { return getVarMockConstructor(variable, model, ctx); diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index b2f8b43fb08..81e4e0a4a0b 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -18,18 +18,18 @@ export class VariableSrv { variables: any[]; /** @ngInject */ - constructor(private $rootScope, - private $q, + constructor(private $q, private $location, private $injector, private templateSrv: TemplateSrv, private timeSrv: TimeSrv) { - $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope); + } init(dashboard: DashboardModel) { this.dashboard = dashboard; this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this)); + this.dashboard.events.on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this)); // create working class models representing variables this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this)); @@ -59,7 +59,7 @@ export class VariableSrv { return variable.updateOptions().then(() => { if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) { - this.$rootScope.$emit('template-variable-value-updated'); + this.dashboard.templateVariableValueUpdated(); } }); }); @@ -144,7 +144,7 @@ export class VariableSrv { return this.$q.all(promises).then(() => { if (emitChangeEvents) { - this.$rootScope.appEvent('template-variable-value-updated'); + this.dashboard.templateVariableValueUpdated(); this.dashboard.startRefresh(); } }); From f695975f651457454bc5f6ec94455487ca12da0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 15:02:35 +0100 Subject: [PATCH 020/144] Fixed handling of orgId --- public/app/core/config.ts | 2 +- public/app/features/dashboard/services/DashboardSrv.ts | 1 + public/app/features/dashboard/state/initDashboard.ts | 8 +++++++- .../features/templating/specs/variable_srv_init.test.ts | 1 - public/app/types/user.ts | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 395e40e914b..368b3798117 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -68,5 +68,5 @@ const bootData = (window as any).grafanaBootData || { const options = bootData.settings; options.bootData = bootData; -const config = new Settings(options); +export const config = new Settings(options); export default config; diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 7d3dfb68cd8..532e2e1c828 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -9,6 +9,7 @@ export class DashboardSrv { /** @ngInject */ constructor(private backendSrv, private $rootScope, private $location) { appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); + appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); } create(dashboard, meta) { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index a697a8e77f9..14d6196d69c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -6,6 +6,7 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; import { VariableSrv } from 'app/features/templating/variable_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; +import { config } from 'app/core/config'; // Actions import { updateLocation } from 'app/core/actions'; @@ -88,7 +89,7 @@ export function initDashboard({ if (dashboardUrl !== currentPath) { // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. - dispatch(updateLocation({path: dashboardUrl, partial: true, replace: true})); + dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true })); return; } break; @@ -117,6 +118,11 @@ export function initDashboard({ return; } + // add missing orgId query param + if (!getState().location.query.orgId) { + dispatch(updateLocation({ query: { orgId: config.bootData.user.orgId }, partial: true, replace: true })); + } + // init services const timeSrv: TimeSrv = $injector.get('timeSrv'); const annotationsSrv: AnnotationsSrv = $injector.get('annotationsSrv'); diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts index d256ab28c2c..480b5207c17 100644 --- a/public/app/features/templating/specs/variable_srv_init.test.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -25,7 +25,6 @@ describe('VariableSrv init', function(this: any) { }; const $injector = {} as any; - let ctx = {} as any; function describeInitScenario(desc, fn) { diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 37c80074dca..365411147bb 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,4 +1,4 @@ -import { DashboardSearchHit } from './search'; +import { DashboardSearchHit } from './search'; export interface OrgUser { avatarUrl: string; From 716258339625e8b5e36f2615537517a2b2e8f362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 15:41:57 +0100 Subject: [PATCH 021/144] Made dashboard view state srv panel view state obsolete --- public/app/core/services/keybindingSrv.ts | 6 +- .../dashboard/containers/DashboardPage.tsx | 8 +-- .../dashboard/services/DashboardSrv.ts | 39 +++++++++++ .../services/DashboardViewStateSrv.test.ts | 64 ------------------- .../services/DashboardViewStateSrv.ts | 2 +- .../dashboard/state/DashboardModel.ts | 2 + 6 files changed, 49 insertions(+), 72 deletions(-) delete mode 100644 public/app/features/dashboard/services/DashboardViewStateSrv.test.ts diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index aa39763841e..dfacc483b8e 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -104,7 +104,7 @@ export class KeybindingSrv { } if (search.fullscreen) { - this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false }); + appEvents.emit('panel-change-view', { fullscreen: false, edit: false }); return; } @@ -174,7 +174,7 @@ export class KeybindingSrv { // edit panel this.bind('e', () => { if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - this.$rootScope.appEvent('panel-change-view', { + appEvents.emit('panel-change-view', { fullscreen: true, edit: true, panelId: dashboard.meta.focusPanelId, @@ -186,7 +186,7 @@ export class KeybindingSrv { // view panel this.bind('v', () => { if (dashboard.meta.focusPanelId) { - this.$rootScope.appEvent('panel-change-view', { + appEvents.emit('panel-change-view', { fullscreen: true, edit: null, panelId: dashboard.meta.focusPanelId, diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 3fbac681498..ec143d735ab 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -94,7 +94,7 @@ export class DashboardPage extends PureComponent { // } // Sync url state with model - if (urlFullscreen !== dashboard.meta.isFullscreen || urlEdit !== dashboard.meta.isEditing) { + if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) { // entering fullscreen/edit mode if (urlPanelId) { const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); @@ -102,6 +102,7 @@ export class DashboardPage extends PureComponent { if (panel) { dashboard.setViewMode(panel, urlFullscreen, urlEdit); this.setState({ isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel }); + this.setPanelFullscreenClass(urlFullscreen); } else { this.handleFullscreenPanelNotFound(urlPanelId); } @@ -110,10 +111,9 @@ export class DashboardPage extends PureComponent { if (this.state.fullscreenPanel) { dashboard.setViewMode(this.state.fullscreenPanel, urlFullscreen, urlEdit); } - this.setState({ isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: null }); + this.setState({ isEditing: false, isFullscreen: false, fullscreenPanel: null }); + this.setPanelFullscreenClass(false); } - - this.setPanelFullscreenClass(urlFullscreen); } } diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 532e2e1c828..e2e524941f8 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -10,6 +10,7 @@ export class DashboardSrv { constructor(private backendSrv, private $rootScope, private $location) { appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); + appEvents.on('panel-change-view', this.onPanelChangeView); } create(dashboard, meta) { @@ -24,6 +25,44 @@ export class DashboardSrv { return this.dash; } + onPanelChangeView = (options) => { + const urlParams = this.$location.search(); + + // handle toggle logic + if (options.fullscreen === urlParams.fullscreen) { + // I hate using these truthy converters (!!) but in this case + // I think it's appropriate. edit can be null/false/undefined and + // here i want all of those to compare the same + if (!!options.edit === !!urlParams.edit) { + delete urlParams.fullscreen; + delete urlParams.edit; + delete urlParams.panelId; + this.$location.search(urlParams); + return; + } + } + + if (options.fullscreen) { + urlParams.fullscreen = true; + } else { + delete urlParams.fullscreen; + } + + if (options.edit) { + urlParams.edit = true; + } else { + delete urlParams.edit; + } + + if (options.panelId) { + urlParams.panelId = options.panelId; + } else { + delete urlParams.panelId; + } + + this.$location.search(urlParams); + }; + handleSaveDashboardError(clone, options, err) { options = options || {}; options.overwrite = true; diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts deleted file mode 100644 index 12bb11b7a08..00000000000 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import config from 'app/core/config'; -import { DashboardViewStateSrv } from './DashboardViewStateSrv'; -import { DashboardModel } from '../state/DashboardModel'; - -describe('when updating view state', () => { - const location = { - replace: jest.fn(), - search: jest.fn(), - }; - - const $scope = { - appEvent: jest.fn(), - onAppEvent: jest.fn(() => {}), - dashboard: new DashboardModel({ - panels: [{ id: 1 }], - }), - }; - - let viewState; - - beforeEach(() => { - config.bootData = { - user: { - orgId: 1, - }, - }; - }); - - describe('to fullscreen true and edit true', () => { - beforeEach(() => { - location.search = jest.fn(() => { - return { fullscreen: true, edit: true, panelId: 1 }; - }); - viewState = new DashboardViewStateSrv($scope, location, {}); - }); - - it('should update querystring and view state', () => { - const updateState = { fullscreen: true, edit: true, panelId: 1 }; - - viewState.update(updateState); - - expect(location.search).toHaveBeenCalledWith({ - edit: true, - editview: null, - fullscreen: true, - orgId: 1, - panelId: 1, - }); - expect(viewState.dashboard.meta.fullscreen).toBe(true); - expect(viewState.state.fullscreen).toBe(true); - }); - }); - - describe('to fullscreen false', () => { - beforeEach(() => { - viewState = new DashboardViewStateSrv($scope, location, {}); - }); - it('should remove params from query string', () => { - viewState.update({ fullscreen: true, panelId: 1, edit: true }); - viewState.update({ fullscreen: false }); - expect(viewState.state.fullscreen).toBe(null); - }); - }); -}); diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts index aa64a2e93cf..7cb4c1de7ab 100644 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts @@ -30,7 +30,7 @@ export class DashboardViewStateSrv { }); $scope.onAppEvent('panel-change-view', (evt, payload) => { - self.update(payload); + // self.update(payload); }); // this marks changes to location during this digest cycle as not to add history item diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index ab9d764358c..a6d45bb3cc0 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -132,6 +132,8 @@ export class DashboardModel { meta.canEdit = meta.canEdit !== false; meta.showSettings = meta.canEdit; meta.canMakeEditable = meta.canSave && !this.editable; + meta.fullscreen = false; + meta.isEditing = false; if (!this.editable) { meta.canEdit = false; From fdeea9144ccc62c1be15663e215edb5dc7261ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 15:50:47 +0100 Subject: [PATCH 022/144] fixed unit test --- .../dashboard/components/DashboardRow/DashboardRow.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx index 9ac6a6b74e1..96b673242e4 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx @@ -9,6 +9,7 @@ describe('DashboardRow', () => { beforeEach(() => { dashboardMock = { toggleRow: jest.fn(), + on: jest.fn(), meta: { canEdit: true, }, From b9c58d88dc381dc096a66bc9eadcc6489cf70588 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 4 Feb 2019 16:48:27 +0100 Subject: [PATCH 023/144] basic layout --- .../AddPanelWidget/AddPanelWidget.tsx | 29 ++++++++++--------- .../AddPanelWidget/_AddPanelWidget.scss | 22 +++++++++++++- public/sass/base/_icons.scss | 2 +- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index 8c1ab93cec1..7a9767666c0 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -125,13 +125,20 @@ export class AddPanelWidget extends React.Component { dashboard.removePanel(this.props.panel); }; + renderOptionLink = (icon, text, onClick) => { + return ( + + ); + }; + render() { - let addCopyButton; - - if (this.state.copiedPanelPlugins.length === 1) { - addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]); - } - return (
@@ -142,13 +149,9 @@ export class AddPanelWidget extends React.Component {
- - {addCopyButton} - + {this.renderOptionLink('queries', 'Add query', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Panel type', this.onCreateNewPanel)} + {this.renderOptionLink('queries', 'Convert to row', this.onCreateNewRow)}
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 5a1cbee4b44..587daa2703f 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -26,6 +26,26 @@ } } +.add-panel-widget__link { + display: block; + margin: 0 8px; + width: 130px; + text-align: center; + padding: 8px 0; +} + +.add-panel-widget__icon { + margin-bottom: 8px; + + .gicon { + color: white; + height: 44px; + width: 53px; + position: relative; + left: 5px; + } +} + .add-panel-widget__close { margin-left: auto; background-color: transparent; @@ -39,7 +59,7 @@ justify-content: center; align-items: center; height: 100%; - flex-direction: column; + flex-direction: row; .btn { margin-bottom: 10px; diff --git a/public/sass/base/_icons.scss b/public/sass/base/_icons.scss index a60259ac0f2..a2649b31fcd 100644 --- a/public/sass/base/_icons.scss +++ b/public/sass/base/_icons.scss @@ -212,7 +212,7 @@ padding-right: 5px; } -.panel-editor-tabs { +.panel-editor-tabs, .add-panel-widget__icon { .gicon-advanced-active { background-image: url('../img/icons_#{$theme-name}_theme/icon_advanced_active.svg'); } From f6b46f7a34c822508605ffca9da8c564c26fb209 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 4 Feb 2019 17:18:46 +0100 Subject: [PATCH 024/144] prepping go to visualization --- .../components/AddPanelWidget/AddPanelWidget.tsx | 9 +++++---- .../features/dashboard/panel_editor/VisualizationTab.tsx | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index 7a9767666c0..b3d5e6167c4 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -62,20 +62,21 @@ export class AddPanelWidget extends React.Component { ); } - moveToEdit(panel) { + moveToEdit(panel, tab) { reduxStore.dispatch( updateLocation({ query: { panelId: panel.id, edit: true, fullscreen: true, + tab: tab, }, partial: true, }) ); } - onCreateNewPanel = () => { + onCreateNewPanel = (tab = 'queries') => { const dashboard = this.props.dashboard; const { gridPos } = this.props.panel; @@ -88,7 +89,7 @@ export class AddPanelWidget extends React.Component { dashboard.addPanel(newPanel); dashboard.removePanel(this.props.panel); - this.moveToEdit(newPanel); + this.moveToEdit(newPanel, tab); }; onPasteCopiedPanel = panelPluginInfo => { @@ -150,7 +151,7 @@ export class AddPanelWidget extends React.Component {
{this.renderOptionLink('queries', 'Add query', this.onCreateNewPanel)} - {this.renderOptionLink('visualization', 'Choose Panel type', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Panel type', () => this.onCreateNewPanel('visualization'))} {this.renderOptionLink('queries', 'Convert to row', this.onCreateNewRow)}
diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index 35b9b71112a..fdf978acdf9 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -3,6 +3,7 @@ import React, { PureComponent } from 'react'; // Utils & Services import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; +//TODO: See PanelEdit // Components import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; From 7626ce9922ccac4b15f82853eef8439ab37d78a7 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 4 Feb 2019 17:28:57 +0100 Subject: [PATCH 025/144] WIP Enable js defined theme to be used in SASS --- package.json | 1 + packages/grafana-ui/src/index.ts | 1 + packages/grafana-ui/src/theme.d.ts | 116 ++++++++++++++++++++++ packages/grafana-ui/src/theme.js | 15 +++ packages/grafana-ui/src/themes/dark.js | 64 ++++++++++++ packages/grafana-ui/src/themes/default.js | 47 +++++++++ packages/grafana-ui/src/themes/light.js | 65 ++++++++++++ public/sass/_variables.dark.scss | 101 +++++++++---------- public/sass/_variables.light.scss | 94 +++++++++--------- public/sass/_variables.scss | 44 ++++---- scripts/webpack/getThemeVariable.js | 53 ++++++++++ scripts/webpack/sass.rule.js | 13 ++- scripts/webpack/webpack.hot.js | 10 +- yarn.lock | 5 + 14 files changed, 506 insertions(+), 123 deletions(-) create mode 100644 packages/grafana-ui/src/theme.d.ts create mode 100644 packages/grafana-ui/src/theme.js create mode 100644 packages/grafana-ui/src/themes/dark.js create mode 100644 packages/grafana-ui/src/themes/default.js create mode 100644 packages/grafana-ui/src/themes/light.js create mode 100644 scripts/webpack/getThemeVariable.js diff --git a/package.json b/package.json index 77fd92baf57..60a8a20cde3 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "ng-annotate-webpack-plugin": "^0.3.0", "ngtemplate-loader": "^2.0.1", "node-sass": "^4.11.0", + "node-sass-utils": "^1.1.2", "npm": "^5.4.2", "optimize-css-assets-webpack-plugin": "^4.0.2", "phantomjs-prebuilt": "^2.1.15", diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index 974d976bbef..4ddc7c8485a 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './types'; export * from './utils'; +export * from './theme'; diff --git a/packages/grafana-ui/src/theme.d.ts b/packages/grafana-ui/src/theme.d.ts new file mode 100644 index 00000000000..015bcd16136 --- /dev/null +++ b/packages/grafana-ui/src/theme.d.ts @@ -0,0 +1,116 @@ +export interface GrafanaThemeType { + name: string; + // TODO: not sure if should be a part of theme + brakpoints: { + xs: string; + s: string; + m: string; + l: string; + xl: string; + }; + typography: { + fontFamily: { + sansSerif: string; + serif: string; + monospace: string; + }; + size: { + base: string; + xs: string; + s: string; + m: string; + l: string; + }; + weight: { + light: number; + normal: number; + semibold: number; + }; + lineHeight: { + xs: number; //1 + s: number; //1.1 + m: number; // 4/3 + l: number; // 1.5 + }; + // TODO: Refactor to use size instead of custom defs + heading: { + h1: string; + h2: string; + h3: string; + h4: string; + h5: string; + h6: string; + }; + }; + spacing: { + xs: string; + s: string; + m: string; + l: string; + gutter: string; + }; + border: { + radius: { + xs: string; + s: string; + m: string; + }; + }; + colors: { + black: string; + white: string; + dark1: string; + dark2: string; + dark3: string; + dark4: string; + dark5: string; + gray1: string; + gray2: string; + gray3: string; + gray4: string; + gray5: string; + gray6: string; + gray7: string; + grayBlue: string; + inputBlack: string; + + // Accent colors + blue: string; + blueLight: string; + blueDark: string; + green: string; + red: string; + yellow: string; + pink: string; + purple: string; + variable: string; + orange: string; + queryRed: string; + queryGreen: string; + queryPurple: string; + queryKeyword: string; + queryOrange: string; + + // Status colors + online: string; + warn: string; + critical: string; + + // TODO: should this be a part of theme? + bodyBg: string; + pageBg: string; + bodyColor: string; + textColor: string; + textColorStrong: string; + textColorWeak: string; + textColorFaint: string; + textColorEmphasis: string; + linkColor: string; + linkColorDisabled: string; + linkColorHover: string; + linkColorExternal: string; + headingColor: string; + }; +} +export function getTheme(): GrafanaThemeType +export function mockTheme(themeMock: Partial): () => void diff --git a/packages/grafana-ui/src/theme.js b/packages/grafana-ui/src/theme.js new file mode 100644 index 00000000000..3d0695e2490 --- /dev/null +++ b/packages/grafana-ui/src/theme.js @@ -0,0 +1,15 @@ +const darkTheme = require('./themes/dark'); +const lightTheme = require('./themes/light'); + +const getTheme = name => (name === 'light' ? lightTheme : darkTheme); + +const mockTheme = mock => { + const originalGetTheme = getTheme; + getTheme = () => mock; + return () => (getTheme = originalGetTheme); +}; + +module.exports = { + getTheme, + mockTheme, +}; diff --git a/packages/grafana-ui/src/themes/dark.js b/packages/grafana-ui/src/themes/dark.js new file mode 100644 index 00000000000..c80a4593f53 --- /dev/null +++ b/packages/grafana-ui/src/themes/dark.js @@ -0,0 +1,64 @@ + + +const defaultTheme = require('./default'); +const tinycolor = require('tinycolor2'); + +const basicColors = { + black: '#00ff00', + white: '#ffffff', + dark1: '#141414', + dark2: '#1f1f20', + dark3: '#262628', + dark4: '#333333', + dark5: '#444444', + gray1: '#555555', + gray2: '#8e8e8e', + gray3: '#b3b3b3', + gray4: '#d8d9da', + gray5: '#ececec', + gray6: '#f4f5f8', + gray7: '#fbfbfb', + grayBlue: '#212327', + blue: '#33b5e5', + blueDark: '#005f81', + blueLight: '#00a8e6', // not used in dark theme + green: '#299c46', + red: '#d44a3a', + yellow: '#ecbb13', + pink: '#ff4444', + purple: '#9933cc', + variable: '#32d1df', + orange: '#eb7b18', +}; + +const darkTheme = { + ...defaultTheme, + name: 'Grafana Dark', + colors: { + ...basicColors, + inputBlack: '#09090b', + queryRed: '#e24d42', + queryGreen: '#74e680', + queryPurple: '#fe85fc', + queryKeyword: '#66d9ef', + queryOrange: 'eb7b18', + online: '#10a345', + warn: '#f79520', + critical: '#ed2e18', + bodyBg: '#171819', + pageBg: '#161719', + bodyColor: basicColors.gray4, + textColor: basicColors.gray4, + textColorStrong: basicColors.white, + textColorWeak: basicColors.gray2, + textColorEmphasis: basicColors.gray5, + textColorFaint: basicColors.dark5, + linkColor: new tinycolor(basicColors.white).darken(11).toString(), + linkColorDisabled: new tinycolor(basicColors.white).darken(11).toString(), + linkColorHover: basicColors.white, + linkColorExternal: basicColors.blue, + headingColor: new tinycolor(basicColors.white).darken(11).toString(), + } +} + +module.exports = darkTheme; diff --git a/packages/grafana-ui/src/themes/default.js b/packages/grafana-ui/src/themes/default.js new file mode 100644 index 00000000000..59ed050e360 --- /dev/null +++ b/packages/grafana-ui/src/themes/default.js @@ -0,0 +1,47 @@ + + +const theme = { + name: 'Grafana Default', + typography: { + fontFamily: { + sansSerif: "'Roboto', Helvetica, Arial, sans-serif;", + serif: "Georgia, 'Times New Roman', Times, serif;", + monospace: "Menlo, Monaco, Consolas, 'Courier New', monospace;" + }, + size: { + base: '13px', + xs: '10px', + s: '12px', + m: '14px', + l: '18px', + }, + heading: { + h1: '2rem', + h2: '1.75rem', + h3: '1.5rem', + h4: '1.3rem', + h5: '1.2rem', + h6: '1rem', + }, + weight: { + light: 300, + normal: 400, + semibold: 500, + }, + lineHeight: { + xs: 1, + s: 1.1, + m: 4/3, + l: 1.5 + } + }, + brakpoints: { + xs: '0', + s: '544px', + m: '768px', + l: '992px', + xl: '1200px' + } +}; + +module.exports = theme; diff --git a/packages/grafana-ui/src/themes/light.js b/packages/grafana-ui/src/themes/light.js new file mode 100644 index 00000000000..84d1e656baa --- /dev/null +++ b/packages/grafana-ui/src/themes/light.js @@ -0,0 +1,65 @@ +// import { GrafanaThemeType } from "../theme"; + +const defaultTheme = require('./default'); +const tinycolor = require('tinycolor2'); + +const basicColors = { + black: '#000000', + white: '#ffffff', + dark1: '#13161d', + dark2: '#1e2028', + dark3: '#303133', + dark4: '#35373f', + dark5: '#41444b', + gray1: '#52545c', + gray2: '#767980', + gray3: '#acb6bf', + gray4: '#c7d0d9', + gray5: '#dde4ed', + gray6: '#e9edf2', + gray7: '#f7f8fa', + grayBlue: '#212327', // not used in light theme + blue: '#0083b3', + blueDark: '#005f81', + blueLight: '#00a8e6', + green: '#3aa655', + red: '#d44939', + yellow: '#ff851b', + pink: '#e671b8', + purple: '#9954bb', + variable: '#0083b3', + orange: '#ff7941', +}; + +const lightTheme/*: GrafanaThemeType*/ = { + ...defaultTheme, + name: 'Grafana Light', + colors: { + ...basicColors, + variable: basicColors.blue, + inputBlack: '#09090b', + queryRed: basicColors.red, + queryGreen: basicColors.green, + queryPurple: basicColors.purple, + queryKeyword: basicColors.blue, + queryOrange: basicColors.orange, + online: '#01a64f', + warn: '#f79520', + critical: '#ec2128', + bodyBg: basicColors.gray7, + pageBg: basicColors.gray7, + bodyColor: basicColors.gray1, + textColor: basicColors.gray1, + textColorStrong: basicColors.dark2, + textColorWeak: basicColors.gray2, + textColorEmphasis: basicColors.gray5, + textColorFaint: basicColors.dark4, + linkColor: basicColors.gray1, + linkColorDisabled: new tinycolor(basicColors.gray1).lighten(30).toString(), + linkColorHover: new tinycolor(basicColors.gray1).darken(20).toString(), + linkColorExternal: basicColors.blueLight, + headingColor: basicColors.gray1, + } +} + +module.exports = lightTheme; diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 7b0ed869bdc..61e2c8bfc76 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -3,73 +3,69 @@ $theme-name: dark; -// Grays // ------------------------- -$black: #000; +$black: getThemeVariable('colors.black', $theme-name); +$dark-1: getThemeVariable('colors.dark1', $theme-name); +$dark-2: getThemeVariable('colors.dark2', $theme-name); +$dark-3: getThemeVariable('colors.dark3', $theme-name); +$dark-4: getThemeVariable('colors.dark4', $theme-name); +$dark-5: getThemeVariable('colors.dark5', $theme-name); +$gray-1: getThemeVariable('colors.gray1', $theme-name); +$gray-2: getThemeVariable('colors.gray2', $theme-name); +$gray-3: getThemeVariable('colors.gray3', $theme-name); +$gray-4: getThemeVariable('colors.gray4', $theme-name); +$gray-5: getThemeVariable('colors.gray5', $theme-name); +$gray-6: getThemeVariable('colors.gray6', $theme-name); +$gray-7: getThemeVariable('colors.gray7', $theme-name); -// ------------------------- -$black: #000; -$dark-1: #141414; -$dark-2: #1f1f20; -$dark-3: #262628; -$dark-4: #333333; -$dark-5: #444444; -$gray-1: #555555; -$gray-2: #8e8e8e; -$gray-3: #b3b3b3; -$gray-4: #d8d9da; -$gray-5: #ececec; -$gray-6: #f4f5f8; -$gray-7: #fbfbfb; +$gray-blue: getThemeVariable('colors.grayBlue', $theme-name); +$input-black: getThemeVariable('colors.inputBlack', $theme-name); -$gray-blue: #212327; -$input-black: #09090b; - -$white: #fff; +$white: getThemeVariable('colors.white', $theme-name); // Accent colors // ------------------------- -$blue: #33b5e5; -$blue-dark: #005f81; -$green: #299c46; -$red: #d44a3a; -$yellow: #ecbb13; -$pink: #ff4444; -$purple: #9933cc; -$variable: #32d1df; -$orange: #eb7b18; +$blue: getThemeVariable('colors.blue', $theme-name); +$blue-dark: getThemeVariable('colors.blueDark', $theme-name); +$green: getThemeVariable('colors.green', $theme-name); +$red: getThemeVariable('colors.red', $theme-name); +$yellow: getThemeVariable('colors.yellow', $theme-name); +$pink: getThemeVariable('colors.pink', $theme-name); +$purple: getThemeVariable('colors.purple', $theme-name); +$variable: getThemeVariable('colors.variable', $theme-name); +$orange: getThemeVariable('colors.orange', $theme-name); $brand-primary: $orange; $brand-success: $green; $brand-warning: $brand-primary; $brand-danger: $red; -$query-red: #e24d42; -$query-green: #74e680; -$query-purple: #fe85fc; -$query-keyword: #66d9ef; -$query-orange: $orange; +$query-red: getThemeVariable('colors.queryRed', $theme-name); +$query-green: getThemeVariable('colors.queryGreen', $theme-name); +$query-purple: getThemeVariable('colors.queryPurple', $theme-name); +$query-keyword: getThemeVariable('colors.queryKeyword', $theme-name); +$query-orange: getThemeVariable('colors.queryOrange', $theme-name); // Status colors // ------------------------- -$online: #10a345; -$warn: #f79520; -$critical: #ed2e18; +$online: getThemeVariable('colors.online', $theme-name); +$warn: getThemeVariable('colors.warn', $theme-name); +$critical: getThemeVariable('colors.critical', $theme-name); // Scaffolding // ------------------------- -$body-bg: rgb(23, 24, 25); -$page-bg: rgb(22, 23, 25); +$body-bg: getThemeVariable('colors.bodyBg', $theme-name); +$page-bg: getThemeVariable('colors.pageBg', $theme-name); -$body-color: $gray-4; -$text-color: $gray-4; -$text-color-strong: $white; -$text-color-weak: $gray-2; -$text-color-faint: $dark-5; -$text-color-emphasis: $gray-5; +$body-color: getThemeVariable('colors.bodyColor', $theme-name); +$text-color: getThemeVariable('colors.textColor', $theme-name); +$text-color-strong: getThemeVariable('colors.textColorStrong', $theme-name); +$text-color-weak: getThemeVariable('colors.textColorWeak', $theme-name); +$text-color-faint: getThemeVariable('colors.textColorFaint', $theme-name); +$text-color-emphasis: getThemeVariable('colors.textColorEmphasis', $theme-name); -$text-shadow-strong: 1px 1px 4px $black; -$text-shadow-faint: 1px 1px 4px rgb(45, 45, 45); +$text-shadow-strong: 1px 1px 4px getThemeVariable('colors.black', $theme-name); +$text-shadow-faint: 1px 1px 4px #2d2d2d; // gradients $brand-gradient: linear-gradient( @@ -84,10 +80,11 @@ $edit-gradient: linear-gradient(180deg, rgb(22, 23, 25) 50%, #090909); // Links // ------------------------- -$link-color: darken($white, 11%); -$link-color-disabled: darken($link-color, 30%); -$link-hover-color: $white; -$external-link-color: $blue; +$link-color: getThemeVariable('colors.linkColor', $theme-name); +$link-color-disabled: getThemeVariable('colors.linkColorDisabled', $theme-name); +$link-hover-color: getThemeVariable('colors.linkColorHover', $theme-name); + +$external-link-color: getThemeVariable('colors.linkColorExternal', $theme-name); // Typography // ------------------------- @@ -135,7 +132,7 @@ $list-item-shadow: $card-shadow; $empty-list-cta-bg: $gray-blue; // Scrollbars -$scrollbarBackground: #404357; +$scrollbarBackground: #aeb5df; $scrollbarBackground2: #3a3a3a; $scrollbarBorder: black; diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 10c074e1481..ad2b22e201a 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -12,83 +12,85 @@ $theme-name: light; $black: #000; // ------------------------- -$black: #000; -$dark-1: #13161d; -$dark-2: #1e2028; -$dark-3: #303133; -$dark-4: #35373f; -$dark-5: #41444b; -$gray-1: #52545c; -$gray-2: #767980; -$gray-3: #acb6bf; -$gray-4: #c7d0d9; -$gray-5: #dde4ed; -$gray-6: #e9edf2; -$gray-7: #f7f8fa; +$black: getThemeVariable('colors.black', $theme-name); +$dark-1: getThemeVariable('colors.dark1', $theme-name); +$dark-2: getThemeVariable('colors.dark2', $theme-name); +$dark-3: getThemeVariable('colors.dark3', $theme-name); +$dark-4: getThemeVariable('colors.dark4', $theme-name); +$dark-5: getThemeVariable('colors.dark5', $theme-name); +$gray-1: getThemeVariable('colors.gray1', $theme-name); +$gray-2: getThemeVariable('colors.gray2', $theme-name); +$gray-3: getThemeVariable('colors.gray3', $theme-name); +$gray-4: getThemeVariable('colors.gray4', $theme-name); +$gray-5: getThemeVariable('colors.gray5', $theme-name); +$gray-6: getThemeVariable('colors.gray6', $theme-name); +$gray-7: getThemeVariable('colors.gray7', $theme-name); -$white: #fff; +$white: getThemeVariable('colors.white', $theme-name); // Accent colors // ------------------------- -$blue: #0083b3; -$blue-dark: #005f81; -$blue-light: #00a8e6; -$green: #3aa655; -$red: #d44939; -$yellow: #ff851b; -$orange: #ff7941; -$pink: #e671b8; -$purple: #9954bb; -$variable: $blue; +$blue: getThemeVariable('colors.blue', $theme-name); +$blue-dark: getThemeVariable('colors.blueDark', $theme-name); +$blue-light: getThemeVariable('colors.blueLight', $theme-name); +$green: getThemeVariable('colors.green', $theme-name); +$red: getThemeVariable('colors.red', $theme-name); +$yellow: getThemeVariable('colors.yellow', $theme-name); +$orange: getThemeVariable('colors.orange', $theme-name); +$pink: getThemeVariable('colors.pink', $theme-name); +$purple: getThemeVariable('colors.purple', $theme-name); +$variable: getThemeVariable('colors.variable', $theme-name); $brand-primary: $orange; $brand-success: $green; $brand-warning: $orange; $brand-danger: $red; -$query-red: $red; -$query-green: $green; -$query-purple: $purple; -$query-orange: $orange; -$query-keyword: $blue; +$query-red: getThemeVariable('colors.queryRed', $theme-name); +$query-green: getThemeVariable('colors.queryGreen', $theme-name); +$query-purple: getThemeVariable('colors.queryPurple', $theme-name); +$query-keyword: getThemeVariable('colors.queryKeyword', $theme-name); +$query-orange: getThemeVariable('colors.queryOrange', $theme-name); // Status colors // ------------------------- -$online: #01a64f; -$warn: #f79520; -$critical: #ec2128; +$online: getThemeVariable('colors.online', $theme-name); +$warn: getThemeVariable('colors.warn', $theme-name); +$critical: getThemeVariable('colors.critical', $theme-name); // Scaffolding // ------------------------- -$body-bg: $gray-7; -$page-bg: $gray-7; -$body-color: $gray-1; -$text-color: $gray-1; -$text-color-strong: $dark-2; -$text-color-weak: $gray-2; -$text-color-faint: $gray-4; -$text-color-emphasis: $dark-5; +$body-bg: getThemeVariable('colors.bodyBg', $theme-name); +$page-bg: getThemeVariable('colors.pageBg', $theme-name); + +$body-color: getThemeVariable('colors.bodyColor', $theme-name); +$text-color: getThemeVariable('colors.textColor', $theme-name); +$text-color-strong: getThemeVariable('colors.textColorStrong', $theme-name); +$text-color-weak: getThemeVariable('colors.textColorWeak', $theme-name); +$text-color-faint: getThemeVariable('colors.textColorFaint', $theme-name); +$text-color-emphasis: getThemeVariable('colors.textColorEmphasis', $theme-name); $text-shadow-strong: none; $text-shadow-faint: none; $textShadow: none; // gradients -$brand-gradient: linear-gradient(to right, rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%); +$brand-gradient: linear-gradient(to right, hsl(50, 100%, 50%) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%); $page-gradient: linear-gradient(180deg, $white 10px, $gray-7 100px); $edit-gradient: linear-gradient(-60deg, $gray-7, #f5f6f9 70%, $gray-7 98%); // Links // ------------------------- -$link-color: $gray-1; -$link-color-disabled: lighten($link-color, 30%); -$link-hover-color: darken($link-color, 20%); -$external-link-color: $blue-light; +$link-color: getThemeVariable('colors.linkColor', $theme-name); +$link-color-disabled: getThemeVariable('colors.linkColorDisabled', $theme-name); +$link-hover-color: getThemeVariable('colors.linkColorHover', $theme-name); + +$external-link-color: getThemeVariable('colors.linkColorExternal', $theme-name); // Typography // ------------------------- -$headings-color: $text-color; +$headings-color: getThemeVariable('colors.headingColor', $theme-name); $abbr-border-color: $gray-2 !default; $text-muted: $text-color-weak; diff --git a/public/sass/_variables.scss b/public/sass/_variables.scss index 4e9e69c4d2f..eab0e8c7f5a 100644 --- a/public/sass/_variables.scss +++ b/public/sass/_variables.scss @@ -47,45 +47,45 @@ $enable-flex: true; // Typography // ------------------------- -$font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif; -$font-family-serif: Georgia, 'Times New Roman', Times, serif; -$font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace; +$font-family-sans-serif: getThemeVariable('typography.fontFamily.sansSerif'); +$font-family-serif: getThemeVariable('typography.fontFamily.serif'); +$font-family-monospace: getThemeVariable('typography.fontFamily.monospace'); $font-family-base: $font-family-sans-serif !default; -$font-size-root: 14px !default; -$font-size-base: 13px !default; +$font-size-root: getThemeVariable('typography.size.m') !default; +$font-size-base: getThemeVariable('typography.size.base') !default; -$font-size-lg: 18px !default; -$font-size-md: 14px !default; -$font-size-sm: 12px !default; -$font-size-xs: 10px !default; +$font-size-lg: getThemeVariable('typography.size.l') !default; +$font-size-md: getThemeVariable('typography.size.m') !default; +$font-size-sm: getThemeVariable('typography.size.s') !default; +$font-size-xs: getThemeVariable('typography.size.xs') !default; -$line-height-base: 1.5 !default; -$font-weight-semi-bold: 500; +$line-height-base: getThemeVariable('typography.lineHeight.l') !default; +$font-weight-semi-bold: getThemeVariable('typography.weight.semibold'); -$font-size-h1: 2rem !default; -$font-size-h2: 1.75rem !default; -$font-size-h3: 1.5rem !default; -$font-size-h4: 1.3rem !default; -$font-size-h5: 1.2rem !default; -$font-size-h6: 1rem !default; +$font-size-h1: getThemeVariable('typography.heading.h1') !default; +$font-size-h2: getThemeVariable('typography.heading.h2') !default; +$font-size-h3: getThemeVariable('typography.heading.h3') !default; +$font-size-h4: getThemeVariable('typography.heading.h4') !default; +$font-size-h5: getThemeVariable('typography.heading.h5') !default; +$font-size-h6: getThemeVariable('typography.heading.h6') !default; $display1-size: 6rem !default; $display2-size: 5.5rem !default; $display3-size: 4.5rem !default; $display4-size: 3.5rem !default; -$display1-weight: 400 !default; -$display2-weight: 400 !default; -$display3-weight: 400 !default; -$display4-weight: 400 !default; +$display1-weight: getThemeVariable('typography.weight.normal') !default; +$display2-weight: getThemeVariable('typography.weight.normal') !default; +$display3-weight: getThemeVariable('typography.weight.normal') !default; +$display4-weight: getThe1meVariable('typography.weight.normal') !default; $lead-font-size: 1.25rem !default; $lead-font-weight: 300 !default; $headings-margin-bottom: ($spacer / 2) !default; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$headings-font-weight: 400 !default; +$headings-font-weight: getThemeVariable('typography.weight.normal') !default; $headings-line-height: 1.1 !default; $hr-border-width: $border-width !default; diff --git a/scripts/webpack/getThemeVariable.js b/scripts/webpack/getThemeVariable.js new file mode 100644 index 00000000000..c0b6bc4ed79 --- /dev/null +++ b/scripts/webpack/getThemeVariable.js @@ -0,0 +1,53 @@ +const sass = require('node-sass'); +const sassUtils = require('node-sass-utils')(sass); +const { getTheme } = require('../../packages/grafana-ui/src/theme'); +const { get } = require('lodash'); +const tinycolor = require('tinycolor2'); + +const units = ['rem', 'em', 'vh', 'vw', 'vmin', 'vmax', 'ex', '%', 'px', 'cm', 'mm', 'in', 'pt', 'pc', 'ch']; +const matchDimension = value => value.match(/[a-zA-Z]+|[0-9]+/g); + +const isHex = value => { + const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi; + return hexRegex.test(value); +}; + +const isDimension = value => { + if( typeof value !== "string") { + return false; + } + + const [val, unit] = matchDimension(value); + return units.indexOf(unit) > -1 +}; + +/** + * @param {SassString} variablePath + * @param {"dark"|"light"} themeName + */ +function getThemeVariable(variablePath, themeName) { + const theme = getTheme(themeName.getValue()); + const variable = get(theme, variablePath.getValue()); + + if (!variable) { + throw new Error(`${variablePath} is not defined fo ${themeName}`); + } + + if (isHex(variable)) { + const rgb = new tinycolor(variable).toRgb(); + const color = sass.types.Color(rgb.r, rgb.g, rgb.b); + return color; + } + + if (isDimension(variable)) { + const [value, unit] = matchDimension(variable) + + const tmp = new sassUtils.SassDimension(parseInt(value,10), unit); + // debugger + return sassUtils.castToSass(tmp) + } + + return sassUtils.castToSass(variable); +} + +module.exports = getThemeVariable; diff --git a/scripts/webpack/sass.rule.js b/scripts/webpack/sass.rule.js index 75455fc4184..78f6b60d33f 100644 --- a/scripts/webpack/sass.rule.js +++ b/scripts/webpack/sass.rule.js @@ -1,6 +1,7 @@ 'use strict'; -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const getThemeVariable = require('./getThemeVariable'); module.exports = function(options) { return { @@ -23,7 +24,15 @@ module.exports = function(options) { config: { path: __dirname + '/postcss.config.js' }, }, }, - { loader: 'sass-loader', options: { sourceMap: options.sourceMap } }, + { + loader: 'sass-loader', + options: { + sourceMap: options.sourceMap, + functions: { + 'getThemeVariable($themeVar, $themeName: dark)': getThemeVariable, + }, + }, + }, ], }; }; diff --git a/scripts/webpack/webpack.hot.js b/scripts/webpack/webpack.hot.js index b37e4c08592..4519e292c6b 100644 --- a/scripts/webpack/webpack.hot.js +++ b/scripts/webpack/webpack.hot.js @@ -8,6 +8,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin'); const IgnoreNotFoundExportPlugin = require("./IgnoreNotFoundExportPlugin.js"); +const getThemeVariable = require("./getThemeVariable"); module.exports = merge(common, { entry: { @@ -85,7 +86,14 @@ module.exports = merge(common, { config: { path: __dirname + '/postcss.config.js' }, }, }, - 'sass-loader', // compiles Sass to CSS + { + loader: 'sass-loader', + options: { + functions: { + "getThemeVariable($themeVar, $themeName: dark)": getThemeVariable + } + } + } ], }, { diff --git a/yarn.lock b/yarn.lock index 169abd40ee4..bc94a05d702 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11910,6 +11910,11 @@ node-releases@^1.0.0-alpha.11, node-releases@^1.1.3: dependencies: semver "^5.3.0" +node-sass-utils@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/node-sass-utils/-/node-sass-utils-1.1.2.tgz#d03639cfa4fc962398ba3648ab466f0db7cc2131" + integrity sha1-0DY5z6T8liOYujZIq0ZvDbfMITE= + node-sass@^4.11.0: version "4.11.0" resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.11.0.tgz#183faec398e9cbe93ba43362e2768ca988a6369a" From 3baaf2c3e4f13c8ee4e70984953f92f4839a6801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 17:36:04 +0100 Subject: [PATCH 026/144] Added handling of kiosk mode --- .../dashboard/state/DashboardModel.ts | 11 ++--- .../features/dashboard/state/initDashboard.ts | 40 ++++++++++--------- public/app/routes/routes.ts | 4 +- public/app/types/dashboard.ts | 2 +- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index a6d45bb3cc0..8756af2ceea 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -15,6 +15,7 @@ import sortByKeys from 'app/core/utils/sort_by_keys'; import { PanelModel } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { TimeRange } from '@grafana/ui/src'; +import { UrlQueryValue } from 'app/types'; export class DashboardModel { id: any; @@ -867,11 +868,7 @@ export class DashboardModel { return !_.isEqual(updated, this.originalTemplating); } - autoFitPanels(viewHeight: number) { - if (!this.meta.autofitpanels) { - return; - } - + autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { const currentGridHeight = Math.max( ...this.panels.map(panel => { return panel.gridPos.h + panel.gridPos.y; @@ -885,12 +882,12 @@ export class DashboardModel { let visibleHeight = viewHeight - navbarHeight - margin; // Remove submenu height if visible - if (this.meta.submenuEnabled && !this.meta.kiosk) { + if (this.meta.submenuEnabled && !kioskMode) { visibleHeight -= submenuHeight; } // add back navbar height - if (this.meta.kiosk === 'b') { + if (kioskMode === 'tv') { visibleHeight += 55; } diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 14d6196d69c..89b8d470d3c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -56,10 +56,6 @@ export function initDashboard({ try { switch (routeInfo) { // handle old urls with no uid - case DashboardRouteInfo.Old: { - redirectToNewUrl(urlSlug, dispatch); - return; - } case DashboardRouteInfo.Home: { // load home dash dashDTO = await getBackendSrv().get('/api/dashboards/home'); @@ -78,20 +74,27 @@ export function initDashboard({ break; } case DashboardRouteInfo.Normal: { + // for old db routes we redirect + if (urlType === 'db') { + redirectToNewUrl(urlSlug, dispatch); + return; + } + const loaderSrv = $injector.get('dashboardLoaderSrv'); dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); - // check if the current url is correct (might be old slug) - const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); - const currentPath = getState().location.path; - console.log('loading dashboard: currentPath', currentPath); - console.log('loading dashboard: dashboardUrl', dashboardUrl); + if (dashDTO.meta.url) { + // check if the current url is correct (might be old slug) + const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); + const currentPath = getState().location.path; - if (dashboardUrl !== currentPath) { - // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. - dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true })); - return; + if (dashboardUrl !== currentPath) { + // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. + dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true })); + return; + } } + break; } case DashboardRouteInfo.New: { @@ -129,7 +132,6 @@ export function initDashboard({ const variableSrv: VariableSrv = $injector.get('variableSrv'); const keybindingSrv: KeybindingSrv = $injector.get('keybindingSrv'); const unsavedChangesSrv = $injector.get('unsavedChangesSrv'); - const viewStateSrv = $injector.get('dashboardViewStateSrv'); const dashboardSrv: DashboardSrv = $injector.get('dashboardSrv'); timeSrv.init(dashboard); @@ -147,14 +149,16 @@ export function initDashboard({ try { dashboard.processRepeats(); dashboard.updateSubmenuVisibility(); - dashboard.autoFitPanels(window.innerHeight); + + // handle auto fix experimental feature + const queryParams = getState().location.query; + if (queryParams.autofitpanels) { + dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk); + } // init unsaved changes tracking unsavedChangesSrv.init(dashboard, $scope); - $scope.dashboard = dashboard; - viewStateSrv.create($scope); - // dashboard keybindings should not live in core, this needs a bigger refactoring // So declaring this here so it can depend on the removePanel util function // Long term onRemovePanel should be handled via react prop callback diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index ecd934cdccf..e0029cf2464 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -62,7 +62,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/dashboard/:type/:slug', { template: '', pageClass: 'page-dashboard', - routeInfo: DashboardRouteInfo.Old, + routeInfo: DashboardRouteInfo.Normal, reloadOnSearch: false, resolve: { component: () => DashboardPage, @@ -88,7 +88,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/dashboard-solo/:type/:slug', { template: '', pageClass: 'dashboard-solo', - routeInfo: DashboardRouteInfo.Old, + routeInfo: DashboardRouteInfo.Normal, resolve: { component: () => SoloPanelPage, }, diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 9b8f539aeb2..36c0a420f28 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -8,10 +8,10 @@ export interface MutableDashboard { } export enum DashboardRouteInfo { - Old = 'old-dashboard', Home = 'home-dashboard', New = 'new-dashboard', Normal = 'normal-dashboard', + Scripted = 'scripted-dashboard', } export enum DashboardLoadingState { From 23ac9405c16b259887a9a5610e955537950de811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 17:39:29 +0100 Subject: [PATCH 027/144] Set page title on dashboard load --- .../dashboard/containers/DashboardPage.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index ec143d735ab..3b902e4d089 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -72,6 +72,13 @@ export class DashboardPage extends PureComponent { }); } + componentWillUnmount() { + if (this.props.dashboard) { + this.props.dashboard.destroy(); + this.props.setDashboardModel(null); + } + } + componentDidUpdate(prevProps: Props) { const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props; @@ -79,6 +86,11 @@ export class DashboardPage extends PureComponent { return; } + // if we just got dashboard update title + if (!prevProps.dashboard) { + document.title = dashboard.title + ' - Grafana'; + } + // handle animation states when opening dashboard settings if (!prevProps.editview && editview) { this.setState({ isSettingsOpening: true }); @@ -135,13 +147,6 @@ export class DashboardPage extends PureComponent { $('body').toggleClass('panel-in-fullscreen', isFullscreen); } - componentWillUnmount() { - if (this.props.dashboard) { - this.props.dashboard.destroy(); - this.props.setDashboardModel(null); - } - } - renderLoadingState() { return ; } From d978a66ef6b1b1b496959e017d5d714d1f187ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 18:24:56 +0100 Subject: [PATCH 028/144] Fixed lots of loading flow issues and updated solo route page --- public/app/core/utils/location_util.ts | 2 +- .../dashboard/components/DashNav/DashNav.tsx | 148 ++++++++++-------- .../dashboard/containers/DashboardPage.tsx | 1 + .../dashboard/containers/SoloPanelPage.tsx | 92 +++++------ .../dashboard/services/DashboardSrv.ts | 1 - .../features/dashboard/state/initDashboard.ts | 21 ++- public/app/routes/GrafanaCtrl.ts | 6 - 7 files changed, 137 insertions(+), 134 deletions(-) diff --git a/public/app/core/utils/location_util.ts b/public/app/core/utils/location_util.ts index 76f2fc5881f..15e1c275550 100644 --- a/public/app/core/utils/location_util.ts +++ b/public/app/core/utils/location_util.ts @@ -1,6 +1,6 @@ import config from 'app/core/config'; -export const stripBaseFromUrl = url => { +export const stripBaseFromUrl = (url: string): string => { const appSubUrl = config.appSubUrl; const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0; const urlWithoutBase = diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 66edb149433..00f89920727 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; // Utils & Services import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import { appEvents } from 'app/core/app_events'; +import { PlaylistSrv } from 'app/features/playlist/playlist_srv'; // Components import { DashNavButton } from './DashNavButton'; @@ -116,12 +117,13 @@ export class DashNav extends PureComponent { }; render() { - const { dashboard, isFullscreen, editview } = this.props; + const { dashboard, isFullscreen, editview, $injector } = this.props; const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; const { snapshot } = dashboard; const haveFolder = dashboard.meta.folderId > 0; const snapshotUrl = snapshot && snapshot.originalUrl; + const playlistSrv: PlaylistSrv = $injector.get('playlistSrv'); return (
@@ -135,13 +137,29 @@ export class DashNav extends PureComponent {
- {/* - - */} + + {playlistSrv.isPlaying && ( +
+ playlistSrv.prev()} + /> + playlistSrv.stop()} + /> + playlistSrv.next()} + /> +
+ )}
{canSave && ( @@ -151,71 +169,71 @@ export class DashNav extends PureComponent { icon="gicon gicon-add-panel" onClick={this.onAddPanel} /> - )} + )} - {canStar && ( - - )} + {canStar && ( + + )} - {canShare && ( - - )} + {canShare && ( + + )} - {canSave && ( - - )} + {canSave && ( + + )} - {snapshotUrl && ( - - )} + {snapshotUrl && ( + + )} - {showSettings && ( - - )} -
+ {showSettings && ( + + )} +
-
- -
+
+ +
-
(this.timePickerEl = element)} /> +
(this.timePickerEl = element)} /> - {(isFullscreen || editview) && ( -
- -
- )} -
+ {(isFullscreen || editview) && ( +
+ +
+ )} +
); } } diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 3b902e4d089..a7b3f51d92c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -69,6 +69,7 @@ export class DashboardPage extends PureComponent { urlType: this.props.urlType, urlFolderId: this.props.urlFolderId, routeInfo: this.props.routeInfo, + fixUrl: true, }); } diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 097c8015929..beb45b6904d 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -3,98 +3,78 @@ import React, { Component } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -// Utils & Services -import appEvents from 'app/core/app_events'; -import locationUtil from 'app/core/utils/location_util'; -import { getBackendSrv } from 'app/core/services/backend_srv'; - // Components import { DashboardPanel } from '../dashgrid/DashboardPanel'; // Redux -import { updateLocation } from 'app/core/actions'; +import { initDashboard } from '../state/initDashboard'; // Types -import { StoreState } from 'app/types'; +import { StoreState, DashboardRouteInfo } from 'app/types'; import { PanelModel, DashboardModel } from 'app/features/dashboard/state'; interface Props { - panelId: string; + urlPanelId: string; urlUid?: string; urlSlug?: string; urlType?: string; $scope: any; $injector: any; - updateLocation: typeof updateLocation; + routeInfo: DashboardRouteInfo; + initDashboard: typeof initDashboard; + dashboard: DashboardModel | null; } interface State { panel: PanelModel | null; - dashboard: DashboardModel | null; notFound: boolean; } export class SoloPanelPage extends Component { - state: State = { panel: null, - dashboard: null, notFound: false, }; componentDidMount() { - const { $injector, $scope, urlUid, urlType, urlSlug } = this.props; + const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props; - // handle old urls with no uid - if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { - this.redirectToNewUrl(); - return; - } - - const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv'); - - // subscribe to event to know when dashboard controller is done with inititalization - appEvents.on('dashboard-initialized', this.onDashoardInitialized); - - dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => { - result.meta.soloMode = true; - $scope.initDashboard(result, $scope); + this.props.initDashboard({ + $injector: $injector, + $scope: $scope, + urlSlug: urlSlug, + urlUid: urlUid, + urlType: urlType, + routeInfo: routeInfo, + fixUrl: false, }); } - redirectToNewUrl() { - getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => { - if (res) { - const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); - this.props.updateLocation(url); + componentDidUpdate(prevProps: Props) { + const { urlPanelId, dashboard } = this.props; + + if (!dashboard) { + return; + } + + // we just got the dashboard! + if (!prevProps.dashboard) { + const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); + if (!panel) { + this.setState({ notFound: true }); + return; } - }); - } - onDashoardInitialized = () => { - const { $scope, panelId } = this.props; - - const dashboard: DashboardModel = $scope.dashboard; - const panel = dashboard.getPanelById(parseInt(panelId, 10)); - - if (!panel) { - this.setState({ notFound: true }); - return; + this.setState({ panel }); } - - this.setState({ dashboard, panel }); - }; + } render() { - const { panelId } = this.props; - const { notFound, panel, dashboard } = this.state; + const { urlPanelId, dashboard } = this.props; + const { notFound, panel } = this.state; if (notFound) { - return ( -
- Panel with id { panelId } not found -
- ); + return
Panel with id {urlPanelId} not found
; } if (!panel) { @@ -113,11 +93,13 @@ const mapStateToProps = (state: StoreState) => ({ urlUid: state.location.routeParams.uid, urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, - panelId: state.location.query.panelId + urlPanelId: state.location.query.panelId, + loadingState: state.dashboard.loadingState, + dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { - updateLocation + initDashboard, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage)); diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index e2e524941f8..38fadfecdc1 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -8,7 +8,6 @@ export class DashboardSrv { /** @ngInject */ constructor(private backendSrv, private $rootScope, private $location) { - appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); appEvents.on('panel-change-view', this.onPanelChangeView); } diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 89b8d470d3c..5419fcb41d7 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -25,16 +25,24 @@ export interface InitDashboardArgs { urlUid?: string; urlSlug?: string; urlType?: string; - urlFolderId: string; + urlFolderId?: string; routeInfo: string; + fixUrl: boolean; } -async function redirectToNewUrl(slug: string, dispatch: any) { +async function redirectToNewUrl(slug: string, dispatch: any, currentPath: string) { const res = await getBackendSrv().getDashboardBySlug(slug); if (res) { - const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); - dispatch(updateLocation(url)); + let newUrl = res.meta.url; + + // fix solo route urls + if (currentPath.indexOf('dashboard-solo') !== -1) { + newUrl = newUrl.replace('/d/', '/d-solo/'); + } + + const url = locationUtil.stripBaseFromUrl(newUrl); + dispatch(updateLocation({ path: url, partial: true, replace: true })); } } @@ -46,6 +54,7 @@ export function initDashboard({ urlType, urlFolderId, routeInfo, + fixUrl, }: InitDashboardArgs): ThunkResult { return async (dispatch, getState) => { let dashDTO = null; @@ -76,14 +85,14 @@ export function initDashboard({ case DashboardRouteInfo.Normal: { // for old db routes we redirect if (urlType === 'db') { - redirectToNewUrl(urlSlug, dispatch); + redirectToNewUrl(urlSlug, dispatch, getState().location.path); return; } const loaderSrv = $injector.get('dashboardLoaderSrv'); dashDTO = await loaderSrv.loadDashboard(urlType, urlSlug, urlUid); - if (dashDTO.meta.url) { + if (fixUrl && dashDTO.meta.url) { // check if the current url is correct (might be old slug) const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); const currentPath = getState().location.path; diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 817e6452f44..f87fe69a684 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -45,12 +45,6 @@ export class GrafanaCtrl { }; $rootScope.colors = colors; - - $scope.initDashboard = (dashboardData, viewScope) => { - $scope.appEvent('dashboard-fetch-end', dashboardData); - $controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData); - }; - $rootScope.onAppEvent = function(name, callback, localScope) { const unbind = $rootScope.$on(name, callback); let callerScope = this; From 70974c01f2cdf487cc0e24800f93db2333ae26ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 21:08:30 +0100 Subject: [PATCH 029/144] Added playlist controls to new react DashNav --- pkg/api/dtos/playlist.go | 1 + pkg/api/playlist_play.go | 1 + .../dashboard/components/DashNav/DashNav.tsx | 37 ++++++++++++++----- .../dashboard/containers/DashboardPage.tsx | 6 --- public/app/features/playlist/playlist_srv.ts | 23 +++++++++--- public/app/routes/GrafanaCtrl.ts | 17 ++++----- 6 files changed, 55 insertions(+), 30 deletions(-) diff --git a/pkg/api/dtos/playlist.go b/pkg/api/dtos/playlist.go index 317ff83339a..7f43bb4df8a 100644 --- a/pkg/api/dtos/playlist.go +++ b/pkg/api/dtos/playlist.go @@ -5,6 +5,7 @@ type PlaylistDashboard struct { Slug string `json:"slug"` Title string `json:"title"` Uri string `json:"uri"` + Url string `json:"url"` Order int `json:"order"` } diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index e82c7b438b4..5ca136c32c4 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i Slug: item.Slug, Title: item.Title, Uri: "db/" + item.Slug, + Url: m.GetDashboardUrl(item.Uid, item.Slug), Order: dashboardIDOrder[item.Id], }) } diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 00f89920727..374fd6dcd36 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -28,6 +28,13 @@ export interface Props { export class DashNav extends PureComponent { timePickerEl: HTMLElement; timepickerCmp: AngularComponent; + playlistSrv: PlaylistSrv; + + constructor(props: Props) { + super(props); + + this.playlistSrv = this.props.$injector.get('playlistSrv'); + } componentDidMount() { const loader = getAngularLoader(); @@ -95,7 +102,7 @@ export class DashNav extends PureComponent { }; onStarDashboard = () => { - const { $injector, dashboard } = this.props; + const { dashboard, $injector } = this.props; const dashboardSrv = $injector.get('dashboardSrv'); dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => { @@ -104,6 +111,19 @@ export class DashNav extends PureComponent { }); }; + onPlaylistPrev = () => { + this.playlistSrv.prev(); + }; + + onPlaylistNext = () => { + this.playlistSrv.next(); + }; + + onPlaylistStop = () => { + this.playlistSrv.stop(); + this.forceUpdate(); + }; + onOpenShare = () => { const $rootScope = this.props.$injector.get('$rootScope'); const modalScope = $rootScope.$new(); @@ -117,13 +137,12 @@ export class DashNav extends PureComponent { }; render() { - const { dashboard, isFullscreen, editview, $injector } = this.props; + const { dashboard, isFullscreen, editview } = this.props; const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; const { snapshot } = dashboard; const haveFolder = dashboard.meta.folderId > 0; const snapshotUrl = snapshot && snapshot.originalUrl; - const playlistSrv: PlaylistSrv = $injector.get('playlistSrv'); return (
@@ -138,25 +157,25 @@ export class DashNav extends PureComponent {
- {playlistSrv.isPlaying && ( + {this.playlistSrv.isPlaying && (
playlistSrv.prev()} + onClick={this.onPlaylistPrev} /> playlistSrv.stop()} + onClick={this.onPlaylistStop} /> playlistSrv.next()} + onClick={this.onPlaylistNext} />
)} diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index a7b3f51d92c..3705cf15dac 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -100,12 +100,6 @@ export class DashboardPage extends PureComponent { }, 10); } - // // when dashboard has loaded subscribe to somme events - // if (prevProps.dashboard === null) { - // // set initial fullscreen class state - // this.setPanelFullscreenClass(); - // } - // Sync url state with model if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) { // entering fullscreen/edit mode diff --git a/public/app/features/playlist/playlist_srv.ts b/public/app/features/playlist/playlist_srv.ts index 0a80ce0cdf0..6c1cf2b4256 100644 --- a/public/app/features/playlist/playlist_srv.ts +++ b/public/app/features/playlist/playlist_srv.ts @@ -1,12 +1,16 @@ -import coreModule from '../../core/core_module'; -import kbn from 'app/core/utils/kbn'; -import appEvents from 'app/core/app_events'; +// Libraries import _ from 'lodash'; + +// Utils import { toUrlParams } from 'app/core/utils/url'; +import coreModule from '../../core/core_module'; +import appEvents from 'app/core/app_events'; +import locationUtil from 'app/core/utils/location_util'; +import kbn from 'app/core/utils/kbn'; export class PlaylistSrv { private cancelPromise: any; - private dashboards: Array<{ uri: string }>; + private dashboards: Array<{ url: string }>; private index: number; private interval: number; private startUrl: string; @@ -36,7 +40,12 @@ export class PlaylistSrv { const queryParams = this.$location.search(); const filteredParams = _.pickBy(queryParams, value => value !== null); - this.$location.url('dashboard/' + dash.uri + '?' + toUrlParams(filteredParams)); + // this is done inside timeout to make sure digest happens after + // as this can be called from react + this.$timeout(() => { + const stripedUrl = locationUtil.stripBaseFromUrl(dash.url); + this.$location.url(stripedUrl + '?' + toUrlParams(filteredParams)); + }); this.index++; this.cancelPromise = this.$timeout(() => this.next(), this.interval); @@ -54,6 +63,8 @@ export class PlaylistSrv { this.index = 0; this.isPlaying = true; + appEvents.emit('playlist-started'); + return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => { return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => { this.dashboards = dashboards; @@ -77,6 +88,8 @@ export class PlaylistSrv { if (this.cancelPromise) { this.$timeout.cancel(this.cancelPromise); } + + appEvents.emit('playlist-stopped'); } } diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index f87fe69a684..07d99725113 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -120,12 +120,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop body.toggleClass('sidemenu-hidden'); }); - scope.$watch( - () => playlistSrv.isPlaying, - newValue => { - elem.toggleClass('view-mode--playlist', newValue === true); - } - ); + appEvents.on('playlist-started', () => { + elem.toggleClass('view-mode--playlist', true); + }); + + appEvents.on('playlist-stopped', () => { + elem.toggleClass('view-mode--playlist', false); + }); // check if we are in server side render if (document.cookie.indexOf('renderKey') !== -1) { @@ -258,10 +259,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop }, 100); } - if (target.parents('.navbar-buttons--playlist').length === 0) { - playlistSrv.stop(); - } - // hide search if (body.find('.search-container').length > 0) { if (target.parents('.search-results-container, .search-field-wrapper').length === 0) { From e4dad78045bc70a17e8274310c67d7b82511660c Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 4 Feb 2019 21:26:49 +0100 Subject: [PATCH 030/144] added flags to vizpicker from query param --- .../AddPanelWidget/AddPanelWidget.tsx | 48 ++++++++++++++----- .../panel_editor/VisualizationTab.tsx | 4 +- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index b3d5e6167c4..21c4451d9b9 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -17,6 +17,17 @@ export interface State { copiedPanelPlugins: any[]; } +type Location = { + query: { + panelId: number; + edit: boolean; + fullscreen: boolean; + tab?: string; + isVizPickerOpen?: boolean; + }; + partial: boolean; +}; + export class AddPanelWidget extends React.Component { constructor(props) { super(props); @@ -62,18 +73,8 @@ export class AddPanelWidget extends React.Component { ); } - moveToEdit(panel, tab) { - reduxStore.dispatch( - updateLocation({ - query: { - panelId: panel.id, - edit: true, - fullscreen: true, - tab: tab, - }, - partial: true, - }) - ); + moveToEdit(location) { + reduxStore.dispatch(updateLocation(location)); } onCreateNewPanel = (tab = 'queries') => { @@ -89,7 +90,28 @@ export class AddPanelWidget extends React.Component { dashboard.addPanel(newPanel); dashboard.removePanel(this.props.panel); - this.moveToEdit(newPanel, tab); + let location: Location = { + query: { + panelId: newPanel.id, + edit: true, + fullscreen: true, + }, + partial: true, + }; + + if (tab === 'visualization') { + location = { + ...location, + query: { + ...location.query, + tab: 'visualization', + isVizPickerOpen: true, + }, + }; + this.moveToEdit(location); + } else { + this.moveToEdit(location); + } }; onPasteCopiedPanel = panelPluginInfo => { diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index fdf978acdf9..1ca290d4051 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react'; // Utils & Services import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; -//TODO: See PanelEdit +import { store } from 'app/store/store'; // Components import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; @@ -39,7 +39,7 @@ export class VisualizationTab extends PureComponent { super(props); this.state = { - isVizPickerOpen: false, + isVizPickerOpen: store.getState().location.query.isVizPickerOpen === true, searchQuery: '', scrollTop: 0, }; From d29e1278dca3c7b07cf79e9c9d161569ba6b6460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 4 Feb 2019 21:39:48 +0100 Subject: [PATCH 031/144] render after leaving fullscreen --- .../dashboard/containers/DashboardPage.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 3705cf15dac..404c953eecb 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -102,28 +102,46 @@ export class DashboardPage extends PureComponent { // Sync url state with model if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) { - // entering fullscreen/edit mode if (urlPanelId) { - const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); - - if (panel) { - dashboard.setViewMode(panel, urlFullscreen, urlEdit); - this.setState({ isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel }); - this.setPanelFullscreenClass(urlFullscreen); - } else { - this.handleFullscreenPanelNotFound(urlPanelId); - } + this.onEnterFullscreen(); } else { - // handle leaving fullscreen mode - if (this.state.fullscreenPanel) { - dashboard.setViewMode(this.state.fullscreenPanel, urlFullscreen, urlEdit); - } - this.setState({ isEditing: false, isFullscreen: false, fullscreenPanel: null }); - this.setPanelFullscreenClass(false); + this.onLeaveFullscreen(); } } } + onEnterFullscreen() { + const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props; + + const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); + + if (panel) { + dashboard.setViewMode(panel, urlFullscreen, urlEdit); + this.setState({ + isEditing: urlEdit, + isFullscreen: urlFullscreen, + fullscreenPanel: panel, + }); + this.setPanelFullscreenClass(urlFullscreen); + } else { + this.handleFullscreenPanelNotFound(urlPanelId); + } + } + + onLeaveFullscreen() { + const { dashboard } = this.props; + + if (this.state.fullscreenPanel) { + dashboard.setViewMode(this.state.fullscreenPanel, false, false); + } + + this.setState({ isEditing: false, isFullscreen: false, fullscreenPanel: null }, () => { + dashboard.render(); + }); + + this.setPanelFullscreenClass(false); + } + handleFullscreenPanelNotFound(urlPanelId: string) { // Panel not found this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); From 7cd3cd6cd43cb0ef2597ce032935e4b2f38904db Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Sat, 2 Feb 2019 12:11:30 +0100 Subject: [PATCH 032/144] auth package refactoring moving middleware/hooks away from package exposing public struct UserToken accessible from other packages fix debug log lines so the same order and naming are used --- pkg/services/auth/auth.go | 6 + pkg/services/auth/auth_token.go | 279 ------------- pkg/services/auth/auth_token_test.go | 378 ----------------- pkg/services/auth/authtoken/auth_token.go | 225 ++++++++++ .../auth/authtoken/auth_token_test.go | 386 ++++++++++++++++++ pkg/services/auth/authtoken/model.go | 76 ++++ .../auth/{ => authtoken}/session_cleanup.go | 2 +- .../{ => authtoken}/session_cleanup_test.go | 2 +- pkg/services/auth/model.go | 25 -- 9 files changed, 695 insertions(+), 684 deletions(-) create mode 100644 pkg/services/auth/auth.go delete mode 100644 pkg/services/auth/auth_token.go delete mode 100644 pkg/services/auth/auth_token_test.go create mode 100644 pkg/services/auth/authtoken/auth_token.go create mode 100644 pkg/services/auth/authtoken/auth_token_test.go create mode 100644 pkg/services/auth/authtoken/model.go rename pkg/services/auth/{ => authtoken}/session_cleanup.go (98%) rename pkg/services/auth/{ => authtoken}/session_cleanup_test.go (98%) delete mode 100644 pkg/services/auth/model.go diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go new file mode 100644 index 00000000000..31316f473f5 --- /dev/null +++ b/pkg/services/auth/auth.go @@ -0,0 +1,6 @@ +package auth + +type UserToken interface { + GetUserId() int64 + GetToken() string +} diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go deleted file mode 100644 index 13b9ef607f5..00000000000 --- a/pkg/services/auth/auth_token.go +++ /dev/null @@ -1,279 +0,0 @@ -package auth - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "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 - SignOutUser(c *models.ReqContext) error -} - -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.Debug("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, - SameSite: s.Cfg.LoginCookieSameSite, - } - - 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) SignOutUser(c *models.ReqContext) error { - unhashedToken := c.GetCookie(s.Cfg.LoginCookieName) - if unhashedToken == "" { - return errors.New("cannot logout without session token") - } - - hashedToken := hashToken(unhashedToken) - - sql := `DELETE FROM user_auth_token WHERE auth_token = ?` - _, err := s.SQLStore.NewSession().Exec(sql, hashedToken) - - s.writeSessionCookie(c, "", -1) - return err -} - -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.Debug("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 deleted file mode 100644 index 312e53a3970..00000000000 --- a/pkg/services/auth/auth_token_test.go +++ /dev/null @@ -1,378 +0,0 @@ -package auth - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - macaron "gopkg.in/macaron.v1" - - "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("signing out should delete token and cookie if present", func() { - httpreq := &http.Request{Header: make(http.Header)} - httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken}) - - ctx := &models.ReqContext{Context: &macaron.Context{ - Req: macaron.Request{Request: httpreq}, - Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()), - }, - Logger: log.New("fakelogger"), - } - - err = userAuthTokenService.SignOutUser(ctx) - So(err, ShouldBeNil) - - // makes sure we tell the browser to overwrite the cookie - cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName) - So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader) - }) - - Convey("signing out an none existing session should return an error", func() { - httpreq := &http.Request{Header: make(http.Header)} - httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""}) - - ctx := &models.ReqContext{Context: &macaron.Context{ - Req: macaron.Request{Request: httpreq}, - Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()), - }, - Logger: log.New("fakelogger"), - } - - err = userAuthTokenService.SignOutUser(ctx) - So(err, ShouldNotBeNil) - }) - }) - - 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/authtoken/auth_token.go b/pkg/services/auth/authtoken/auth_token.go new file mode 100644 index 00000000000..4e4bd375501 --- /dev/null +++ b/pkg/services/auth/authtoken/auth_token.go @@ -0,0 +1,225 @@ +package authtoken + +import ( + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/grafana/grafana/pkg/services/auth" + + "github.com/grafana/grafana/pkg/infra/serverlock" + + "github.com/grafana/grafana/pkg/log" + "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.Register(®istry.Descriptor{ + Name: "AuthTokenService", + Instance: &UserAuthTokenServiceImpl{}, + InitPriority: registry.Low, + }) +} + +var getTime = time.Now + +const urgentRotateTime = 1 * time.Minute + +type UserAuthTokenServiceImpl struct { + SQLStore *sqlstore.SqlStore `inject:""` + ServerLockService *serverlock.ServerLockService `inject:""` + Cfg *setting.Cfg `inject:""` + log log.Logger +} + +func (s *UserAuthTokenServiceImpl) Init() error { + s.log = log.New("auth") + return nil +} + +func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + clientIP = util.ParseIPAddress(clientIP) + token, err := util.RandomHex(16) + if err != nil { + return nil, err + } + + hashedToken := hashToken(token) + + now := getTime().Unix() + + userAuthToken := 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(&userAuthToken) + if err != nil { + return nil, err + } + + userAuthToken.UnhashedToken = token + + s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken) + + return userAuthToken.toUserToken() +} + +func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (auth.UserToken, error) { + hashedToken := hashToken(unhashedToken) + if setting.Env == setting.DEV { + s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) + } + + expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() + + var model userAuthToken + exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&model) + if err != nil { + return nil, err + } + + if !exists { + return nil, ErrAuthTokenNotFound + } + + if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen { + modelCopy := model + modelCopy.AuthTokenSeen = false + expireBefore := getTime().Add(-urgentRotateTime).Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", modelCopy.Id, modelCopy.PrevAuthToken, expireBefore).AllCols().Update(&modelCopy) + if err != nil { + return nil, err + } + + if affectedRows == 0 { + s.log.Debug("prev seen token unchanged", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken) + } else { + s.log.Debug("prev seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken) + } + } + + if !model.AuthTokenSeen && model.AuthToken == hashedToken { + modelCopy := model + modelCopy.AuthTokenSeen = true + modelCopy.SeenAt = getTime().Unix() + affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken).AllCols().Update(&modelCopy) + if err != nil { + return nil, err + } + + if affectedRows == 1 { + model = modelCopy + } + + if affectedRows == 0 { + s.log.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken) + } else { + s.log.Debug("seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken) + } + } + + model.UnhashedToken = unhashedToken + return model.toUserToken() +} + +func (s *UserAuthTokenServiceImpl) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { + if token == nil { + return false, nil + } + + model, err := extractModelFromToken(token) + if err != nil { + return false, err + } + + now := getTime() + + needsRotation := false + rotatedAt := time.Unix(model.RotatedAt, 0) + if model.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("token needs rotation", "tokenId", model.Id, "authTokenSeen", model.AuthTokenSeen, "rotatedAt", rotatedAt) + + clientIP = util.ParseIPAddress(clientIP) + newToken, err := util.RandomHex(16) + if err != nil { + return false, err + } + 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(), model.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix()) + if err != nil { + return false, err + } + + affected, _ := res.RowsAffected() + s.log.Debug("auth token rotated", "affected", affected, "auth_token_id", model.Id, "userId", model.UserId) + if affected > 0 { + model.UnhashedToken = newToken + return true, nil + } + + return false, nil +} + +func (s *UserAuthTokenServiceImpl) RevokeToken(token auth.UserToken) error { + if token == nil { + return ErrAuthTokenNotFound + } + + model, err := extractModelFromToken(token) + if err != nil { + return err + } + + rowsAffected, err := s.SQLStore.NewSession().Delete(model) + if err != nil { + return err + } + + if rowsAffected == 0 { + s.log.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) + return ErrAuthTokenNotFound + } + + s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) + + return nil +} + +func hashToken(token string) string { + hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) + return hex.EncodeToString(hashBytes[:]) +} diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/authtoken/auth_token_test.go new file mode 100644 index 00000000000..7809e235f5c --- /dev/null +++ b/pkg/services/auth/authtoken/auth_token_test.go @@ -0,0 +1,386 @@ +package authtoken + +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() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + So(model.AuthTokenSeen, ShouldBeFalse) + + Convey("When lookup unhashed token should return user auth token", func() { + userToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + lookedUpModel, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.UserId, ShouldEqual, userID) + So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + + storedAuthToken, err := ctx.getAuthTokenByID(lookedUpModel.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() { + userToken, err := userAuthTokenService.LookupToken(model.AuthToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(userToken, ShouldBeNil) + }) + + Convey("revoking existing token should delete token", func() { + err = userAuthTokenService.RevokeToken(userToken) + So(err, ShouldBeNil) + + model, err := ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + So(model, ShouldBeNil) + }) + + Convey("revoking nil token should return error", func() { + err = userAuthTokenService.RevokeToken(nil) + So(err, ShouldEqual, ErrAuthTokenNotFound) + }) + + Convey("revoking non-existing token should return error", func() { + model.Id = 1000 + nonExistingToken, err := model.toUserToken() + So(err, ShouldBeNil) + err = userAuthTokenService.RevokeToken(nonExistingToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + }) + }) + + Convey("expires correctly", func() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + + _, err = userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + + model, err = ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + + userToken, err = model.toUserToken() + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + _, err = userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + + stillGood, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + So(stillGood, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(24 * 7 * time.Hour) + } + notGood, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(notGood, ShouldBeNil) + }) + + Convey("can properly rotate tokens", func() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + + prevToken := model.AuthToken + unhashedPrev := model.UnhashedToken + + rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(rotated, ShouldBeFalse) + + updated, err := ctx.markAuthTokenAsSeen(model.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + model, err = ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + tok, err := model.toUserToken() + So(err, ShouldBeNil) + + getTime = func() time.Time { + return t.Add(time.Hour) + } + + rotated, err = userAuthTokenService.TryRotateToken(tok, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + unhashedToken := model.UnhashedToken + + model, err = ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + model.UnhashedToken = unhashedToken + + So(model.RotatedAt, ShouldEqual, getTime().Unix()) + So(model.ClientIp, ShouldEqual, "192.168.10.12") + So(model.UserAgent, ShouldEqual, "a new user agent") + So(model.AuthTokenSeen, ShouldBeFalse) + So(model.SeenAt, ShouldEqual, 0) + So(model.PrevAuthToken, ShouldEqual, prevToken) + + // ability to auth using an old token + + lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + lookedUpModel, err := extractModelFromToken(lookedUpUserToken) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + So(lookedUpModel.SeenAt, ShouldEqual, getTime().Unix()) + + lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.Id, ShouldEqual, model.Id) + So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(time.Hour + (2 * time.Minute)) + } + + lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev) + So(err, ShouldBeNil) + lookedUpModel, err = extractModelFromToken(lookedUpUserToken) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + + lookedUpModel, err = ctx.getAuthTokenByID(lookedUpModel.Id) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.AuthTokenSeen, ShouldBeFalse) + + rotated, err = userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + model, err = ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + So(model.SeenAt, ShouldEqual, 0) + }) + + Convey("keeps prev token valid for 1 minute after it is confirmed", func() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + + lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + So(lookedUpUserToken, ShouldNotBeNil) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + prevToken := model.UnhashedToken + rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + currentUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + So(currentUserToken, ShouldNotBeNil) + + prevUserToken, err := userAuthTokenService.LookupToken(prevToken) + So(err, ShouldBeNil) + So(prevUserToken, ShouldNotBeNil) + }) + + Convey("will not mark token unseen when prev and current are the same", func() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + + lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + lookedUpModel, err := extractModelFromToken(lookedUpUserToken) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + + lookedUpUserToken, err = userAuthTokenService.LookupToken(model.UnhashedToken) + So(err, ShouldBeNil) + lookedUpModel, err = extractModelFromToken(lookedUpUserToken) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + + lookedUpModel, err = ctx.getAuthTokenByID(lookedUpModel.Id) + So(err, ShouldBeNil) + So(lookedUpModel, ShouldNotBeNil) + So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + }) + + Convey("Rotate token", func() { + userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + model, err := extractModelFromToken(userToken) + So(err, ShouldBeNil) + So(model, ShouldNotBeNil) + + prevToken := model.AuthToken + + Convey("Should rotate current token and previous token when auth token seen", func() { + updated, err := ctx.markAuthTokenAsSeen(model.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(10 * time.Minute) + } + + rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(model.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(model.Id) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return t.Add(20 * time.Minute) + } + + rotated, err = userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + storedToken, err = ctx.getAuthTokenByID(model.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() { + model.RotatedAt = getTime().Add(-2 * time.Minute).Unix() + + getTime = func() time.Time { + return t.Add(2 * time.Minute) + } + + rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox") + So(err, ShouldBeNil) + So(rotated, ShouldBeTrue) + + storedToken, err := ctx.getAuthTokenByID(model.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"), + } + + 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/authtoken/model.go b/pkg/services/auth/authtoken/model.go new file mode 100644 index 00000000000..8bd89c68b04 --- /dev/null +++ b/pkg/services/auth/authtoken/model.go @@ -0,0 +1,76 @@ +package authtoken + +import ( + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/services/auth" +) + +// 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:"-"` +} + +func (uat *userAuthToken) toUserToken() (auth.UserToken, error) { + if uat == nil { + return nil, fmt.Errorf("needs pointer to userAuthToken struct") + } + + return &userTokenImpl{ + userAuthToken: uat, + }, nil +} + +type userToken interface { + auth.UserToken + GetModel() *userAuthToken +} + +type userTokenImpl struct { + *userAuthToken +} + +func (ut *userTokenImpl) GetUserId() int64 { + return ut.UserId +} + +func (ut *userTokenImpl) GetToken() string { + return ut.UnhashedToken +} + +func (ut *userTokenImpl) GetModel() *userAuthToken { + return ut.userAuthToken +} + +func extractModelFromToken(token auth.UserToken) (*userAuthToken, error) { + ut, ok := token.(userToken) + if !ok { + return nil, fmt.Errorf("failed to cast token") + } + + return ut.GetModel(), nil +} + +// UserAuthTokenService are used for generating and validating user auth tokens +type UserAuthTokenService interface { + CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) + LookupToken(unhashedToken string) (auth.UserToken, error) + TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) + RevokeToken(token auth.UserToken) error +} diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/authtoken/session_cleanup.go similarity index 98% rename from pkg/services/auth/session_cleanup.go rename to pkg/services/auth/authtoken/session_cleanup.go index 7e523181a7b..cd2b766d6c0 100644 --- a/pkg/services/auth/session_cleanup.go +++ b/pkg/services/auth/authtoken/session_cleanup.go @@ -1,4 +1,4 @@ -package auth +package authtoken import ( "context" diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/authtoken/session_cleanup_test.go similarity index 98% rename from pkg/services/auth/session_cleanup_test.go rename to pkg/services/auth/authtoken/session_cleanup_test.go index eef2cd74d04..101a279c374 100644 --- a/pkg/services/auth/session_cleanup_test.go +++ b/pkg/services/auth/authtoken/session_cleanup_test.go @@ -1,4 +1,4 @@ -package auth +package authtoken import ( "fmt" diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go deleted file mode 100644 index 7a0f49539f2..00000000000 --- a/pkg/services/auth/model.go +++ /dev/null @@ -1,25 +0,0 @@ -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:"-"` -} From d53e64a32c48fca9149d2e291313a6dc8b04bb53 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 4 Feb 2019 23:44:28 +0100 Subject: [PATCH 033/144] move auth token middleware/hooks to middleware package fix/adds auth token middleware tests --- pkg/api/common_test.go | 63 +++++++++-- pkg/api/http_server.go | 18 +-- pkg/api/login.go | 17 ++- pkg/middleware/middleware.go | 65 ++++++++++- pkg/middleware/middleware_test.go | 165 +++++++++++++++++++++++++--- pkg/middleware/org_redirect_test.go | 31 ++++-- pkg/middleware/quota_test.go | 16 ++- pkg/models/context.go | 2 + 8 files changed, 324 insertions(+), 53 deletions(-) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index fe02c94e277..853a04b5c11 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -129,24 +130,70 @@ func setupScenarioContext(url string) *scenarioContext { return sc } +type fakeUserToken interface { + auth.UserToken + SetToken(token string) +} + +type userTokenImpl struct { + userId int64 + token string +} + +func (ut *userTokenImpl) GetUserId() int64 { + return ut.userId +} + +func (ut *userTokenImpl) GetToken() string { + return ut.token +} + +func (ut *userTokenImpl) SetToken(token string) { + ut.token = token +} + type fakeUserAuthTokenService struct { - initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool + createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) + tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) + revokeTokenProvider func(token auth.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { - return false + createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + revokeTokenProvider: func(token auth.UserToken) error { + return nil }, } } -func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { - return s.initContextWithTokenProvider(ctx, orgID) +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { - return nil +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { + return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil } +func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return s.tryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { + return s.revokeTokenProvider(token) +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 7b7c1478a4c..a0a65d73244 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -21,7 +21,7 @@ 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/auth/authtoken" "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" @@ -48,14 +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:""` - AuthTokenService auth.UserAuthTokenService `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 authtoken.UserAuthTokenService `inject:""` } func (hs *HTTPServer) Init() error { diff --git a/pkg/api/login.go b/pkg/api/login.go index 49da147724e..d25e83d34e8 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -5,11 +5,14 @@ import ( "net/http" "net/url" + "github.com/grafana/grafana/pkg/services/auth/authtoken" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -126,17 +129,23 @@ func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { if user == nil { - hs.log.Error("User login with nil user") + hs.log.Error("user login with nil user") } - err := hs.AuthTokenService.UserAuthenticatedHook(user, c) + userToken, err := hs.AuthTokenService.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent()) if err != nil { - hs.log.Error("User auth hook failed", "error", err) + hs.log.Error("failed to create auth token", "error", err) } + + middleware.WriteSessionCookie(c, userToken.GetToken(), middleware.OneYearInSeconds) } func (hs *HTTPServer) Logout(c *m.ReqContext) { - hs.AuthTokenService.SignOutUser(c) + if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != authtoken.ErrAuthTokenNotFound { + hs.log.Error("failed to revoke auth token", "error", err) + } + + middleware.WriteSessionCookie(c, "", -1) if setting.SignoutRedirectUrl != "" { c.Redirect(setting.SignoutRedirectUrl) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 3722ac3058f..6cf29340b82 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -1,13 +1,15 @@ package middleware import ( + "net/http" + "net/url" "strconv" "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/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -21,7 +23,7 @@ var ( ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ) -func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { +func GetContextHandler(ats authtoken.UserAuthTokenService) macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ Context: c, @@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { case initContextWithApiKey(ctx): case initContextWithBasicAuth(ctx, orgId): case initContextWithAuthProxy(ctx, orgId): - case ats.InitContextWithToken(ctx, orgId): + case initContextWithToken(ats, ctx, orgId): case initContextWithAnonymousUser(ctx): } @@ -166,6 +168,63 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } +const cookieName = "grafana_session" +const OneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often. + +func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx *m.ReqContext, orgID int64) bool { + rawToken := ctx.GetCookie(cookieName) + if rawToken == "" { + return false + } + + token, err := authTokenService.LookupToken(rawToken) + if err != nil { + ctx.Logger.Error("failed to look up user based on cookie", "error", err) + return false + } + + query := m.GetSignedInUserQuery{UserId: token.GetUserId(), OrgId: orgID} + if err := bus.Dispatch(&query); err != nil { + ctx.Logger.Error("failed to get user with id", "userId", token.GetUserId(), "error", err) + return false + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + ctx.UserToken = token + + rotated, err := authTokenService.TryRotateToken(token, ctx.RemoteAddr(), ctx.Req.UserAgent()) + if err != nil { + ctx.Logger.Error("failed to rotate token", "error", err) + return true + } + + if rotated { + WriteSessionCookie(ctx, token.GetToken(), OneYearInSeconds) + } + + return true +} + +func WriteSessionCookie(ctx *m.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: cookieName, + Value: url.QueryEscape(value), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: false, // TODO: use setting SecurityHTTPSCookies + MaxAge: maxAge, + SameSite: http.SameSiteLaxMode, // TODO: use setting LoginCookieSameSite + } + + http.SetCookie(ctx.Resp, &cookie) +} + func AddDefaultResponseHeaders() macaron.Handler { return func(ctx *m.ReqContext) { if ctx.IsApiRequest() && ctx.Req.Method == "GET" { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 4679c449853..4e10ee39201 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -10,6 +10,8 @@ import ( 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/auth" + "github.com/grafana/grafana/pkg/services/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -146,17 +148,91 @@ func TestMiddlewareContext(t *testing.T) { }) }) - middlewareScenario("Auth token service", func(sc *scenarioContext) { - var wasCalled bool - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - wasCalled = true - return false + middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: unhashedToken, + }, nil } sc.fakeReq("GET", "/").exec() - Convey("should call middleware", func() { - So(wasCalled, ShouldBeTrue) + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) + So(sc.context.UserToken.GetToken(), ShouldEqual, "token") + }) + + Convey("should not set cookie", func() { + So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "") + }) + }) + + middlewareScenario("Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: unhashedToken, + }, nil + } + + sc.userAuthTokenService.tryRotateTokenProvider = func(userToken auth.UserToken, clientIP, userAgent string) (bool, error) { + userToken.(fakeUserToken).SetToken("rotated") + return true, nil + } + + expectedCookie := &http.Cookie{ + Name: cookieName, + Value: "rotated", + Path: setting.AppSubUrl + "/", + HttpOnly: true, + MaxAge: OneYearInSeconds, + SameSite: http.SameSiteLaxMode, + } + + sc.fakeReq("GET", "/").exec() + + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) + So(sc.context.UserToken.GetToken(), ShouldEqual, "rotated") + }) + + Convey("should set cookie", func() { + So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String()) + }) + }) + + middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return nil, authtoken.ErrAuthTokenNotFound + } + + sc.fakeReq("GET", "/").exec() + + Convey("should not init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeFalse) + So(sc.context.UserId, ShouldEqual, 0) + So(sc.context.UserToken, ShouldBeNil) }) }) @@ -508,6 +584,7 @@ type scenarioContext struct { resp *httptest.ResponseRecorder apiKey string authHeader string + tokenSessionCookie string respJson map[string]interface{} handlerFunc handlerFunc defaultHandler macaron.Handler @@ -522,6 +599,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext { return sc } +func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext { + sc.tokenSessionCookie = unhashedToken + return sc +} + func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext { sc.authHeader = authHeader return sc @@ -571,6 +653,13 @@ func (sc *scenarioContext) exec() { sc.req.Header.Add("Authorization", sc.authHeader) } + if sc.tokenSessionCookie != "" { + sc.req.AddCookie(&http.Cookie{ + Name: cookieName, + Value: sc.tokenSessionCookie, + }) + } + sc.m.ServeHTTP(sc.resp, sc.req) if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" { @@ -582,24 +671,70 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) +type fakeUserToken interface { + auth.UserToken + SetToken(token string) +} + +type userTokenImpl struct { + userId int64 + token string +} + +func (ut *userTokenImpl) GetUserId() int64 { + return ut.userId +} + +func (ut *userTokenImpl) GetToken() string { + return ut.token +} + +func (ut *userTokenImpl) SetToken(token string) { + ut.token = token +} + type fakeUserAuthTokenService struct { - initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool + createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) + tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) + revokeTokenProvider func(token auth.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { - return false + createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + revokeTokenProvider: func(token auth.UserToken) error { + return nil }, } } -func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { - return s.initContextWithTokenProvider(ctx, orgID) +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { - return nil +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { + return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil } +func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return s.tryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { + return s.revokeTokenProvider(token) +} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 46b8776fdcc..c7479b3e9bc 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -3,6 +3,8 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/auth" + "fmt" "github.com/grafana/grafana/pkg/bus" @@ -14,14 +16,21 @@ 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.withTokenSessionCookie("token") bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { 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 + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil } sc.m.Get("/", sc.defaultHandler) @@ -33,21 +42,23 @@ func TestOrgRedirectMiddleware(t *testing.T) { }) middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") 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 }) + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil + } + sc.m.Get("/", sc.defaultHandler) sc.fakeReq("GET", "/?orgId=3").exec() diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 4f2203a5d3d..af22f41deba 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/bus" 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/smartystreets/goconvey/convey" @@ -74,10 +75,17 @@ func TestMiddlewareQuota(t *testing.T) { }) middlewareScenario("with user logged in", func(sc *scenarioContext) { - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12} - ctx.IsSignedIn = true - return true + sc.withTokenSessionCookie("token") + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil } bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { diff --git a/pkg/models/context.go b/pkg/models/context.go index df970451304..da63db63f45 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -13,6 +14,7 @@ import ( type ReqContext struct { *macaron.Context *SignedInUser + UserToken auth.UserToken // This should only be used by the auth_proxy Session session.SessionStore From a7a964ec19cec8f0848d14eee6109d3656f61701 Mon Sep 17 00:00:00 2001 From: SamuelToh Date: Sun, 27 Jan 2019 21:49:22 +1000 Subject: [PATCH 034/144] Added PATCH verb end point for annotation op Added new PATCH verb annotation endpoint Removed unwanted fmt Added test cases for PATCH verb annotation endpoint Fixed formatting issue Check arr len before proceeding Updated doc to include PATCH verb annotation endpt --- docs/sources/http_api/annotations.md | 27 +++++++++++- pkg/api/annotations.go | 59 ++++++++++++++++++++++++++ pkg/api/annotations_test.go | 62 ++++++++++++++++++++++++++++ pkg/api/api.go | 1 + pkg/api/dtos/annotations.go | 8 ++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index 6633714d77b..dee4ede0777 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -160,15 +160,18 @@ Content-Type: application/json } ``` -## Update Annotation +## Replace Annotation `PUT /api/annotations/:id` +Replaces the annotation that matches the specified id. + **Example Request**: ```json PUT /api/annotations/1141 HTTP/1.1 Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Content-Type: application/json { @@ -180,6 +183,28 @@ Content-Type: application/json } ``` +## Update Annotation + +`PATCH /api/annotations/:id` + +Updates one or more properties of an annotation that matches the specified id. + +**Example Request**: + +```json +PATCH /api/annotations/1145 HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +Content-Type: application/json + +{ + "time":1507037197000, + "timeEnd":1507180807095, + "text":"New Annotation Description", + "tags":["tag6","tag7","tag8"] +} +``` + ## Delete Annotation By Id `DELETE /api/annotations/:id` diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 242b5531f51..da9b55a1c16 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -210,6 +210,65 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response { return Success("Annotation updated") } +func PatchAnnotation(c *m.ReqContext, cmd dtos.PatchAnnotationsCmd) Response { + annotationID := c.ParamsInt64(":annotationId") + + repo := annotations.GetRepository() + + if resp := canSave(c, repo, annotationID); resp != nil { + return resp + } + + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return Error(500, "Could not find annotation to update", err) + } + + existing := annotations.Item{ + OrgId: c.OrgId, + UserId: c.UserId, + Id: annotationID, + Epoch: items[0].Time, + Text: items[0].Text, + Tags: items[0].Tags, + RegionId: items[0].RegionId, + } + + if cmd.Tags != nil { + existing.Tags = cmd.Tags + } + + if cmd.Text != "" && cmd.Text != existing.Text { + existing.Text = cmd.Text + } + + if cmd.Time > 0 && cmd.Time != existing.Epoch { + existing.Epoch = cmd.Time + } + + if err := repo.Update(&existing); err != nil { + return Error(500, "Failed to update annotation", err) + } + + // Update region end time if provided + if existing.RegionId != 0 && cmd.TimeEnd > 0 { + itemRight := existing + itemRight.RegionId = existing.Id + itemRight.Epoch = cmd.TimeEnd + + // We don't know id of region right event, so set it to 0 and find then using query like + // ... WHERE region_id = AND id != ... + itemRight.Id = 0 + + if err := repo.Update(&itemRight); err != nil { + return Error(500, "Failed to update annotation for region end time", err) + } + } + + return Success("Annotation patched") +} + func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response { repo := annotations.GetRepository() diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 08f3018c694..ebdd867a031 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -27,6 +27,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) { IsRegion: false, } + patchCmd := dtos.PatchAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + } + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER Convey("Should not be allowed to save an annotation", func() { @@ -40,6 +46,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -67,6 +78,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 200) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -100,6 +116,13 @@ func TestAnnotationsApiEndpoint(t *testing.T) { Id: 1, } + patchCmd := dtos.PatchAnnotationsCmd{ + Time: 8000, + Text: "annotation text 50", + Tags: []string{"foo", "bar"}, + Id: 1, + } + deleteCmd := dtos.DeleteAnnotationsCmd{ DashboardId: 1, PanelId: 1, @@ -136,6 +159,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -163,6 +191,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 200) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -189,6 +222,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) { sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) }) + + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) @@ -264,6 +303,29 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. }) } +func patchAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PatchAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Patch(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() diff --git a/pkg/api/api.go b/pkg/api/api.go index 980706d8355..0685ef3814d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -354,6 +354,7 @@ func (hs *HTTPServer) registerRoutes() { annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation)) annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation)) + annotationsRoute.Patch("/:annotationId", bind(dtos.PatchAnnotationsCmd{}), Wrap(PatchAnnotation)) annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion)) annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation)) }) diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index c917b0d9feb..b64329e56d1 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -22,6 +22,14 @@ type UpdateAnnotationsCmd struct { TimeEnd int64 `json:"timeEnd"` } +type PatchAnnotationsCmd struct { + Id int64 `json:"id"` + Time int64 `json:"time"` + Text string `json:"text"` + Tags []string `json:"tags"` + TimeEnd int64 `json:"timeEnd"` +} + type DeleteAnnotationsCmd struct { AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` From e4c92ae12433cefffc3b9855fc117def44a0ebb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 09:24:30 +0100 Subject: [PATCH 035/144] added comment to initDashboard --- public/app/features/dashboard/state/initDashboard.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 5419fcb41d7..14428bfa290 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -46,6 +46,15 @@ async function redirectToNewUrl(slug: string, dispatch: any, currentPath: string } } +/** + * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model. + * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates) + * + * This is used both for single dashboard & solo panel routes, home & new dashboard routes. + * + * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on + * + */ export function initDashboard({ $injector, $scope, From 04f190c3e357476adb1fd95284b6392648be38bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 11:11:17 +0100 Subject: [PATCH 036/144] Updated playlist test --- public/app/features/playlist/specs/playlist_srv.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/public/app/features/playlist/specs/playlist_srv.test.ts b/public/app/features/playlist/specs/playlist_srv.test.ts index e6b7671c964..d2ff27e54e0 100644 --- a/public/app/features/playlist/specs/playlist_srv.test.ts +++ b/public/app/features/playlist/specs/playlist_srv.test.ts @@ -1,6 +1,6 @@ import { PlaylistSrv } from '../playlist_srv'; -const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }]; +const dashboards = [{ url: 'dash1' }, { url: 'dash2' }]; const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance }] => { const mockBackendSrv = { @@ -50,13 +50,12 @@ const mockWindowLocation = (): [jest.MockInstance, () => void] => { describe('PlaylistSrv', () => { let srv: PlaylistSrv; - let mockLocationService: { url: jest.MockInstance }; let hrefMock: jest.MockInstance; let unmockLocation: () => void; const initialUrl = 'http://localhost/playlist'; beforeEach(() => { - [srv, mockLocationService] = createPlaylistSrv(); + [srv] = createPlaylistSrv(); [hrefMock, unmockLocation] = mockWindowLocation(); // This will be cached in the srv when start() is called @@ -71,7 +70,6 @@ describe('PlaylistSrv', () => { await srv.start(1); for (let i = 0; i < 6; i++) { - expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`); srv.next(); } @@ -84,7 +82,6 @@ describe('PlaylistSrv', () => { // 1 complete loop for (let i = 0; i < 3; i++) { - expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`); srv.next(); } @@ -93,7 +90,6 @@ describe('PlaylistSrv', () => { // Another 2 loops for (let i = 0; i < 4; i++) { - expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`); srv.next(); } From fd1ef0a2be8ae3115ddeb65f44b1a50dbd5cb650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 12:10:42 +0100 Subject: [PATCH 037/144] Added custom scrollbar and remember scroll pos to jump back to same scroll pos when going back to dashboard from edit mode --- .../CustomScrollbar/CustomScrollbar.tsx | 1 + public/app/core/components/Page/Page.tsx | 7 --- .../dashboard/containers/DashboardPage.tsx | 45 +++++++++++++------ public/app/routes/GrafanaCtrl.ts | 1 + public/app/routes/ReactContainer.tsx | 3 ++ 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 40f6c6c3c37..17c511826fb 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -45,6 +45,7 @@ export class CustomScrollbar extends PureComponent { if (this.props.scrollTop > 10000) { ref.scrollToBottom(); } else { + console.log('scrollbar set scrollTop'); ref.scrollTop(this.props.scrollTop); } } diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index c4846ecf85d..997f02b700c 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -17,13 +17,10 @@ interface Props { } class Page extends Component { - private bodyClass = 'is-react'; - private body = document.body; static Header = PageHeader; static Contents = PageContents; componentDidMount() { - this.body.classList.add(this.bodyClass); this.updateTitle(); } @@ -33,10 +30,6 @@ class Page extends Component { } } - componentWillUnmount() { - this.body.classList.remove(this.bodyClass); - } - updateTitle = () => { const title = this.getPageTitle; document.title = title ? title + ' - Grafana' : 'Grafana'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 404c953eecb..33f2a602b0c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -1,6 +1,6 @@ // Libraries import $ from 'jquery'; -import React, { PureComponent } from 'react'; +import React, { PureComponent, MouseEvent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import classNames from 'classnames'; @@ -9,11 +9,11 @@ import classNames from 'classnames'; import { createErrorNotification } from 'app/core/copy/appNotification'; // Components -import { LoadingPlaceholder } from '@grafana/ui'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; import { DashNav } from '../components/DashNav'; import { SubMenu } from '../components/SubMenu'; import { DashboardSettings } from '../components/DashboardSettings'; +import { CustomScrollbar } from '@grafana/ui'; // Redux import { initDashboard } from '../state/initDashboard'; @@ -50,6 +50,8 @@ interface State { isEditing: boolean; isFullscreen: boolean; fullscreenPanel: PanelModel | null; + scrollTop: number; + rememberScrollTop: number; } export class DashboardPage extends PureComponent { @@ -58,6 +60,8 @@ export class DashboardPage extends PureComponent { isEditing: false, isFullscreen: false, fullscreenPanel: null, + scrollTop: 0, + rememberScrollTop: 0, }; async componentDidMount() { @@ -121,6 +125,7 @@ export class DashboardPage extends PureComponent { isEditing: urlEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel, + rememberScrollTop: this.state.scrollTop, }); this.setPanelFullscreenClass(urlFullscreen); } else { @@ -135,9 +140,17 @@ export class DashboardPage extends PureComponent { dashboard.setViewMode(this.state.fullscreenPanel, false, false); } - this.setState({ isEditing: false, isFullscreen: false, fullscreenPanel: null }, () => { - dashboard.render(); - }); + this.setState( + { + isEditing: false, + isFullscreen: false, + fullscreenPanel: null, + scrollTop: this.state.rememberScrollTop, + }, + () => { + dashboard.render(); + } + ); this.setPanelFullscreenClass(false); } @@ -160,9 +173,10 @@ export class DashboardPage extends PureComponent { $('body').toggleClass('panel-in-fullscreen', isFullscreen); } - renderLoadingState() { - return ; - } + setScrollTop = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; renderDashboard() { const { dashboard, editview } = this.props; @@ -186,7 +200,7 @@ export class DashboardPage extends PureComponent { render() { const { dashboard, editview, $injector } = this.props; - const { isSettingsOpening, isEditing, isFullscreen } = this.state; + const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state; if (!dashboard) { return null; @@ -201,6 +215,7 @@ export class DashboardPage extends PureComponent { 'dashboard-container': true, 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled, }); + return (
{ $injector={$injector} />
- {dashboard && editview && } + + {dashboard && editview && } -
- {dashboard.meta.submenuEnabled && } - -
+
+ {dashboard.meta.submenuEnabled && } + +
+
); diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 07d99725113..9157c189ab2 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -45,6 +45,7 @@ export class GrafanaCtrl { }; $rootScope.colors = colors; + $rootScope.onAppEvent = function(name, callback, localScope) { const unbind = $rootScope.$on(name, callback); let callerScope = this; diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index a56c8878fb1..d64e74e3949 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -47,9 +47,12 @@ export function reactContainer( routeInfo: $route.current.$$route.routeInfo, }; + document.body.classList.add('is-react'); + ReactDOM.render(WrapInProvider(store, component, props), elem[0]); scope.$on('$destroy', () => { + document.body.classList.remove('is-react'); ReactDOM.unmountComponentAtNode(elem[0]); }); }, From 139fb65fa926c17cdb68f15c69da95c8eeefd7ee Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 5 Feb 2019 12:36:12 +0100 Subject: [PATCH 038/144] docs: fixes #14940 --- docs/sources/installation/configuration.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 46bab83654e..ac3dc6ebfd0 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -393,9 +393,7 @@ Analytics ID here. By default this feature is disabled. ### check_for_updates -Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used -in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor -send any sensitive information. +Set to false to disable all checks to https://grafana.com for new versions of installed plugins and to the Grafana GitHub repository to check for a newer version of Grafana. The version information is used in some UI views to notify that a new Grafana update or a plugin update exists. This option does not cause any auto updates, nor send any sensitive information. The check is run every 10 minutes.
From 2802569529197d48e602da0f67bc9f2e1b1e75a1 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 5 Feb 2019 12:47:42 +0100 Subject: [PATCH 039/144] minor layout change, simple render test --- .../AddPanelWidget/AddPanelWidget.test.tsx | 23 ++++++ .../AddPanelWidget/AddPanelWidget.tsx | 39 +++++---- .../AddPanelWidget/_AddPanelWidget.scss | 27 ++++--- .../AddPanelWidget.test.tsx.snap | 81 +++++++++++++++++++ 4 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx create mode 100644 public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx new file mode 100644 index 00000000000..91da066e4cc --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddPanelWidget, Props } from './AddPanelWidget'; +import { DashboardModel, PanelModel } from '../../state'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboard: {} as DashboardModel, + panel: {} as PanelModel, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index 21c4451d9b9..e70615bde39 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -1,8 +1,8 @@ import React from 'react'; import _ from 'lodash'; import config from 'app/core/config'; -import { PanelModel } from '../../state/PanelModel'; -import { DashboardModel } from '../../state/DashboardModel'; +import { PanelModel } from '../../state'; +import { DashboardModel } from '../../state'; import store from 'app/core/store'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { updateLocation } from 'app/core/actions'; @@ -57,6 +57,7 @@ export class AddPanelWidget extends React.Component { copiedPanels.push(pluginCopy); } } + return _.sortBy(copiedPanels, 'sort'); } @@ -65,14 +66,6 @@ export class AddPanelWidget extends React.Component { this.props.dashboard.removePanel(this.props.dashboard.panels[0]); } - copyButton(panel) { - return ( - - ); - } - moveToEdit(location) { reduxStore.dispatch(updateLocation(location)); } @@ -151,7 +144,7 @@ export class AddPanelWidget extends React.Component { renderOptionLink = (icon, text, onClick) => { return (
- +
@@ -162,6 +155,8 @@ export class AddPanelWidget extends React.Component { }; render() { + const { copiedPanelPlugins } = this.state; + return (
@@ -172,9 +167,25 @@ export class AddPanelWidget extends React.Component {
- {this.renderOptionLink('queries', 'Add query', this.onCreateNewPanel)} - {this.renderOptionLink('visualization', 'Choose Panel type', () => this.onCreateNewPanel('visualization'))} - {this.renderOptionLink('queries', 'Convert to row', this.onCreateNewRow)} +
+ {this.renderOptionLink('queries', 'Add query', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Panel type', () => + this.onCreateNewPanel('visualization') + )} +
+
+
+ Convert to row +
+ {copiedPanelPlugins.length === 1 && ( +
this.onPasteCopiedPanel(copiedPanelPlugins[0])} + > + Paste copied panel +
+ )} +
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 587daa2703f..ab6ff8556d8 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -27,11 +27,8 @@ } .add-panel-widget__link { - display: block; margin: 0 8px; - width: 130px; - text-align: center; - padding: 8px 0; + width: 150px; } .add-panel-widget__icon { @@ -54,14 +51,24 @@ margin-right: -10px; } +.add-panel-widget__create { + display: inherit; + margin-bottom: 24px; +} + +.add-panel-widget__actions { + display: inherit; +} + +.add-panel-widget__action { + cursor: pointer; + margin: 0 4px; +} + .add-panel-widget__btn-container { + height: 100%; display: flex; justify-content: center; align-items: center; - height: 100%; - flex-direction: row; - - .btn { - margin-bottom: 10px; - } + flex-direction: column; } diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap new file mode 100644 index 00000000000..585f45210af --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + +`; From bbc5dff7bd719b4410eb685a8ff31dbd81ab96d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 12:56:03 +0100 Subject: [PATCH 040/144] Fixed add panel should scroll to top --- .../dashboard/components/DashNav/DashNav.tsx | 137 ++++++++---------- .../dashboard/containers/DashboardPage.tsx | 31 ++-- 2 files changed, 77 insertions(+), 91 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 374fd6dcd36..297d7ca7ea7 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -23,6 +23,7 @@ export interface Props { isFullscreen: boolean; $injector: any; updateLocation: typeof updateLocation; + onAddPanel: () => void; } export class DashNav extends PureComponent { @@ -39,7 +40,8 @@ export class DashNav extends PureComponent { componentDidMount() { const loader = getAngularLoader(); - const template = ''; + const template = + ''; const scopeProps = { dashboard: this.props.dashboard }; this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template); @@ -55,21 +57,6 @@ export class DashNav extends PureComponent { appEvents.emit('show-dash-search'); }; - onAddPanel = () => { - const { dashboard } = this.props; - - // Return if the "Add panel" exists already - if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { - return; - } - - dashboard.addPanel({ - type: 'add-panel', - gridPos: { x: 0, y: 0, w: 12, h: 8 }, - title: 'Panel Title', - }); - }; - onClose = () => { if (this.props.editview) { this.props.updateLocation({ @@ -137,7 +124,7 @@ export class DashNav extends PureComponent { }; render() { - const { dashboard, isFullscreen, editview } = this.props; + const { dashboard, isFullscreen, editview, onAddPanel } = this.props; const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; const { snapshot } = dashboard; @@ -186,73 +173,73 @@ export class DashNav extends PureComponent { tooltip="Add panel" classSuffix="add-panel" icon="gicon gicon-add-panel" - onClick={this.onAddPanel} + onClick={onAddPanel} /> - )} + )} - {canStar && ( - - )} + {canStar && ( + + )} - {canShare && ( - - )} + {canShare && ( + + )} - {canSave && ( - - )} + {canSave && ( + + )} - {snapshotUrl && ( - - )} + {snapshotUrl && ( + + )} - {showSettings && ( - - )} -
+ {showSettings && ( + + )} +
-
- -
+
+ +
-
(this.timePickerEl = element)} /> +
(this.timePickerEl = element)} /> - {(isFullscreen || editview) && ( -
- -
- )} -
+ {(isFullscreen || editview) && ( +
+ +
+ )} +
); } } diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 33f2a602b0c..1d1882f277d 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -178,25 +178,23 @@ export class DashboardPage extends PureComponent { this.setState({ scrollTop: target.scrollTop }); }; - renderDashboard() { - const { dashboard, editview } = this.props; - const { isEditing, isFullscreen } = this.state; + onAddPanel = () => { + const { dashboard } = this.props; - const classes = classNames({ - 'dashboard-container': true, - 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled, + // Return if the "Add panel" exists already + if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { + return; + } + + dashboard.addPanel({ + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: 'Panel Title', }); - return ( -
- {dashboard && editview && } - -
- -
-
- ); - } + // scroll to top after adding panel + this.setState({ scrollTop: 0 }); + }; render() { const { dashboard, editview, $injector } = this.props; @@ -224,6 +222,7 @@ export class DashboardPage extends PureComponent { isFullscreen={isFullscreen} editview={editview} $injector={$injector} + onAddPanel={this.onAddPanel} />
From 9ba98b87035e07714bf65c7a11c63a3f1c0c952f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 5 Feb 2019 13:13:52 +0100 Subject: [PATCH 041/144] Fixes #15223 by handling onPaste event because of bug in Slate --- public/app/features/explore/QueryField.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 8ab7e56dc5a..810bca9ef5a 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -468,6 +468,14 @@ export class QueryField extends React.PureComponent { + const pastedValue = event.clipboardData.getData('Text'); + const newValue = change.value.change().insertText(pastedValue); + this.onChange(newValue); + + return true; + }; + render() { const { disabled } = this.props; const wrapperClassName = classnames('slate-query-field__wrapper', { @@ -484,6 +492,7 @@ export class QueryField extends React.PureComponent Date: Tue, 5 Feb 2019 13:49:35 +0100 Subject: [PATCH 042/144] Basic loading state for slow dashboards --- .../CustomScrollbar/CustomScrollbar.tsx | 1 - public/app/core/redux/index.ts | 6 ++---- .../dashboard/containers/DashboardPage.tsx | 19 ++++++++++++++++++- .../app/features/dashboard/state/actions.ts | 4 ++-- .../features/dashboard/state/initDashboard.ts | 10 +++++++++- .../app/features/dashboard/state/reducers.ts | 13 +++++++++++-- public/app/types/dashboard.ts | 1 + public/sass/pages/_dashboard.scss | 11 +++++++++++ 8 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 17c511826fb..40f6c6c3c37 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -45,7 +45,6 @@ export class CustomScrollbar extends PureComponent { if (this.props.scrollTop > 10000) { ref.scrollToBottom(); } else { - console.log('scrollbar set scrollTop'); ref.scrollTop(this.props.scrollTop); } } diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts index 359f160b9ce..bf45d7d22df 100644 --- a/public/app/core/redux/index.ts +++ b/public/app/core/redux/index.ts @@ -1,4 +1,2 @@ -import { actionCreatorFactory } from './actionCreatorFactory'; -import { reducerFactory } from './reducerFactory'; - -export { actionCreatorFactory, reducerFactory }; +export { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from './actionCreatorFactory'; +export { reducerFactory } from './reducerFactory'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1d1882f277d..5fa48f45375 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -38,6 +38,7 @@ interface Props { urlEdit: boolean; urlFullscreen: boolean; loadingState: DashboardLoadingState; + isLoadingSlow: boolean; dashboard: DashboardModel; initDashboard: typeof initDashboard; setDashboardModel: typeof setDashboardModel; @@ -52,6 +53,7 @@ interface State { fullscreenPanel: PanelModel | null; scrollTop: number; rememberScrollTop: number; + showLoadingState: boolean; } export class DashboardPage extends PureComponent { @@ -59,6 +61,7 @@ export class DashboardPage extends PureComponent { isSettingsOpening: false, isEditing: false, isFullscreen: false, + showLoadingState: false, fullscreenPanel: null, scrollTop: 0, rememberScrollTop: 0, @@ -196,11 +199,24 @@ export class DashboardPage extends PureComponent { this.setState({ scrollTop: 0 }); }; + renderLoadingState() { + return ( +
+
+ Dashboard {this.props.loadingState} +
+
+ ); + } + render() { - const { dashboard, editview, $injector } = this.props; + const { dashboard, editview, $injector, isLoadingSlow } = this.props; const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state; if (!dashboard) { + if (isLoadingSlow) { + return this.renderLoadingState(); + } return null; } @@ -249,6 +265,7 @@ const mapStateToProps = (state: StoreState) => ({ urlFullscreen: state.location.query.fullscreen === true, urlEdit: state.location.query.edit === true, loadingState: state.dashboard.loadingState, + isLoadingSlow: state.dashboard.isLoadingSlow, dashboard: state.dashboard.model as DashboardModel, }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index bc57b8e5f10..da4c195c953 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -3,8 +3,7 @@ import { ThunkAction } from 'redux-thunk'; // Services & Utils import { getBackendSrv } from 'app/core/services/backend_srv'; -import { actionCreatorFactory } from 'app/core/redux'; -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux'; import { createSuccessNotification } from 'app/core/copy/appNotification'; // Actions @@ -25,6 +24,7 @@ import { DashboardLoadingState, MutableDashboard } from 'app/types/dashboard'; export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_LOADING_STATE').create(); export const setDashboardModel = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); +export const setDashboardLoadingSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_LOADING_SLOW').create(); export type Action = ActionOf; export type ThunkResult = ThunkAction; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 14428bfa290..d529ca0b531 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -12,7 +12,7 @@ import { config } from 'app/core/config'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; -import { setDashboardLoadingState, ThunkResult, setDashboardModel } from './actions'; +import { setDashboardLoadingState, ThunkResult, setDashboardModel, setDashboardLoadingSlow } from './actions'; import { removePanel } from '../utils/panel'; // Types @@ -71,6 +71,14 @@ export function initDashboard({ // set fetching state dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + // Detect slow loading / initializing and set state flag + // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing + setTimeout(() => { + if (getState().dashboard.model === null) { + dispatch(setDashboardLoadingSlow()); + } + }, 500); + try { switch (routeInfo) { // handle old urls with no uid diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 2f4e5df5c14..5566363c996 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,10 +1,11 @@ import { DashboardState, DashboardLoadingState } from 'app/types/dashboard'; -import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel } from './actions'; +import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; export const initialState: DashboardState = { loadingState: DashboardLoadingState.NotStarted, + isLoadingSlow: false, model: null, permissions: [], }; @@ -28,7 +29,15 @@ export const dashboardReducer = reducerFactory(initialState) filter: setDashboardModel, mapper: (state, action) => ({ ...state, - model: action.payload + model: action.payload, + isLoadingSlow: false, + }), + }) + .addMapper({ + filter: setDashboardLoadingSlow, + mapper: (state, action) => ({ + ...state, + isLoadingSlow: true, }), }) .create(); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 36c0a420f28..39d7e3cba8a 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -25,5 +25,6 @@ export enum DashboardLoadingState { export interface DashboardState { model: MutableDashboard | null; loadingState: DashboardLoadingState; + isLoadingSlow: boolean; permissions: DashboardAcl[] | null; } diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 9ca4e092f02..0f37ffc850e 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -276,3 +276,14 @@ div.flot-text { .panel-full-edit { padding-top: $dashboard-padding; } + +.dashboard-loading { + height: 60vh; + display: flex; + align-items: center; + justify-content: center; +} + +.dashboard-loading__text { + font-size: $font-size-lg; +} From aa2bf07c71d0e42e9e2df7ef40ae939f2a60c016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 14:12:32 +0100 Subject: [PATCH 043/144] Expand rows for panels in collapsed rows --- .../dashboard/containers/DashboardPage.tsx | 7 ++++++- .../dashboard/containers/SoloPanelPage.tsx | 8 +++++++- .../app/features/dashboard/state/DashboardModel.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 5fa48f45375..0d28ccb19a2 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -120,7 +120,12 @@ export class DashboardPage extends PureComponent { onEnterFullscreen() { const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props; - const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); + const panelId = parseInt(urlPanelId, 10); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); if (panel) { dashboard.setViewMode(panel, urlFullscreen, urlEdit); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index beb45b6904d..915d2e03965 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -59,7 +59,13 @@ export class SoloPanelPage extends Component { // we just got the dashboard! if (!prevProps.dashboard) { - const panel = dashboard.getPanelById(parseInt(urlPanelId, 10)); + const panelId = parseInt(urlPanelId, 10); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); + if (!panel) { this.setState({ notFound: true }); return; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 8756af2ceea..743eb61f97d 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -904,4 +904,18 @@ export class DashboardModel { this.processRepeats(); this.events.emit('template-variable-value-updated'); } + + expandParentRowFor(panelId: number) { + for (const panel of this.panels) { + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id === panelId) { + this.toggleRow(panel); + return; + } + } + } + } + } + } From da531032812565840c2e29c90b2f6e280adcb454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 14:18:35 +0100 Subject: [PATCH 044/144] Prevent viewers from going into edit mode --- public/app/features/dashboard/containers/DashboardPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0d28ccb19a2..1bd5218fd60 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -130,7 +130,7 @@ export class DashboardPage extends PureComponent { if (panel) { dashboard.setViewMode(panel, urlFullscreen, urlEdit); this.setState({ - isEditing: urlEdit, + isEditing: urlEdit && dashboard.meta.canEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel, rememberScrollTop: this.state.scrollTop, From ae0b027d69ce0fe2946aabfe55267150151a4038 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 09:34:04 +0100 Subject: [PATCH 045/144] chore: Replace sizeMe with AutoSizer in DashboardGrid --- .../dashboard/dashgrid/DashboardGrid.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 658bfad3816..5a65fadd74b 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -5,13 +5,12 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; +import { AutoSizer } from 'react-virtualized'; let lastGridWidth = 1200; let ignoreNextWidthChange = false; -interface GridWrapperProps { - size: { width: number; }; +interface SizedReactLayoutGridProps { layout: ReactGridLayout.Layout[]; onLayoutChange: (layout: ReactGridLayout.Layout[]) => void; children: JSX.Element | JSX.Element[]; @@ -25,8 +24,12 @@ interface GridWrapperProps { isFullscreen?: boolean; } +interface GridWrapperProps extends SizedReactLayoutGridProps { + sizedWidth: number; +} + function GridWrapper({ - size, + sizedWidth, layout, onLayoutChange, children, @@ -38,8 +41,8 @@ function GridWrapper({ isResizable, isDraggable, isFullscreen, -}: GridWrapperProps) { - const width = size.width > 0 ? size.width : lastGridWidth; +}: GridWrapperProps) { + const width = sizedWidth > 0 ? sizedWidth : lastGridWidth; // logic to ignore width changes (optimization) if (width !== lastGridWidth) { @@ -74,7 +77,16 @@ function GridWrapper({ ); } -const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); +const SizedReactLayoutGrid = (props: SizedReactLayoutGridProps) => ( + + {({width}) => ( + + )} + +); export interface DashboardGridProps { dashboard: DashboardModel; From 097396c517e1db4e5633f7625168c255511deb1c Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 09:57:54 +0100 Subject: [PATCH 046/144] chore: Replace withSize with AutoSizer in explore/Graph.tsx --- public/app/features/explore/Graph.test.tsx | 1 + public/app/features/explore/Graph.tsx | 27 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx index fe4deaf17aa..8976c677592 100644 --- a/public/app/features/explore/Graph.test.tsx +++ b/public/app/features/explore/Graph.test.tsx @@ -5,6 +5,7 @@ import { mockData } from './__mocks__/mockData'; const setup = (propOverrides?: object) => { const props = { + size: { width: 10, height: 20 }, data: mockData().slice(0, 19), range: { from: 'now-6h', to: 'now' }, ...propOverrides, diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 5d64dde28ce..b087f6a457d 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -1,7 +1,7 @@ import $ from 'jquery'; import React, { PureComponent } from 'react'; import moment from 'moment'; -import { withSize } from 'react-sizeme'; +import { AutoSizer } from 'react-virtualized'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; @@ -80,12 +80,15 @@ interface GraphProps { id?: string; range: RawTimeRange; split?: boolean; - size?: { width: number; height: number }; userOptions?: any; onChangeTime?: (range: RawTimeRange) => void; onToggleSeries?: (alias: string, hiddenSeries: Set) => void; } +interface SizedGraphProps extends GraphProps { + size: { width: number; height: number }; +} + interface GraphState { /** * Type parameter refers to the `alias` property of a `TimeSeries`. @@ -95,7 +98,7 @@ interface GraphState { showAllTimeSeries: boolean; } -export class Graph extends PureComponent { +export class Graph extends PureComponent { $el: any; dynamicOptions = null; @@ -116,7 +119,7 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { + componentDidUpdate(prevProps: SizedGraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || @@ -261,4 +264,18 @@ export class Graph extends PureComponent { } } -export default withSize()(Graph); +export default (props: GraphProps) => ( + + {({width, height}) => { + return ( + + ); + }} + +); From d68df9d704d2fb637ba302c86e11107fe5e1953e Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 12:09:24 +0100 Subject: [PATCH 047/144] fix: Calculation issue with AutoSizer in explore --- public/app/features/explore/Graph.tsx | 28 ++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index b087f6a457d..10006557349 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -265,17 +265,19 @@ export class Graph extends PureComponent { } export default (props: GraphProps) => ( - - {({width, height}) => { - return ( - - ); - }} - +
{/* div needed for AutoSizer to calculate, https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#observation */} + + {({width, height}) => ( +
+ {width > 0 && } +
+ )} +
+
); From 260b6f5de83133f668aa15a3874eeeb20d46cbe7 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 12:12:24 +0100 Subject: [PATCH 048/144] chore: Remove react-sizeme --- package.json | 1 - yarn.lock | 28 ---------------------------- 2 files changed, 29 deletions(-) diff --git a/package.json b/package.json index 77fd92baf57..18c0a56f0c4 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,6 @@ "react-highlight-words": "0.11.0", "react-popper": "^1.3.0", "react-redux": "^5.0.7", - "react-sizeme": "^2.3.6", "react-table": "^6.8.6", "react-transition-group": "^2.2.1", "react-virtualized": "^9.21.0", diff --git a/yarn.lock b/yarn.lock index 169abd40ee4..fd0c446fbce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3972,11 +3972,6 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" -batch-processor@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/batch-processor/-/batch-processor-1.0.0.tgz#75c95c32b748e0850d10c2b168f6bdbe9891ace8" - integrity sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg= - batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -6613,13 +6608,6 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= -element-resize-detector@^1.1.12: - version "1.2.0" - resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.2.0.tgz#63344fd6f4e5ecff6f018d027e17b281fd4fa338" - integrity sha512-UmhNB8sIJVZeg56gEjgmMd6p37sCg8j8trVW0LZM7Wzv+kxQ5CnRHcgRKBTB/kFUSn3e7UP59kl2V2U8Du1hmg== - dependencies: - batch-processor "1.0.0" - elliptic@^6.0.0: version "6.4.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" @@ -10900,11 +10888,6 @@ lodash.tail@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= - lodash.union@4.6.0, lodash.union@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" @@ -14215,17 +14198,6 @@ react-resizable@1.x: prop-types "15.x" react-draggable "^2.2.6 || ^3.0.3" -react-sizeme@^2.3.6: - version "2.5.2" - resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.5.2.tgz#e7041390cfb895ed15d896aa91d76e147e3b70b5" - integrity sha512-hYvcncV1FxVzPm2EhVwlOLf7Tk+k/ttO6rI7bfKUL/aL1gYzrY3DXJsdZ6nFaFgGSU/i8KC6gCoptOhBbRJpXQ== - dependencies: - element-resize-detector "^1.1.12" - invariant "^2.2.2" - lodash.debounce "^4.0.8" - lodash.throttle "^4.1.1" - shallowequal "^1.0.2" - react-split-pane@^0.1.84: version "0.1.85" resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.85.tgz#64819946a99b617ffa2d20f6f45a0056b6ee4faa" From f5431f521082743d930f54d1d93f6dcaface7f7a Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 13:48:00 +0100 Subject: [PATCH 049/144] chore: Explore: Remove inner AutoSizer, spread the size-object to width/height, change height type to number --- public/app/features/explore/Explore.tsx | 2 +- public/app/features/explore/Graph.tsx | 38 +++++-------------- .../app/features/explore/GraphContainer.tsx | 6 ++- public/app/features/explore/Logs.tsx | 2 +- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b210bcccc18..437b50db63c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -211,7 +211,7 @@ export class Explore extends React.PureComponent { {showingStartPage && } {!showingStartPage && ( <> - {supportsGraph && !supportsLogs && } + {supportsGraph && !supportsLogs && } {supportsTable && } {supportsLogs && ( ) => void; } -interface SizedGraphProps extends GraphProps { - size: { width: number; height: number }; -} - interface GraphState { /** * Type parameter refers to the `alias` property of a `TimeSeries`. @@ -98,7 +94,7 @@ interface GraphState { showAllTimeSeries: boolean; } -export class Graph extends PureComponent { +export class Graph extends PureComponent { $el: any; dynamicOptions = null; @@ -119,13 +115,13 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: SizedGraphProps, prevState: GraphState) { + componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || - (prevProps.size && prevProps.size.width !== this.props.size.width) || + prevProps.width !== this.props.width || !equal(prevState.hiddenSeries, this.state.hiddenSeries) ) { this.draw(); @@ -147,8 +143,8 @@ export class Graph extends PureComponent { }; getDynamicOptions() { - const { range, size } = this.props; - const ticks = (size.width || 0) / 100; + const { range, width } = this.props; + const ticks = (width || 0) / 100; let { from, to } = range; if (!moment.isMoment(from)) { from = dateMath.parse(from, false); @@ -240,7 +236,7 @@ export class Graph extends PureComponent { } render() { - const { height = '100px', id = 'graph' } = this.props; + const { height = 100, id = 'graph' } = this.props; const { hiddenSeries } = this.state; const data = this.getGraphData(); @@ -264,20 +260,4 @@ export class Graph extends PureComponent { } } -export default (props: GraphProps) => ( -
{/* div needed for AutoSizer to calculate, https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#observation */} - - {({width, height}) => ( -
- {width > 0 && } -
- )} -
-
-); +export default Graph; diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx index 7263fd09288..3950d89c11f 100644 --- a/public/app/features/explore/GraphContainer.tsx +++ b/public/app/features/explore/GraphContainer.tsx @@ -20,6 +20,7 @@ interface GraphContainerProps { split: boolean; toggleGraph: typeof toggleGraph; changeTime: typeof changeTime; + width: number; } export class GraphContainer extends PureComponent { @@ -32,8 +33,8 @@ export class GraphContainer extends PureComponent { }; render() { - const { exploreId, graphResult, loading, showingGraph, showingTable, range, split } = this.props; - const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props; + const graphHeight = showingGraph && showingTable ? 200 : 400; if (!graphResult) { return null; @@ -48,6 +49,7 @@ export class GraphContainer extends PureComponent { onChangeTime={this.onChangeTime} range={range} split={split} + width={width} /> ); diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 490257cb9a9..b6c903bc504 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -214,7 +214,7 @@ export default class Logs extends PureComponent {
Date: Tue, 5 Feb 2019 14:09:25 +0100 Subject: [PATCH 050/144] fix: Update snapshot --- .../app/features/explore/__snapshots__/Graph.test.tsx.snap | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index a7ec6deb22c..c38fb26a252 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -7,7 +7,7 @@ exports[`Render should render component 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> @@ -480,7 +480,7 @@ exports[`Render should render component with disclaimer 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> @@ -962,7 +962,7 @@ exports[`Render should show query return no time series 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> From 6d874dd1f160f8e3124dec7ed892cf0f3e54c82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 14:42:29 +0100 Subject: [PATCH 051/144] Improved error handling --- public/app/core/copy/appNotification.ts | 26 ++++++++++++++----- .../features/dashboard/state/initDashboard.ts | 7 ++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index c34480d7aad..0062cd08fa6 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,3 +1,4 @@ +import _ from 'lodash'; import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; const defaultSuccessNotification: AppNotification = { @@ -31,12 +32,25 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti id: Date.now(), }); -export const createErrorNotification = (title: string, text?: string): AppNotification => ({ - ...defaultErrorNotification, - title: title, - text: text, - id: Date.now(), -}); +export const createErrorNotification = (title: string, text?: any): AppNotification => { + // Handling if text is an error object + if (text && !_.isString(text)) { + if (text.message) { + text = text.message; + } else if (text.data && text.data.message) { + text = text.data.message; + } else { + text = text.toString(); + } + } + + return { + ...defaultErrorNotification, + title: title, + text: text, + id: Date.now(), + }; +}; export const createWarningNotification = (title: string, text?: string): AppNotification => ({ ...defaultWarningNotification, diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index d529ca0b531..b8eed6c4e64 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -81,7 +81,6 @@ export function initDashboard({ try { switch (routeInfo) { - // handle old urls with no uid case DashboardRouteInfo.Home: { // load home dash dashDTO = await getBackendSrv().get('/api/dashboards/home'); @@ -130,6 +129,7 @@ export function initDashboard({ } } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + dispatch(notifyApp(createErrorNotification('Dashboard fetch failed', err))); console.log(err); return; } @@ -143,6 +143,7 @@ export function initDashboard({ dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); + dispatch(notifyApp(createErrorNotification('Dashboard model initializing failure', err))); console.log(err); return; } @@ -168,7 +169,7 @@ export function initDashboard({ try { await variableSrv.init(dashboard); } catch (err) { - dispatch(notifyApp(createErrorNotification('Templating init failed'))); + dispatch(notifyApp(createErrorNotification('Templating init failed', err))); console.log(err); } @@ -194,7 +195,7 @@ export function initDashboard({ keybindingSrv.setupDashboardBindings($scope, dashboard, onRemovePanel); } catch (err) { - dispatch(notifyApp(createErrorNotification('Dashboard init failed', err.toString()))); + dispatch(notifyApp(createErrorNotification('Dashboard init failed', err))); console.log(err); } From a624c9713aac9e1132099223e07e7b06ab0223ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 15:09:56 +0100 Subject: [PATCH 052/144] Removed unused controllers and services --- public/app/core/components/gf_page.ts | 40 ----- .../app/core/components/scroll/page_scroll.ts | 43 ----- public/app/core/core.ts | 4 - .../dashboard/containers/DashboardCtrl.ts | 150 ----------------- public/app/features/dashboard/index.ts | 2 - .../services/DashboardViewStateSrv.ts | 155 ------------------ public/app/partials/dashboard.html | 17 -- public/views/index-template.html | 2 +- 8 files changed, 1 insertion(+), 412 deletions(-) delete mode 100644 public/app/core/components/gf_page.ts delete mode 100644 public/app/core/components/scroll/page_scroll.ts delete mode 100644 public/app/features/dashboard/containers/DashboardCtrl.ts delete mode 100644 public/app/features/dashboard/services/DashboardViewStateSrv.ts delete mode 100644 public/app/partials/dashboard.html diff --git a/public/app/core/components/gf_page.ts b/public/app/core/components/gf_page.ts deleted file mode 100644 index 057a307f205..00000000000 --- a/public/app/core/components/gf_page.ts +++ /dev/null @@ -1,40 +0,0 @@ -import coreModule from 'app/core/core_module'; - -const template = ` -
- -
- - -
-
-
-
-`; - -export function gfPageDirective() { - return { - restrict: 'E', - template: template, - scope: { - model: '=', - }, - transclude: { - header: '?gfPageHeader', - body: 'gfPageBody', - }, - link: (scope, elem, attrs) => { - console.log(scope); - }, - }; -} - -coreModule.directive('gfPage', gfPageDirective); diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts deleted file mode 100644 index 2d6e27f8b22..00000000000 --- a/public/app/core/components/scroll/page_scroll.ts +++ /dev/null @@ -1,43 +0,0 @@ -import coreModule from 'app/core/core_module'; -import appEvents from 'app/core/app_events'; - -export function pageScrollbar() { - return { - restrict: 'A', - link: (scope, elem, attrs) => { - let lastPos = 0; - - appEvents.on( - 'dash-scroll', - evt => { - if (evt.restore) { - elem[0].scrollTop = lastPos; - return; - } - - lastPos = elem[0].scrollTop; - - if (evt.animate) { - elem.animate({ scrollTop: evt.pos }, 500); - } else { - elem[0].scrollTop = evt.pos; - } - }, - scope - ); - - scope.$on('$routeChangeSuccess', () => { - lastPos = 0; - elem[0].scrollTop = 0; - // Focus page to enable scrolling by keyboard - elem[0].focus({ preventScroll: true }); - }); - - elem[0].tabIndex = -1; - // Focus page to enable scrolling by keyboard - elem[0].focus({ preventScroll: true }); - }, - }; -} - -coreModule.directive('pageScrollbar', pageScrollbar); diff --git a/public/app/core/core.ts b/public/app/core/core.ts index fb38cefd435..1f289fc4b27 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -43,8 +43,6 @@ import { helpModal } from './components/help/help'; import { JsonExplorer } from './components/json_explorer/json_explorer'; import { NavModelSrv, NavModel } from './nav_model_srv'; import { geminiScrollbar } from './components/scroll/scroll'; -import { pageScrollbar } from './components/scroll/page_scroll'; -import { gfPageDirective } from './components/gf_page'; import { orgSwitcher } from './components/org_switcher'; import { profiler } from './profiler'; import { registerAngularDirectives } from './angular_wrappers'; @@ -79,8 +77,6 @@ export { NavModelSrv, NavModel, geminiScrollbar, - pageScrollbar, - gfPageDirective, orgSwitcher, manageDashboardsDirective, TimeSeries, diff --git a/public/app/features/dashboard/containers/DashboardCtrl.ts b/public/app/features/dashboard/containers/DashboardCtrl.ts deleted file mode 100644 index 0151f8f7331..00000000000 --- a/public/app/features/dashboard/containers/DashboardCtrl.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Utils -import config from 'app/core/config'; -import appEvents from 'app/core/app_events'; -import coreModule from 'app/core/core_module'; -import { removePanel } from 'app/features/dashboard/utils/panel'; - -// Services -import { AnnotationsSrv } from '../../annotations/annotations_srv'; - -// Types -import { DashboardModel } from '../state/DashboardModel'; - -export class DashboardCtrl { - dashboard: DashboardModel; - dashboardViewState: any; - loadedFallbackDashboard: boolean; - editTab: number; - - /** @ngInject */ - constructor( - private $scope, - private keybindingSrv, - private timeSrv, - private variableSrv, - private dashboardSrv, - private unsavedChangesSrv, - private dashboardViewStateSrv, - private annotationsSrv: AnnotationsSrv, - public playlistSrv - ) { - // temp hack due to way dashboards are loaded - // can't use controllerAs on route yet - $scope.ctrl = this; - } - - setupDashboard(data) { - try { - this.setupDashboardInternal(data); - } catch (err) { - this.onInitFailed(err, 'Dashboard init failed', true); - } - } - - setupDashboardInternal(data) { - const dashboard = this.dashboardSrv.create(data.dashboard, data.meta); - this.dashboardSrv.setCurrent(dashboard); - - // init services - this.timeSrv.init(dashboard); - this.annotationsSrv.init(dashboard); - - // template values service needs to initialize completely before - // the rest of the dashboard can load - this.variableSrv - .init(dashboard) - // template values failes are non fatal - .catch(this.onInitFailed.bind(this, 'Templating init failed', false)) - // continue - .finally(() => { - this.dashboard = dashboard; - this.dashboard.processRepeats(); - this.dashboard.updateSubmenuVisibility(); - this.dashboard.autoFitPanels(window.innerHeight); - - this.unsavedChangesSrv.init(dashboard, this.$scope); - - // TODO refactor ViewStateSrv - this.$scope.dashboard = dashboard; - this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope); - - this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); - this.setWindowTitleAndTheme(); - - appEvents.emit('dashboard-initialized', dashboard); - }) - .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); - } - - onInitFailed(msg, fatal, err) { - console.log(msg, err); - - if (err.data && err.data.message) { - err.message = err.data.message; - } else if (!err.message) { - err = { message: err.toString() }; - } - - this.$scope.appEvent('alert-error', [msg, err.message]); - - // protect against recursive fallbacks - if (fatal && !this.loadedFallbackDashboard) { - this.loadedFallbackDashboard = true; - this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } }); - } - } - - templateVariableUpdated() { - this.dashboard.processRepeats(); - } - - setWindowTitleAndTheme() { - window.document.title = config.windowTitlePrefix + this.dashboard.title; - } - - showJsonEditor(evt, options) { - const model = { - object: options.object, - updateHandler: options.updateHandler, - }; - - this.$scope.appEvent('show-dash-editor', { - src: 'public/app/partials/edit_json.html', - model: model, - }); - } - - getDashboard() { - return this.dashboard; - } - - getPanelContainer() { - return this; - } - - onRemovingPanel(evt, options) { - options = options || {}; - if (!options.panelId) { - return; - } - - const panelInfo = this.dashboard.getPanelInfoById(options.panelId); - removePanel(this.dashboard, panelInfo.panel, true); - } - - onDestroy() { - if (this.dashboard) { - this.dashboard.destroy(); - } - } - - init(dashboard) { - this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); - this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); - this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this)); - this.$scope.$on('$destroy', this.onDestroy.bind(this)); - this.setupDashboard(dashboard); - } -} - -coreModule.controller('DashboardCtrl', DashboardCtrl); diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index 9f2935660ef..d9a03b0aad6 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -1,8 +1,6 @@ -import './containers/DashboardCtrl'; import './dashgrid/DashboardGridDirective'; // Services -import './services/DashboardViewStateSrv'; import './services/UnsavedChangesSrv'; import './services/DashboardLoaderSrv'; import './services/DashboardSrv'; diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts deleted file mode 100644 index 7cb4c1de7ab..00000000000 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ /dev/null @@ -1,155 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import config from 'app/core/config'; -import appEvents from 'app/core/app_events'; -import { DashboardModel } from '../state/DashboardModel'; - -// represents the transient view state -// like fullscreen panel & edit -export class DashboardViewStateSrv { - state: any; - panelScopes: any; - $scope: any; - dashboard: DashboardModel; - fullscreenPanel: any; - oldTimeRange: any; - - /** @ngInject */ - constructor($scope, private $location, private $timeout) { - const self = this; - self.state = {}; - self.panelScopes = []; - self.$scope = $scope; - self.dashboard = $scope.dashboard; - - $scope.onAppEvent('$routeUpdate', () => { - // const urlState = self.getQueryStringState(); - // if (self.needsSync(urlState)) { - // self.update(urlState, true); - // } - }); - - $scope.onAppEvent('panel-change-view', (evt, payload) => { - // self.update(payload); - }); - - // this marks changes to location during this digest cycle as not to add history item - // don't want url changes like adding orgId to add browser history - // $location.replace(); - // this.update(this.getQueryStringState()); - } - - needsSync(urlState) { - return _.isEqual(this.state, urlState) === false; - } - - getQueryStringState() { - const state = this.$location.search(); - state.panelId = parseInt(state.panelId, 10) || null; - state.fullscreen = state.fullscreen ? true : null; - state.edit = state.edit === 'true' || state.edit === true || null; - state.editview = state.editview || null; - state.orgId = config.bootData.user.orgId; - return state; - } - - serializeToUrl() { - const urlState = _.clone(this.state); - urlState.fullscreen = this.state.fullscreen ? true : null; - urlState.edit = this.state.edit ? true : null; - return urlState; - } - - update(state, fromRouteUpdated?) { - // implement toggle logic - if (state.toggle) { - delete state.toggle; - if (this.state.fullscreen && state.fullscreen) { - if (this.state.edit === state.edit) { - state.fullscreen = !state.fullscreen; - } - } - } - - _.extend(this.state, state); - - if (!this.state.fullscreen) { - this.state.fullscreen = null; - this.state.edit = null; - // clear panel id unless in solo mode - if (!this.dashboard.meta.soloMode) { - this.state.panelId = null; - } - } - - if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) { - // Trying to render panel in fullscreen when it's in the collapsed row causes an issue. - // So in this case expand collapsed row first. - this.toggleCollapsedPanelRow(this.state.panelId); - } - - // if no edit state cleanup tab parm - if (!this.state.edit) { - delete this.state.tab; - } - - // do not update url params if we are here - // from routeUpdated event - if (fromRouteUpdated !== true) { - this.$location.search(this.serializeToUrl()); - } - } - - toggleCollapsedPanelRow(panelId) { - for (const panel of this.dashboard.panels) { - if (panel.collapsed) { - for (const rowPanel of panel.panels) { - if (rowPanel.id === panelId) { - this.dashboard.toggleRow(panel); - return; - } - } - } - } - } - - leaveFullscreen() { - const panel = this.fullscreenPanel; - - this.dashboard.setViewMode(panel, false, false); - - delete this.fullscreenPanel; - - this.$timeout(() => { - appEvents.emit('dash-scroll', { restore: true }); - - if (this.oldTimeRange !== this.dashboard.time) { - this.dashboard.startRefresh(); - } else { - this.dashboard.render(); - } - }); - } - - enterFullscreen(panel) { - const isEditing = this.state.edit && this.dashboard.meta.canEdit; - - this.oldTimeRange = this.dashboard.time; - this.fullscreenPanel = panel; - - // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode() - this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); - this.dashboard.setViewMode(panel, true, isEditing); - } -} - -/** @ngInject */ -export function dashboardViewStateSrv($location, $timeout) { - return { - create: $scope => { - return new DashboardViewStateSrv($scope, $location, $timeout); - }, - }; -} - -angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv); diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html deleted file mode 100644 index 32acdc435f2..00000000000 --- a/public/app/partials/dashboard.html +++ /dev/null @@ -1,17 +0,0 @@ -
- - -
- - - -
- - - - -
-
-
diff --git a/public/views/index-template.html b/public/views/index-template.html index 770ab74eccc..895b0e4ae19 100644 --- a/public/views/index-template.html +++ b/public/views/index-template.html @@ -192,7 +192,7 @@
-
+
From 49a597fcd0018dfd833da5796bf95800cbdd42c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 15:15:15 +0100 Subject: [PATCH 053/144] Moved remove panel logic to dashboard srv --- public/app/core/services/keybindingSrv.ts | 4 ++-- .../app/features/dashboard/services/DashboardSrv.ts | 13 ++++++++++--- .../app/features/dashboard/state/initDashboard.ts | 10 +--------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index dfacc483b8e..917d1801c0e 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -144,7 +144,7 @@ export class KeybindingSrv { this.$location.search(search); } - setupDashboardBindings(scope, dashboard, onRemovePanel) { + setupDashboardBindings(scope, dashboard) { this.bind('mod+o', () => { dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; appEvents.emit('graph-hover-clear'); @@ -212,7 +212,7 @@ export class KeybindingSrv { // delete panel this.bind('p r', () => { if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - onRemovePanel(dashboard.meta.focusPanelId); + appEvents.emit('remove-panel', dashboard.meta.focusPanelId); dashboard.meta.focusPanelId = 0; } }); diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 38fadfecdc1..88eb58ad345 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -2,28 +2,35 @@ import coreModule from 'app/core/core_module'; import { appEvents } from 'app/core/app_events'; import locationUtil from 'app/core/utils/location_util'; import { DashboardModel } from '../state/DashboardModel'; +import { removePanel } from '../utils/panel'; export class DashboardSrv { - dash: any; + dash: DashboardModel; /** @ngInject */ constructor(private backendSrv, private $rootScope, private $location) { appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); appEvents.on('panel-change-view', this.onPanelChangeView); + appEvents.on('remove-panel', this.onRemovePanel); } create(dashboard, meta) { return new DashboardModel(dashboard, meta); } - setCurrent(dashboard) { + setCurrent(dashboard: DashboardModel) { this.dash = dashboard; } - getCurrent() { + getCurrent(): DashboardModel { return this.dash; } + onRemovePanel = (panelId: number) => { + const dashboard = this.getCurrent(); + removePanel(dashboard, dashboard.getPanelById(panelId), true); + }; + onPanelChangeView = (options) => { const urlParams = this.$location.search(); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index b8eed6c4e64..c774c4926f4 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -13,7 +13,6 @@ import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; import { setDashboardLoadingState, ThunkResult, setDashboardModel, setDashboardLoadingSlow } from './actions'; -import { removePanel } from '../utils/panel'; // Types import { DashboardLoadingState, DashboardRouteInfo } from 'app/types'; @@ -185,15 +184,8 @@ export function initDashboard({ // init unsaved changes tracking unsavedChangesSrv.init(dashboard, $scope); + keybindingSrv.setupDashboardBindings($scope, dashboard); - // dashboard keybindings should not live in core, this needs a bigger refactoring - // So declaring this here so it can depend on the removePanel util function - // Long term onRemovePanel should be handled via react prop callback - const onRemovePanel = (panelId: number) => { - removePanel(dashboard, dashboard.getPanelById(panelId), true); - }; - - keybindingSrv.setupDashboardBindings($scope, dashboard, onRemovePanel); } catch (err) { dispatch(notifyApp(createErrorNotification('Dashboard init failed', err))); console.log(err); From e2ffaef88a0a67a9b155c0ce115bb3654d6af9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 5 Feb 2019 15:25:19 +0100 Subject: [PATCH 054/144] Fixed so that we close angular TimePicker when user clicks outside the dropdown --- public/app/routes/GrafanaCtrl.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 70bdf49e5e4..a860ba87e6b 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -280,6 +280,24 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop if (popover.length > 0 && target.parents('.graph-legend').length === 0) { popover.hide(); } + + // hide time picker + const timePickerDropDownIsOpen = elem.find('.gf-timepicker-dropdown').length > 0; + const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0; + const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0; + const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0; + const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0; + if ( + timePickerDropDownIsOpen && + !targetIsInTimePickerNav && + !targetIsInTimePickerDropDown && + !targetIsDatePickerRowBtn && + !targetIsDatePickerHeaderBtn + ) { + scope.$apply(() => { + scope.appEvent('closeTimepicker'); + }); + } }); }, }; From 0302c7afa7446b28baf02dec1b2c43e5e7f8d3d6 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 5 Feb 2019 15:28:03 +0100 Subject: [PATCH 055/144] stackdriver: add some more typings --- .../stackdriver/components/QueryEditor.tsx | 8 +++--- .../datasource/stackdriver/datasource.ts | 7 ++--- .../datasource/stackdriver/query_ctrl.ts | 4 +-- .../plugins/datasource/stackdriver/types.ts | 26 +++++++++++-------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx index 94521041416..c3bd9212b21 100644 --- a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx @@ -10,21 +10,21 @@ import { Alignments } from './Alignments'; import { AlignmentPeriods } from './AlignmentPeriods'; import { AliasBy } from './AliasBy'; import { Help } from './Help'; -import { Target, MetricDescriptor } from '../types'; +import { StackdriverQuery, MetricDescriptor } from '../types'; import { getAlignmentPickerData } from '../functions'; import StackdriverDatasource from '../datasource'; import { SelectOptionItem } from '@grafana/ui'; export interface Props { - onQueryChange: (target: Target) => void; + onQueryChange: (target: StackdriverQuery) => void; onExecuteQuery: () => void; - target: Target; + target: StackdriverQuery; events: any; datasource: StackdriverDatasource; templateSrv: TemplateSrv; } -interface State extends Target { +interface State extends StackdriverQuery { alignOptions: SelectOptionItem[]; lastQuery: string; lastQueryError: string; diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 025955105a7..15c6350c8a0 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -2,9 +2,10 @@ import { stackdriverUnitMappings } from './constants'; import appEvents from 'app/core/app_events'; import _ from 'lodash'; import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; -import { MetricDescriptor } from './types'; +import { StackdriverQuery, MetricDescriptor } from './types'; +import { DataSourceApi, DataQueryOptions } from '@grafana/ui/src/types'; -export default class StackdriverDatasource { +export default class StackdriverDatasource implements DataSourceApi { id: number; url: string; baseUrl: string; @@ -103,7 +104,7 @@ export default class StackdriverDatasource { return unit; } - async query(options) { + async query(options: DataQueryOptions) { const result = []; const data = await this.getTimeSeries(options); if (data.results) { diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts index c6a8a4d9782..3a2d0bb970a 100644 --- a/public/app/plugins/datasource/stackdriver/query_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { QueryCtrl } from 'app/plugins/sdk'; -import { Target } from './types'; +import { StackdriverQuery } from './types'; import { TemplateSrv } from 'app/features/templating/template_srv'; export class StackdriverQueryCtrl extends QueryCtrl { @@ -16,7 +16,7 @@ export class StackdriverQueryCtrl extends QueryCtrl { this.onExecuteQuery = this.onExecuteQuery.bind(this); } - onQueryChange(target: Target) { + onQueryChange(target: StackdriverQuery) { Object.assign(this.target, target); } diff --git a/public/app/plugins/datasource/stackdriver/types.ts b/public/app/plugins/datasource/stackdriver/types.ts index 29b12b4289d..83909bbafce 100644 --- a/public/app/plugins/datasource/stackdriver/types.ts +++ b/public/app/plugins/datasource/stackdriver/types.ts @@ -1,3 +1,5 @@ +import { DataQuery } from '@grafana/ui/src/types'; + export enum MetricFindQueryTypes { Services = 'services', MetricTypes = 'metricTypes', @@ -20,20 +22,22 @@ export interface VariableQueryData { services: Array<{ value: string; name: string }>; } -export interface Target { - defaultProject: string; - unit: string; +export interface StackdriverQuery extends DataQuery { + defaultProject?: string; + unit?: string; metricType: string; - service: string; + service?: string; refId: string; crossSeriesReducer: string; - alignmentPeriod: string; - perSeriesAligner: string; - groupBys: string[]; - filters: string[]; - aliasBy: string; - metricKind: string; - valueType: string; + alignmentPeriod?: string; + perSeriesAligner?: string; + groupBys?: string[]; + filters?: string[]; + aliasBy?: string; + metricKind?: string; + valueType?: string; + datasourceId: number; + view: string; } export interface AnnotationTarget { From a344091d82e74a8054fed8279ca271a41278980e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 5 Feb 2019 15:29:19 +0100 Subject: [PATCH 056/144] Optimized so we only do checks when dropdown is opened --- public/app/routes/GrafanaCtrl.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index a860ba87e6b..c6945f26d08 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -283,17 +283,21 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop // hide time picker const timePickerDropDownIsOpen = elem.find('.gf-timepicker-dropdown').length > 0; - const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0; - const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0; - const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0; - const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0; - if ( - timePickerDropDownIsOpen && - !targetIsInTimePickerNav && - !targetIsInTimePickerDropDown && - !targetIsDatePickerRowBtn && - !targetIsDatePickerHeaderBtn - ) { + if (timePickerDropDownIsOpen) { + const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0; + const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0; + const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0; + const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0; + + if ( + targetIsInTimePickerNav || + targetIsInTimePickerDropDown || + targetIsDatePickerRowBtn || + targetIsDatePickerHeaderBtn + ) { + return; + } + scope.$apply(() => { scope.appEvent('closeTimepicker'); }); From e42b670f5c29df535d4cb5477077011ab5ec9842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 5 Feb 2019 15:41:00 +0100 Subject: [PATCH 057/144] Closing timepicker when clicking outside the picker --- public/app/features/explore/ExploreToolbar.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 35f06d11c81..4d9620e311e 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -8,6 +8,7 @@ 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'; +import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; enum IconSide { left = 'left', @@ -79,6 +80,10 @@ export class UnConnectedExploreToolbar extends PureComponent { this.props.runQuery(this.props.exploreId); }; + onCloseTimePicker = () => { + this.props.timepickerRef.current.setState({ isOpen: false }); + }; + render() { const { datasourceMissing, @@ -137,7 +142,9 @@ export class UnConnectedExploreToolbar extends PureComponent {
) : null}
- + + +
- {this.renderOptionLink('queries', 'Add query', this.onCreateNewPanel)} - {this.renderOptionLink('visualization', 'Choose Panel type', () => + {this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Visualization', () => this.onCreateNewPanel('visualization') )}
-
- Convert to row -
+ {copiedPanelPlugins.length === 1 && ( -
this.onPasteCopiedPanel(copiedPanelPlugins[0])} > Paste copied panel -
+ )}
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index ab6ff8556d8..288b2e7a410 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -14,6 +14,9 @@ align-items: center; width: 100%; cursor: move; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; .gicon { font-size: 30px; @@ -26,9 +29,15 @@ } } +.add-panel-widget__title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin-right: $spacer*2; +} + .add-panel-widget__link { margin: 0 8px; - width: 150px; + width: 154px; } .add-panel-widget__icon { @@ -54,6 +63,8 @@ .add-panel-widget__create { display: inherit; margin-bottom: 24px; + // this is to have the big button appear centered + margin-top: 55px; } .add-panel-widget__actions { @@ -61,7 +72,6 @@ } .add-panel-widget__action { - cursor: pointer; margin: 0 4px; } diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap index 585f45210af..00faf48d8df 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -13,6 +13,11 @@ exports[`Render should render component 1`] = ` + + New Panel +
- Add query + Add Query
@@ -60,7 +65,7 @@ exports[`Render should render component 1`] = ` />
- Choose Panel type + Choose Visualization
@@ -68,12 +73,12 @@ exports[`Render should render component 1`] = `
-
Convert to row -
+
diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx index d7aafb89e55..bfdc13bc8f2 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { QueriesTab } from './QueriesTab'; -import { VisualizationTab } from './VisualizationTab'; +import VisualizationTab from './VisualizationTab'; import { GeneralTab } from './GeneralTab'; import { AlertTab } from '../../alerting/AlertTab'; @@ -38,7 +38,7 @@ export class PanelEditor extends PureComponent { onChangeTab = (tab: PanelEditorTab) => { store.dispatch( updateLocation({ - query: { tab: tab.id }, + query: { tab: tab.id, openVizPicker: null }, partial: true, }) ); diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index 1ca290d4051..94a403c11bf 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -3,7 +3,9 @@ import React, { PureComponent } from 'react'; // Utils & Services import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; -import { store } from 'app/store/store'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { StoreState } from 'app/types'; +import { updateLocation } from 'app/core/actions'; // Components import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; @@ -22,6 +24,8 @@ interface Props { plugin: PanelPlugin; angularPanel?: AngularComponent; onTypeChanged: (newType: PanelPlugin) => void; + updateLocation: typeof updateLocation; + urlOpenVizPicker: boolean; } interface State { @@ -39,7 +43,7 @@ export class VisualizationTab extends PureComponent { super(props); this.state = { - isVizPickerOpen: store.getState().location.query.isVizPickerOpen === true, + isVizPickerOpen: this.props.urlOpenVizPicker, searchQuery: '', scrollTop: 0, }; @@ -150,6 +154,10 @@ export class VisualizationTab extends PureComponent { }; onCloseVizPicker = () => { + if (this.props.urlOpenVizPicker) { + this.props.updateLocation({ query: { openVizPicker: null }, partial: true }); + } + this.setState({ isVizPickerOpen: false }); }; @@ -237,3 +245,13 @@ export class VisualizationTab extends PureComponent { ); } } + +const mapStateToProps = (state: StoreState) => ({ + urlOpenVizPicker: !!state.location.query.openVizPicker +}); + +const mapDispatchToProps = { + updateLocation +}; + +export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps); diff --git a/public/img/icons_dark_theme/icon_advanced.svg b/public/img/icons_dark_theme/icon_advanced.svg index 5fd18a86dd5..dea3ddff685 100644 --- a/public/img/icons_dark_theme/icon_advanced.svg +++ b/public/img/icons_dark_theme/icon_advanced.svg @@ -4,7 +4,7 @@ diff --git a/public/img/icons_dark_theme/icon_advanced_active.svg b/public/img/icons_dark_theme/icon_advanced_active.svg index 80672a2595b..1227ddc868c 100644 --- a/public/img/icons_dark_theme/icon_advanced_active.svg +++ b/public/img/icons_dark_theme/icon_advanced_active.svg @@ -5,7 +5,7 @@ width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve"> - From 4d2cff41ff15fb801960f8a5a5cd17a7fc8f5136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 19:38:51 +0100 Subject: [PATCH 068/144] Minor code simplification --- public/app/features/dashboard/containers/DashboardPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1bd5218fd60..a620ac848b4 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -247,7 +247,7 @@ export class DashboardPage extends PureComponent { />
- {dashboard && editview && } + {editview && }
{dashboard.meta.submenuEnabled && } From 3e129dffa0490d4ef33f18786031bd95a2e33845 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 19:40:46 +0100 Subject: [PATCH 069/144] changelog: add notes about closing #8207 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 286b013a30f..48551ae13db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Minor * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +* **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) * **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) From 749320002b02d15c9d9cd287d87d285bba3791dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20J=C3=B8rgensen?= Date: Tue, 5 Feb 2019 20:57:51 +0100 Subject: [PATCH 070/144] Added ServerlessDatabaseCapacity metric to list of AWS RDS metrics. --- pkg/tsdb/cloudwatch/metric_find_query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index f898a65f911..44e0333335c 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -99,7 +99,7 @@ func init() { "AWS/NetworkELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"}, "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, "AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"}, From 1d1b617cee3cfedeb03586e0db00f5219d187761 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:08:55 +0100 Subject: [PATCH 071/144] remove unused code --- pkg/api/common_test.go | 87 ++++-------------------------------------- 1 file changed, 8 insertions(+), 79 deletions(-) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 853a04b5c11..3f3a50aae69 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -95,14 +94,13 @@ 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 - userAuthTokenService *fakeUserAuthTokenService + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string } func (sc *scenarioContext) exec() { @@ -124,76 +122,7 @@ func setupScenarioContext(url string) *scenarioContext { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.userAuthTokenService = newFakeUserAuthTokenService() - sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService)) + sc.m.Use(middleware.GetContextHandler(nil)) return sc } - -type fakeUserToken interface { - auth.UserToken - SetToken(token string) -} - -type userTokenImpl struct { - userId int64 - token string -} - -func (ut *userTokenImpl) GetUserId() int64 { - return ut.userId -} - -func (ut *userTokenImpl) GetToken() string { - return ut.token -} - -func (ut *userTokenImpl) SetToken(token string) { - ut.token = token -} - -type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) - tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) - revokeTokenProvider func(token auth.UserToken) error -} - -func newFakeUserAuthTokenService() *fakeUserAuthTokenService { - return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 0, - token: "", - }, nil - }, - tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { - return false, nil - }, - lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 0, - token: "", - }, nil - }, - revokeTokenProvider: func(token auth.UserToken) error { - return nil - }, - } -} - -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { - return s.createTokenProvider(userId, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { - return s.lookupTokenProvider(unhashedToken) -} - -func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { - return s.tryRotateTokenProvider(token, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { - return s.revokeTokenProvider(token) -} From 3c2fd02bc00ac631b1429533e898b848ed22dc47 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:09:55 +0100 Subject: [PATCH 072/144] refactor login/auth token configuration settings remove login section and reuse existing sections security and auth --- conf/defaults.ini | 41 +++++++++++---------- conf/sample.ini | 43 +++++++++++----------- pkg/setting/setting.go | 82 ++++++++++++++++++++++++------------------ 3 files changed, 89 insertions(+), 77 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index d021d342fbf..c65cb93d426 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -106,25 +106,6 @@ 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 - -# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none" -cookie_samesite = lax - -# 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" @@ -206,8 +187,11 @@ 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 +# set to true if you host Grafana behind HTTPS. default is false. +cookie_secure = false + +# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none" +cookie_samesite = lax #################################### Snapshots ########################### [snapshots] @@ -260,6 +244,21 @@ external_manage_info = viewers_can_edit = false [auth] +# Login cookie name +login_cookie_name = grafana_session + +# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days. +login_maximum_inactive_lifetime_days = 7 + +# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +login_maximum_lifetime_days = 30 + +# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. +token_rotation_interval_minutes = 10 + +# How often should expired auth tokens be deleted from the database. The default is 7 days. +expired_tokens_cleanup_interval_days = 7 + # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false diff --git a/conf/sample.ini b/conf/sample.ini index ef677320686..39feb31441e 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -102,25 +102,6 @@ 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 - -# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none" -;cookie_samesite = lax - -# 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" @@ -193,8 +174,11 @@ 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 +# set to true if you host Grafana behind HTTPS. default is false. +;cookie_secure = false + +# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none" +;cookie_samesite = lax #################################### Snapshots ########################### [snapshots] @@ -240,6 +224,21 @@ log_queries = ;viewers_can_edit = false [auth] +# Login cookie name +;login_cookie_name = grafana_session + +# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days, +;login_maximum_inactive_lifetime_days = 7 + +# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +;login_maximum_lifetime_days = 30 + +# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. +;token_rotation_interval_minutes = 10 + +# How often should expired auth tokens be deleted from the database. The default is 7 days. +;expired_tokens_cleanup_interval_days = 7 + # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false @@ -253,7 +252,7 @@ log_queries = # This setting is ignored if multiple OAuth providers are configured. ;oauth_auto_login = false -#################################### Anonymous Auth ########################## +#################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access ;enabled = false diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c3c78d10fec..9f7d03bb472 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -89,6 +89,8 @@ var ( EmailCodeValidMinutes int DataProxyWhiteList map[string]bool DisableBruteForceLoginProtection bool + CookieSecure bool + CookieSameSite http.SameSite // Snapshots ExternalSnapshotUrl string @@ -118,8 +120,10 @@ var ( ViewersCanEdit bool // Http auth - AdminUser string - AdminPassword string + AdminUser string + AdminPassword string + LoginCookieName string + LoginMaxLifetimeDays int AnonymousEnabled bool AnonymousOrgName string @@ -215,7 +219,11 @@ type Cfg struct { RendererLimit int RendererLimitAlerting int + // Security DisableBruteForceLoginProtection bool + CookieSecure bool + CookieSameSite http.SameSite + TempDataLifetime time.Duration MetricsEndpointEnabled bool MetricsEndpointBasicAuthUsername string @@ -224,13 +232,12 @@ type Cfg struct { DisableSanitizeHtml bool EnterpriseLicensePath string - LoginCookieName string - LoginCookieMaxDays int - LoginCookieRotation int - LoginDeleteExpiredTokensAfterDays int - LoginCookieSameSite http.SameSite - - SecurityHTTPSCookies bool + // Auth + LoginCookieName string + LoginMaxInactiveLifetimeDays int + LoginMaxLifetimeDays int + TokenRotationIntervalMinutes int + ExpiredTokensCleanupIntervalDays int } type CommandLineArgs struct { @@ -554,30 +561,6 @@ 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) - - samesiteString := login.Key("cookie_samesite").MustString("lax") - validSameSiteValues := map[string]http.SameSite{ - "lax": http.SameSiteLaxMode, - "strict": http.SameSiteStrictMode, - "none": http.SameSiteDefaultMode, - } - - if samesite, ok := validSameSiteValues[samesiteString]; ok { - cfg.LoginCookieSameSite = samesite - } else { - cfg.LoginCookieSameSite = http.SameSiteLaxMode - } - - 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) @@ -621,9 +604,26 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { SecretKey = security.Key("secret_key").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 + CookieSecure = security.Key("cookie_secure").MustBool(false) + cfg.CookieSecure = CookieSecure + + samesiteString := security.Key("cookie_samesite").MustString("lax") + validSameSiteValues := map[string]http.SameSite{ + "lax": http.SameSiteLaxMode, + "strict": http.SameSiteStrictMode, + "none": http.SameSiteDefaultMode, + } + + if samesite, ok := validSameSiteValues[samesiteString]; ok { + CookieSameSite = samesite + cfg.CookieSameSite = CookieSameSite + } else { + CookieSameSite = http.SameSiteLaxMode + cfg.CookieSameSite = CookieSameSite + } + // read snapshots settings snapshots := iniFile.Section("snapshots") ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String() @@ -661,6 +661,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // auth auth := iniFile.Section("auth") + + LoginCookieName = auth.Key("login_cookie_name").MustString("grafana_session") + cfg.LoginCookieName = LoginCookieName + cfg.LoginMaxInactiveLifetimeDays = auth.Key("login_maximum_inactive_lifetime_days").MustInt(7) + + LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30) + cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays + + cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10) + if cfg.TokenRotationIntervalMinutes < 2 { + cfg.TokenRotationIntervalMinutes = 2 + } + cfg.ExpiredTokensCleanupIntervalDays = auth.Key("expired_tokens_cleanup_interval_days").MustInt(7) + DisableLoginForm = auth.Key("disable_login_form").MustBool(false) DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false) From 80d0943d9d1c0fb20ceb5236dad7ee672b6dc522 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:10:56 +0100 Subject: [PATCH 073/144] document login, short-lived tokens and secure cookie configurations --- docs/sources/auth/overview.md | 32 ++++++++++++++++++++++ docs/sources/installation/configuration.md | 8 ++++++ 2 files changed, 40 insertions(+) diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index 0480ee88adc..e3d4c08ca5d 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -36,6 +36,38 @@ Grafana of course has a built in user authentication system with password authen disable authentication by enabling anonymous access. You can also hide login form and only allow login through an auth provider (listed above). There is also options for allowing self sign up. +### Login and short-lived tokens + +> The followung applies when using Grafana's built in user authentication, LDAP (without Auth proxy) or OAuth integration. + +Grafana are using short-lived tokens as a mechanism for verifying authenticated users. +These short-lived tokens are rotated each `token_rotation_interval_minutes` for an active authenticated user. + +An active authenticated user that gets it token rotated will extend the `login_maximum_inactive_lifetime_days` time from "now" that Grafana will remember the user. +This means that a user can close its browser and come back before `now + login_maximum_inactive_lifetime_days` and still being authenticated. + This is true as long as the time since user login is less than `login_maximum_lifetime_days`. + +Example: + +```bash +[auth] + +# Login cookie name +login_cookie_name = grafana_session + +# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days. +login_maximum_inactive_lifetime_days = 7 + +# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +login_maximum_lifetime_days = 30 + +# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. +token_rotation_interval_minutes = 10 + +# How often should expired auth tokens be deleted from the database. The default is 7 days. +expired_tokens_cleanup_interval_days = 7 +``` + ### Anonymous authentication You can make Grafana accessible without any login required by enabling anonymous access in the configuration file. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 46bab83654e..b4b53d7557b 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -287,6 +287,14 @@ Default is `false`. Define a white list of allowed ips/domains to use in data sources. Format: `ip_or_domain:port` separated by spaces +### cookie_secure + +Set to `true` if you host Grafana behind HTTPS. Default is `false`. + +### cookie_samesite + +Sets the `SameSite` cookie attribute and prevents the browser from sending this cookie along with cross-site requests. The main goal is mitigate the risk of cross-origin information leakage. It also provides some protection against cross-site request forgery attacks (CSRF), [read more here](https://www.owasp.org/index.php/SameSite). Valid values are `lax`, `strict` and `none`. Default is `lax`. +
## [users] From 0915f931ae6cff86ad04d3e531fd969b802d0b4d Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:12:30 +0100 Subject: [PATCH 074/144] change configuration settings in auth package --- pkg/services/auth/authtoken/auth_token.go | 4 ++-- pkg/services/auth/authtoken/auth_token_test.go | 8 ++++---- pkg/services/auth/authtoken/session_cleanup.go | 4 ++-- pkg/services/auth/authtoken/session_cleanup_test.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/services/auth/authtoken/auth_token.go b/pkg/services/auth/authtoken/auth_token.go index 4e4bd375501..1fdad4dbea5 100644 --- a/pkg/services/auth/authtoken/auth_token.go +++ b/pkg/services/auth/authtoken/auth_token.go @@ -81,7 +81,7 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (auth.UserT s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) } - expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() + expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginMaxInactiveLifetimeDays) * time.Second).Unix() var model userAuthToken exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&model) @@ -148,7 +148,7 @@ func (s *UserAuthTokenServiceImpl) TryRotateToken(token auth.UserToken, clientIP needsRotation := false rotatedAt := time.Unix(model.RotatedAt, 0) if model.AuthTokenSeen { - needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute)) + needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute)) } else { needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime)) } diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/authtoken/auth_token_test.go index 7809e235f5c..51d361a9f4f 100644 --- a/pkg/services/auth/authtoken/auth_token_test.go +++ b/pkg/services/auth/authtoken/auth_token_test.go @@ -341,10 +341,10 @@ func createTestContext(t *testing.T) *testContext { tokenService := &UserAuthTokenServiceImpl{ SQLStore: sqlstore, Cfg: &setting.Cfg{ - LoginCookieName: "grafana_session", - LoginCookieMaxDays: 7, - LoginDeleteExpiredTokensAfterDays: 30, - LoginCookieRotation: 10, + LoginMaxInactiveLifetimeDays: 7, + LoginMaxLifetimeDays: 30, + TokenRotationIntervalMinutes: 10, + ExpiredTokensCleanupIntervalDays: 1, }, log: log.New("test-logger"), } diff --git a/pkg/services/auth/authtoken/session_cleanup.go b/pkg/services/auth/authtoken/session_cleanup.go index cd2b766d6c0..ecee82767e4 100644 --- a/pkg/services/auth/authtoken/session_cleanup.go +++ b/pkg/services/auth/authtoken/session_cleanup.go @@ -7,12 +7,12 @@ import ( func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { ticker := time.NewTicker(time.Hour * 12) - deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays) + deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalDays) for { select { case <-ticker.C: - srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() { + srv.ServerLockService.LockAndExecute(ctx, "delete expired auth tokens", time.Hour*12, func() { srv.deleteOldSession(deleteSessionAfter) }) diff --git a/pkg/services/auth/authtoken/session_cleanup_test.go b/pkg/services/auth/authtoken/session_cleanup_test.go index 101a279c374..bca1aa824eb 100644 --- a/pkg/services/auth/authtoken/session_cleanup_test.go +++ b/pkg/services/auth/authtoken/session_cleanup_test.go @@ -14,7 +14,7 @@ func TestUserAuthTokenCleanup(t *testing.T) { ctx := createTestContext(t) insertToken := func(token string, prev string, rotatedAt int64) { - ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""} + ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: rotatedAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""} _, err := ctx.sqlstore.NewSession().Insert(&ut) So(err, ShouldBeNil) } From 871c84d195417e51839db1f1fb33e47e196e18ef Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:14:23 +0100 Subject: [PATCH 075/144] changes needed for api/middleware due to configuration settings --- pkg/api/login.go | 5 +++-- pkg/api/login_oauth.go | 3 ++- pkg/middleware/middleware.go | 24 +++++++++++++++--------- pkg/middleware/middleware_test.go | 16 ++++++++++++---- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/pkg/api/login.go b/pkg/api/login.go index d25e83d34e8..def24f983c1 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -137,7 +137,7 @@ func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { hs.log.Error("failed to create auth token", "error", err) } - middleware.WriteSessionCookie(c, userToken.GetToken(), middleware.OneYearInSeconds) + middleware.WriteSessionCookie(c, userToken.GetToken(), hs.Cfg.LoginMaxLifetimeDays) } func (hs *HTTPServer) Logout(c *m.ReqContext) { @@ -185,7 +185,8 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string Value: hex.EncodeToString(encryptedError), HttpOnly: true, Path: setting.AppSubUrl + "/", - Secure: hs.Cfg.SecurityHTTPSCookies, + Secure: hs.Cfg.CookieSecure, + SameSite: hs.Cfg.CookieSameSite, }) return nil diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 4160d48733e..87a8ecc876f 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -214,7 +214,8 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri Value: value, HttpOnly: true, Path: setting.AppSubUrl + "/", - Secure: hs.Cfg.SecurityHTTPSCookies, + Secure: hs.Cfg.CookieSecure, + SameSite: hs.Cfg.CookieSameSite, }) } diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 6cf29340b82..9a3e5e1e01c 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -4,6 +4,7 @@ import ( "net/http" "net/url" "strconv" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" @@ -168,11 +169,8 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } -const cookieName = "grafana_session" -const OneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often. - func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx *m.ReqContext, orgID int64) bool { - rawToken := ctx.GetCookie(cookieName) + rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false } @@ -200,26 +198,34 @@ func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx * } if rotated { - WriteSessionCookie(ctx, token.GetToken(), OneYearInSeconds) + WriteSessionCookie(ctx, token.GetToken(), setting.LoginMaxLifetimeDays) } return true } -func WriteSessionCookie(ctx *m.ReqContext, value string, maxAge int) { +func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) { if setting.Env == setting.DEV { ctx.Logger.Info("new token", "unhashed token", value) } + var maxAge int + if maxLifetimeDays <= 0 { + maxAge = -1 + } else { + maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + time.Hour + maxAge = int(maxAgeHours.Seconds()) + } + ctx.Resp.Header().Del("Set-Cookie") cookie := http.Cookie{ - Name: cookieName, + Name: setting.LoginCookieName, Value: url.QueryEscape(value), HttpOnly: true, Path: setting.AppSubUrl + "/", - Secure: false, // TODO: use setting SecurityHTTPSCookies + Secure: setting.CookieSecure, MaxAge: maxAge, - SameSite: http.SameSiteLaxMode, // TODO: use setting LoginCookieSameSite + SameSite: setting.CookieSameSite, } http.SetCookie(ctx.Resp, &cookie) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 4e10ee39201..fdcc56da3bf 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "path/filepath" "testing" + "time" msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" @@ -197,13 +198,17 @@ func TestMiddlewareContext(t *testing.T) { return true, nil } + maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + maxAge := (maxAgeHours + time.Hour).Seconds() + expectedCookie := &http.Cookie{ - Name: cookieName, + Name: setting.LoginCookieName, Value: "rotated", Path: setting.AppSubUrl + "/", HttpOnly: true, - MaxAge: OneYearInSeconds, - SameSite: http.SameSiteLaxMode, + MaxAge: int(maxAge), + Secure: setting.CookieSecure, + SameSite: setting.CookieSameSite, } sc.fakeReq("GET", "/").exec() @@ -545,6 +550,9 @@ func middlewareScenario(desc string, fn scenarioFunc) { Convey(desc, func() { defer bus.ClearBusHandlers() + setting.LoginCookieName = "grafana_session" + setting.LoginMaxLifetimeDays = 30 + sc := &scenarioContext{} viewsPath, _ := filepath.Abs("../../public/views") @@ -655,7 +663,7 @@ func (sc *scenarioContext) exec() { if sc.tokenSessionCookie != "" { sc.req.AddCookie(&http.Cookie{ - Name: cookieName, + Name: setting.LoginCookieName, Value: sc.tokenSessionCookie, }) } From 948350659094f140806c829a52b23eac172ec400 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 21:20:11 +0100 Subject: [PATCH 076/144] auth token clean up job now runs on schedule and deletes all expired tokens delete tokens having created_at <= LoginMaxLifetimeDays or rotated_at <= LoginMaxInactiveLifetimeDays --- .../auth/authtoken/session_cleanup.go | 34 ++++++++--- .../auth/authtoken/session_cleanup_test.go | 56 +++++++++++++++---- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/pkg/services/auth/authtoken/session_cleanup.go b/pkg/services/auth/authtoken/session_cleanup.go index ecee82767e4..2b8dfb7b4e2 100644 --- a/pkg/services/auth/authtoken/session_cleanup.go +++ b/pkg/services/auth/authtoken/session_cleanup.go @@ -6,14 +6,23 @@ import ( ) func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { - ticker := time.NewTicker(time.Hour * 12) - deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalDays) + if srv.Cfg.ExpiredTokensCleanupIntervalDays <= 0 { + srv.log.Debug("cleanup of expired auth tokens are disabled") + return nil + } + + jobInterval := time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalDays) * 24 * time.Hour + srv.log.Debug("cleanup of expired auth tokens are enabled", "intervalDays", srv.Cfg.ExpiredTokensCleanupIntervalDays) + + ticker := time.NewTicker(jobInterval) + maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour + maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour for { select { case <-ticker.C: - srv.ServerLockService.LockAndExecute(ctx, "delete expired auth tokens", time.Hour*12, func() { - srv.deleteOldSession(deleteSessionAfter) + srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { + srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime) }) case <-ctx.Done(): @@ -22,17 +31,24 @@ func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { } } -func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) { - sql := `DELETE from user_auth_token WHERE rotated_at < ?` +func (srv *UserAuthTokenServiceImpl) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) { + createdBefore := getTime().Add(-maxLifetime) + rotatedBefore := getTime().Add(-maxInactiveLifetime) - deleteBefore := getTime().Add(-deleteSessionAfter) - res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix()) + srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore) + + sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?` + res, err := srv.SQLStore.NewSession().Exec(sql, createdBefore.Unix(), rotatedBefore.Unix()) if err != nil { return 0, err } affected, err := res.RowsAffected() - srv.log.Info("deleted old sessions", "count", affected) + if err != nil { + srv.log.Error("failed to cleanup expired auth tokens", "error", err) + return 0, nil + } + srv.log.Info("cleanup of expired auth tokens done", "count", affected) return affected, err } diff --git a/pkg/services/auth/authtoken/session_cleanup_test.go b/pkg/services/auth/authtoken/session_cleanup_test.go index bca1aa824eb..7b611b3263c 100644 --- a/pkg/services/auth/authtoken/session_cleanup_test.go +++ b/pkg/services/auth/authtoken/session_cleanup_test.go @@ -12,25 +12,57 @@ func TestUserAuthTokenCleanup(t *testing.T) { Convey("Test user auth token cleanup", t, func() { ctx := createTestContext(t) + ctx.tokenService.Cfg.LoginMaxInactiveLifetimeDays = 7 + ctx.tokenService.Cfg.LoginMaxLifetimeDays = 30 - insertToken := func(token string, prev string, rotatedAt int64) { - ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: rotatedAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""} + insertToken := func(token string, prev string, createdAt, rotatedAt int64) { + ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: createdAt, 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)) + t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC) + getTime = func() time.Time { + return t } - // 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()) - } + Convey("should delete tokens where token rotation age is older than or equal 7 days", func() { + from := t.Add(-7 * 24 * time.Hour) - affected, err := ctx.tokenService.deleteOldSession(time.Hour) - So(err, ShouldBeNil) - So(affected, ShouldEqual, 3) + // 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), from.Unix(), from.Unix()) + } + + // insert three active tokens that should not be deleted + for i := 0; i < 3; i++ { + from = from.Add(time.Second) + insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), from.Unix()) + } + + affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour) + So(err, ShouldBeNil) + So(affected, ShouldEqual, 3) + }) + + Convey("should delete tokens where token age is older than or equal 30 days", func() { + from := t.Add(-30 * 24 * time.Hour) + fromRotate := t.Add(-time.Second) + + // 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), from.Unix(), fromRotate.Unix()) + } + + // insert three active tokens that should not be deleted + for i := 0; i < 3; i++ { + from = from.Add(time.Second) + insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), fromRotate.Unix()) + } + + affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour) + So(err, ShouldBeNil) + So(affected, ShouldEqual, 3) + }) }) } From 4de9e3598b8bb35b6f700719a6e626e77e36c197 Mon Sep 17 00:00:00 2001 From: SamuelToh Date: Wed, 6 Feb 2019 06:41:39 +1000 Subject: [PATCH 077/144] Address review comments --- docs/sources/http_api/annotations.md | 4 ++-- pkg/api/annotations.go | 2 +- pkg/api/dtos/annotations.go | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index dee4ede0777..ca589581f96 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -189,6 +189,8 @@ Content-Type: application/json Updates one or more properties of an annotation that matches the specified id. +The `PATCH` operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties. It does not handle updating of the `isRegion` and `regionId` properties. To make an annotation regional or vice versa, consider using the `PUT` operation. + **Example Request**: ```json @@ -198,8 +200,6 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Content-Type: application/json { - "time":1507037197000, - "timeEnd":1507180807095, "text":"New Annotation Description", "tags":["tag6","tag7","tag8"] } diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index da9b55a1c16..de9d2517caa 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -222,7 +222,7 @@ func PatchAnnotation(c *m.ReqContext, cmd dtos.PatchAnnotationsCmd) Response { items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId}) if err != nil || len(items) == 0 { - return Error(500, "Could not find annotation to update", err) + return Error(404, "Could not find annotation to update", err) } existing := annotations.Item{ diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index b64329e56d1..bdee8599fea 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -23,11 +23,11 @@ type UpdateAnnotationsCmd struct { } type PatchAnnotationsCmd struct { - Id int64 `json:"id"` - Time int64 `json:"time"` - Text string `json:"text"` - Tags []string `json:"tags"` - TimeEnd int64 `json:"timeEnd"` + Id int64 `json:"id"` + Time int64 `json:"time"` + Text string `json:"text"` + Tags []string `json:"tags"` + TimeEnd int64 `json:"timeEnd"` } type DeleteAnnotationsCmd struct { From 0be43948e2cab8685714e9ee93d085fc069e0501 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 5 Feb 2019 22:01:00 +0100 Subject: [PATCH 078/144] changelog: add notes about closing #15265 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48551ae13db..4cf2262f7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ### Minor * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +* **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) * **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) -* **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) +* **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) # 6.0.0-beta1 (2019-01-30) From d8658a765c568d62cfeb3e5bac6d2a55969c9e65 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 08:30:14 +0100 Subject: [PATCH 079/144] enhanced expiration logic for lookup token tokens are not expired if created_at > now - LoginMaxLifetimeDays and rotated_at > now - LoginMaxInactiveLifetimeDays --- pkg/services/auth/authtoken/auth_token.go | 7 +- .../auth/authtoken/auth_token_test.go | 70 +++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/pkg/services/auth/authtoken/auth_token.go b/pkg/services/auth/authtoken/auth_token.go index 1fdad4dbea5..47aa925fd4d 100644 --- a/pkg/services/auth/authtoken/auth_token.go +++ b/pkg/services/auth/authtoken/auth_token.go @@ -81,10 +81,13 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (auth.UserT s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) } - expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginMaxInactiveLifetimeDays) * time.Second).Unix() + tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour + tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour + createdAfter := getTime().Add(-tokenMaxLifetime).Unix() + rotatedAfter := getTime().Add(-tokenMaxInactiveLifetime).Unix() var model userAuthToken - exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&model) + exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, createdAfter, rotatedAfter).Get(&model) if err != nil { return nil, err } diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/authtoken/auth_token_test.go index 51d361a9f4f..7ecb67b2ebf 100644 --- a/pkg/services/auth/authtoken/auth_token_test.go +++ b/pkg/services/auth/authtoken/auth_token_test.go @@ -105,12 +105,56 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(stillGood, ShouldNotBeNil) - getTime = func() time.Time { - return t.Add(24 * 7 * time.Hour) - } - notGood, err := userAuthTokenService.LookupToken(model.UnhashedToken) - So(err, ShouldEqual, ErrAuthTokenNotFound) - So(notGood, ShouldBeNil) + model, err = ctx.getAuthTokenByID(model.Id) + So(err, ShouldBeNil) + + Convey("when rotated_at is 6:59:59 ago should find token", func() { + getTime = func() time.Time { + return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour).Add(-time.Second) + } + + stillGood, err = userAuthTokenService.LookupToken(stillGood.GetToken()) + So(err, ShouldBeNil) + So(stillGood, ShouldNotBeNil) + }) + + Convey("when rotated_at is 7:00:00 ago should not find token", func() { + getTime = func() time.Time { + return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour) + } + + notGood, err := userAuthTokenService.LookupToken(userToken.GetToken()) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(notGood, ShouldBeNil) + }) + + Convey("when rotated_at is 5 days ago and created_at is 29 days and 23:59:59 ago should not find token", func() { + updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix()) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour).Add(-time.Second) + } + + stillGood, err = userAuthTokenService.LookupToken(stillGood.GetToken()) + So(err, ShouldBeNil) + So(stillGood, ShouldNotBeNil) + }) + + Convey("when rotated_at is 5 days ago and created_at is 30 days ago should not find token", func() { + updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix()) + So(err, ShouldBeNil) + So(updated, ShouldBeTrue) + + getTime = func() time.Time { + return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour) + } + + notGood, err := userAuthTokenService.LookupToken(userToken.GetToken()) + So(err, ShouldEqual, ErrAuthTokenNotFound) + So(notGood, ShouldBeNil) + }) }) Convey("can properly rotate tokens", func() { @@ -384,3 +428,17 @@ func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) { } return rowsAffected == 1, nil } + +func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) { + sess := c.sqlstore.NewSession() + res, err := sess.Exec("UPDATE user_auth_token SET rotated_at = ? WHERE id = ?", rotatedAt, id) + if err != nil { + return false, err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return false, err + } + return rowsAffected == 1, nil +} From 44275d9660feabcb42ca41db2b6866b16314c340 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 08:45:01 +0100 Subject: [PATCH 080/144] middleware fix --- pkg/middleware/middleware.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 9a3e5e1e01c..817372292b9 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -178,6 +178,7 @@ func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx * token, err := authTokenService.LookupToken(rawToken) if err != nil { ctx.Logger.Error("failed to look up user based on cookie", "error", err) + WriteSessionCookie(ctx, "", -1) return false } From 1fbdd02464ff5b1c917c7ca4b13f66d4153065c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 09:04:38 +0100 Subject: [PATCH 081/144] wip: tests --- public/app/core/redux/index.ts | 2 +- public/app/features/dashboard/containers/DashboardPage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts index bf45d7d22df..1ed23a8d744 100644 --- a/public/app/core/redux/index.ts +++ b/public/app/core/redux/index.ts @@ -1,2 +1,2 @@ -export { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from './actionCreatorFactory'; +export { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf, ActionCreator } from './actionCreatorFactory'; export { reducerFactory } from './reducerFactory'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1bd5218fd60..dfc0c1d2758 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -25,7 +25,7 @@ import { notifyApp } from 'app/core/actions'; import { StoreState, DashboardLoadingState, DashboardRouteInfo } from 'app/types'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; -interface Props { +export interface Props { urlUid?: string; urlSlug?: string; urlType?: string; @@ -46,7 +46,7 @@ interface Props { updateLocation: typeof updateLocation; } -interface State { +export interface State { isSettingsOpening: boolean; isEditing: boolean; isFullscreen: boolean; From 85ef2ca738c8e976d0a387728429934637841012 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 09:43:45 +0100 Subject: [PATCH 082/144] fix spelling --- conf/defaults.ini | 2 +- conf/sample.ini | 2 +- docs/sources/auth/overview.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index c65cb93d426..41b948e53af 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -250,7 +250,7 @@ login_cookie_name = grafana_session # The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days. login_maximum_inactive_lifetime_days = 7 -# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days. login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. diff --git a/conf/sample.ini b/conf/sample.ini index 39feb31441e..831fa31253e 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -230,7 +230,7 @@ log_queries = # The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days, ;login_maximum_inactive_lifetime_days = 7 -# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days. ;login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index e3d4c08ca5d..fba8da00a5e 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -58,7 +58,7 @@ login_cookie_name = grafana_session # The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days. login_maximum_inactive_lifetime_days = 7 -# The maximum lifetime (days) an autenticated user can be logged in since login time before being required to login. Default is 30 days. +# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days. login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. From 9c64e3b4b98b1ad3b7c89d814554e58c1370033d Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 6 Feb 2019 09:45:03 +0100 Subject: [PATCH 083/144] Revert "chore: Remove react-sizeme" This reverts commit 260b6f5de83133f668aa15a3874eeeb20d46cbe7. --- package.json | 1 + yarn.lock | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/package.json b/package.json index 18c0a56f0c4..77fd92baf57 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "react-highlight-words": "0.11.0", "react-popper": "^1.3.0", "react-redux": "^5.0.7", + "react-sizeme": "^2.3.6", "react-table": "^6.8.6", "react-transition-group": "^2.2.1", "react-virtualized": "^9.21.0", diff --git a/yarn.lock b/yarn.lock index fd0c446fbce..169abd40ee4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3972,6 +3972,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +batch-processor@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/batch-processor/-/batch-processor-1.0.0.tgz#75c95c32b748e0850d10c2b168f6bdbe9891ace8" + integrity sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg= + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -6608,6 +6613,13 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= +element-resize-detector@^1.1.12: + version "1.2.0" + resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.2.0.tgz#63344fd6f4e5ecff6f018d027e17b281fd4fa338" + integrity sha512-UmhNB8sIJVZeg56gEjgmMd6p37sCg8j8trVW0LZM7Wzv+kxQ5CnRHcgRKBTB/kFUSn3e7UP59kl2V2U8Du1hmg== + dependencies: + batch-processor "1.0.0" + elliptic@^6.0.0: version "6.4.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" @@ -10888,6 +10900,11 @@ lodash.tail@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= + lodash.union@4.6.0, lodash.union@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" @@ -14198,6 +14215,17 @@ react-resizable@1.x: prop-types "15.x" react-draggable "^2.2.6 || ^3.0.3" +react-sizeme@^2.3.6: + version "2.5.2" + resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.5.2.tgz#e7041390cfb895ed15d896aa91d76e147e3b70b5" + integrity sha512-hYvcncV1FxVzPm2EhVwlOLf7Tk+k/ttO6rI7bfKUL/aL1gYzrY3DXJsdZ6nFaFgGSU/i8KC6gCoptOhBbRJpXQ== + dependencies: + element-resize-detector "^1.1.12" + invariant "^2.2.2" + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + shallowequal "^1.0.2" + react-split-pane@^0.1.84: version "0.1.85" resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.85.tgz#64819946a99b617ffa2d20f6f45a0056b6ee4faa" From c47c2528aa9d5075a7c3ddc0a3a22220140652c3 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 6 Feb 2019 09:45:09 +0100 Subject: [PATCH 084/144] Revert "chore: Replace sizeMe with AutoSizer in DashboardGrid" This reverts commit ae0b027d69ce0fe2946aabfe55267150151a4038. --- .../dashboard/dashgrid/DashboardGrid.tsx | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 5a65fadd74b..658bfad3816 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -5,12 +5,13 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; import classNames from 'classnames'; -import { AutoSizer } from 'react-virtualized'; +import sizeMe from 'react-sizeme'; let lastGridWidth = 1200; let ignoreNextWidthChange = false; -interface SizedReactLayoutGridProps { +interface GridWrapperProps { + size: { width: number; }; layout: ReactGridLayout.Layout[]; onLayoutChange: (layout: ReactGridLayout.Layout[]) => void; children: JSX.Element | JSX.Element[]; @@ -24,12 +25,8 @@ interface SizedReactLayoutGridProps { isFullscreen?: boolean; } -interface GridWrapperProps extends SizedReactLayoutGridProps { - sizedWidth: number; -} - function GridWrapper({ - sizedWidth, + size, layout, onLayoutChange, children, @@ -41,8 +38,8 @@ function GridWrapper({ isResizable, isDraggable, isFullscreen, -}: GridWrapperProps) { - const width = sizedWidth > 0 ? sizedWidth : lastGridWidth; +}: GridWrapperProps) { + const width = size.width > 0 ? size.width : lastGridWidth; // logic to ignore width changes (optimization) if (width !== lastGridWidth) { @@ -77,16 +74,7 @@ function GridWrapper({ ); } -const SizedReactLayoutGrid = (props: SizedReactLayoutGridProps) => ( - - {({width}) => ( - - )} - -); +const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); export interface DashboardGridProps { dashboard: DashboardModel; From 865d1567fc0da20bd5388ce425d42857bd9d5e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 11:30:42 +0100 Subject: [PATCH 085/144] Added DashboardPage tests that tests view mode transition logic --- .../containers/DashboardPage.test.tsx | 126 ++++++++++ .../dashboard/containers/DashboardPage.tsx | 2 +- .../__snapshots__/DashboardPage.test.tsx.snap | 220 ++++++++++++++++++ 3 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 public/app/features/dashboard/containers/DashboardPage.test.tsx create mode 100644 public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx new file mode 100644 index 00000000000..59e71c69757 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { DashboardPage, Props, State } from './DashboardPage'; +import { DashboardModel } from '../state'; +import { setDashboardModel } from '../state/actions'; +import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; + +jest.mock('sass/_variables.scss', () => ({ + panelhorizontalpadding: 10, + panelVerticalPadding: 10, +})); + +jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({ +})); + +function setup(propOverrides?: Partial): ShallowWrapper { + const props: Props = { + urlUid: '11', + urlSlug: 'my-dash', + $scope: {}, + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + urlEdit: false, + urlFullscreen: false, + loadingState: DashboardLoadingState.Done, + isLoadingSlow: false, + initDashboard: jest.fn(), + updateLocation: jest.fn(), + notifyApp: jest.fn(), + dashboard: null, + setDashboardModel: setDashboardModel, + }; + + Object.assign(props, propOverrides); + return shallow(); +} + +describe('DashboardPage', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = setup(); + }); + + describe('Given dashboard has not loaded yet', () => { + it('should render nothing', () => { + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('Given dashboard model', () => { + let dashboard: DashboardModel; + + beforeEach(() => { + dashboard = new DashboardModel({ + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 } + } + ] + }, { + canEdit: true, + canSave: true, + }); + wrapper.setProps({ dashboard, loadingState: DashboardLoadingState.Done }); + }); + + it('Should update title', () => { + expect(document.title).toBe('My dashboard - Grafana'); + }); + + it('After render dashboard', () => { + expect(wrapper).toMatchSnapshot(); + }); + + describe('Given user has scrolled down and goes into fullscreen edit', () => { + beforeEach(() => { + wrapper.setState({ scrollTop: 100 }); + wrapper.setProps({ + urlFullscreen: true, + urlEdit: true, + urlPanelId: '1', + }); + }); + + it('Should update model state to fullscreen & edit', () => { + expect(dashboard.meta.fullscreen).toBe(true); + expect(dashboard.meta.isEditing).toBe(true); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = wrapper.state(); + expect(state.isEditing).toBe(true); + expect(state.isFullscreen).toBe(true); + expect(state.rememberScrollTop).toBe(100); + }); + + describe('Given user goes back to dashboard', () => { + beforeEach(() => { + wrapper.setState({ scrollTop: 0 }); + wrapper.setProps({ + urlFullscreen: false, + urlEdit: false, + urlPanelId: null, + }); + }); + + it('Should update model state normal state', () => { + expect(dashboard.meta.fullscreen).toBe(false); + expect(dashboard.meta.isEditing).toBe(false); + }); + + it('Should update component state to normal and restore scrollTop', () => { + const state = wrapper.state(); + expect(state.isEditing).toBe(false); + expect(state.isFullscreen).toBe(false); + expect(state.scrollTop).toBe(100); + }); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index dfc0c1d2758..f71838d2aa0 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -39,7 +39,7 @@ export interface Props { urlFullscreen: boolean; loadingState: DashboardLoadingState; isLoadingSlow: boolean; - dashboard: DashboardModel; + dashboard: DashboardModel | null; initDashboard: typeof initDashboard; setDashboardModel: typeof setDashboardModel; notifyApp: typeof notifyApp; diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap new file mode 100644 index 00000000000..d3808513e7b --- /dev/null +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -0,0 +1,220 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardPage Given dashboard has not loaded yet should render nothing 1`] = `""`; + +exports[`DashboardPage Given dashboard model After render dashboard 1`] = ` +
+ +
+ +
+ +
+
+
+
+`; From 6848fe0edf0480eda09efa300f075ac4f560b3ee Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 14:26:43 +0100 Subject: [PATCH 086/144] docs: update annotaions http api --- docs/sources/http_api/annotations.md | 50 +++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index ca589581f96..e1d2876f48a 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -97,7 +97,7 @@ Creates an annotation in the Grafana database. The `dashboardId` and `panelId` f **Example Request**: -```json +```http POST /api/annotations HTTP/1.1 Accept: application/json Content-Type: application/json @@ -115,7 +115,7 @@ Content-Type: application/json **Example Response**: -```json +```http HTTP/1.1 200 Content-Type: application/json @@ -135,7 +135,7 @@ format (string with multiple tags being separated by a space). **Example Request**: -```json +```http POST /api/annotations/graphite HTTP/1.1 Accept: application/json Content-Type: application/json @@ -150,7 +150,7 @@ Content-Type: application/json **Example Response**: -```json +```http HTTP/1.1 200 Content-Type: application/json @@ -160,15 +160,15 @@ Content-Type: application/json } ``` -## Replace Annotation +## Update Annotation `PUT /api/annotations/:id` -Replaces the annotation that matches the specified id. +Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the [Patch Annotation](#patch-annotation) operation. **Example Request**: -```json +```http PUT /api/annotations/1141 HTTP/1.1 Accept: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -183,17 +183,28 @@ Content-Type: application/json } ``` -## Update Annotation +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message":"Annotation updated" +} +``` + +## Patch Annotation `PATCH /api/annotations/:id` Updates one or more properties of an annotation that matches the specified id. -The `PATCH` operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties. It does not handle updating of the `isRegion` and `regionId` properties. To make an annotation regional or vice versa, consider using the `PUT` operation. +This operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties. It does not handle updating of the `isRegion` and `regionId` properties. To make an annotation regional or vice versa, consider using the [Update Annotation](#update-annotation) operation. **Example Request**: -```json +```http PATCH /api/annotations/1145 HTTP/1.1 Accept: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk @@ -205,6 +216,17 @@ Content-Type: application/json } ``` +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message":"Annotation patched" +} +``` + ## Delete Annotation By Id `DELETE /api/annotations/:id` @@ -226,7 +248,9 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk HTTP/1.1 200 Content-Type: application/json -{"message":"Annotation deleted"} +{ + "message":"Annotation deleted" +} ``` ## Delete Annotation By RegionId @@ -250,5 +274,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk HTTP/1.1 200 Content-Type: application/json -{"message":"Annotation region deleted"} +{ + "message":"Annotation region deleted" +} ``` From a53c3b45fcf75b8b6c375f80f730066a9c3ed802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 14:35:53 +0100 Subject: [PATCH 087/144] Added a basic test for initDashboard thunk --- package.json | 1 + .../dashboard/state/initDashboard.test.ts | 130 ++++++++++++++++++ .../features/dashboard/state/initDashboard.ts | 6 +- public/app/features/profile/state/reducers.ts | 14 ++ public/app/store/configureStore.ts | 2 + public/app/types/user.ts | 4 +- yarn.lock | 7 + 7 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 public/app/features/dashboard/state/initDashboard.test.ts create mode 100644 public/app/features/profile/state/reducers.ts diff --git a/package.json b/package.json index 77fd92baf57..5ac751ced3f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "prettier": "1.9.2", "react-hot-loader": "^4.3.6", "react-test-renderer": "^16.5.0", + "redux-mock-store": "^1.5.3", "regexp-replace-loader": "^1.0.1", "sass-lint": "^1.10.2", "sass-loader": "^7.0.1", diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts new file mode 100644 index 00000000000..eebeb5010fb --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -0,0 +1,130 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { initDashboard, InitDashboardArgs } from './initDashboard'; +import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; + +const mockStore = configureMockStore([thunk]); + +interface ScenarioContext { + args: InitDashboardArgs; + timeSrv: any; + annotationsSrv: any; + unsavedChangesSrv: any; + variableSrv: any; + dashboardSrv: any; + keybindingSrv: any; + setup: (fn: () => void) => void; + actions: any[]; + storeState: any; +} + +type ScenarioFn = (ctx: ScenarioContext) => void; + +function describeInitScenario(description: string, scenarioFn: ScenarioFn) { + describe(description, () => { + const timeSrv = { init: jest.fn() }; + const annotationsSrv = { init: jest.fn() }; + const unsavedChangesSrv = { init: jest.fn() }; + const variableSrv = { init: jest.fn() }; + const dashboardSrv = { setCurrent: jest.fn() }; + const keybindingSrv = { setupDashboardBindings: jest.fn() }; + + const injectorMock = { + get: (name: string) => { + switch (name) { + case 'timeSrv': + return timeSrv; + case 'annotationsSrv': + return annotationsSrv; + case 'unsavedChangesSrv': + return unsavedChangesSrv; + case 'dashboardSrv': + return dashboardSrv; + case 'variableSrv': + return variableSrv; + case 'keybindingSrv': + return keybindingSrv; + default: + throw { message: 'Unknown service ' + name }; + } + }, + }; + + let setupFn = () => {}; + + const ctx: ScenarioContext = { + args: { + $injector: injectorMock, + $scope: {}, + fixUrl: false, + routeInfo: DashboardRouteInfo.Normal, + }, + timeSrv, + annotationsSrv, + unsavedChangesSrv, + variableSrv, + dashboardSrv, + keybindingSrv, + actions: [], + storeState: { + location: { + query: {}, + }, + user: {}, + }, + setup: (fn: () => void) => { + setupFn = fn; + }, + }; + + beforeEach(async () => { + setupFn(); + + const store = mockStore(ctx.storeState); + + await store.dispatch(initDashboard(ctx.args)); + + ctx.actions = store.getActions(); + }); + + scenarioFn(ctx); + }); +} + +describeInitScenario('Initializing new dashboard', ctx => { + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.args.routeInfo = DashboardRouteInfo.New; + }); + + it('Should send action to set loading state to fetching', () => { + expect(ctx.actions[0].type).toBe('SET_DASHBOARD_LOADING_STATE'); + expect(ctx.actions[0].payload).toBe(DashboardLoadingState.Fetching); + }); + + it('Should send action to set loading state to Initializing', () => { + expect(ctx.actions[1].type).toBe('SET_DASHBOARD_LOADING_STATE'); + expect(ctx.actions[1].payload).toBe(DashboardLoadingState.Initializing); + }); + + it('Should update location with orgId query param', () => { + expect(ctx.actions[2].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[2].payload.query.orgId).toBe(12); + }); + + it('Should send action to set dashboard model', () => { + expect(ctx.actions[3].type).toBe('SET_DASHBOARD_MODEL'); + expect(ctx.actions[3].payload.title).toBe('New dashboard'); + }); + + it('Should Initializing services', () => { + expect(ctx.timeSrv.init).toBeCalled(); + expect(ctx.annotationsSrv.init).toBeCalled(); + expect(ctx.variableSrv.init).toBeCalled(); + expect(ctx.unsavedChangesSrv.init).toBeCalled(); + expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled(); + expect(ctx.dashboardSrv.setCurrent).toBeCalled(); + }); +}); + + diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index ba218c1583d..2c68435b313 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -9,7 +9,6 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; import { VariableSrv } from 'app/features/templating/variable_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; -import { config } from 'app/core/config'; // Actions import { updateLocation } from 'app/core/actions'; @@ -150,8 +149,9 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { } // add missing orgId query param - if (!getState().location.query.orgId) { - dispatch(updateLocation({ query: { orgId: config.bootData.user.orgId }, partial: true, replace: true })); + const storeState = getState() ; + if (!storeState.location.query.orgId) { + dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true })); } // init services diff --git a/public/app/features/profile/state/reducers.ts b/public/app/features/profile/state/reducers.ts new file mode 100644 index 00000000000..dc6e841449e --- /dev/null +++ b/public/app/features/profile/state/reducers.ts @@ -0,0 +1,14 @@ +import { UserState } from 'app/types'; +import config from 'app/core/config'; + +export const initialState: UserState = { + orgId: config.bootData.user.orgId, +}; + +export const userReducer = (state = initialState, action: any): UserState => { + return state; +}; + +export default { + user: userReducer, +}; diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 570a387cd74..e2c33523271 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -11,6 +11,7 @@ import exploreReducers from 'app/features/explore/state/reducers'; import pluginReducers from 'app/features/plugins/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; +import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import { setStore } from './store'; @@ -25,6 +26,7 @@ const rootReducers = { ...pluginReducers, ...dataSourcesReducers, ...usersReducers, + ...userReducers, ...organizationReducers, }; diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 365411147bb..7691558ce90 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,5 +1,3 @@ -import { DashboardSearchHit } from './search'; - export interface OrgUser { avatarUrl: string; email: string; @@ -47,5 +45,5 @@ export interface UsersState { } export interface UserState { - starredDashboards: DashboardSearchHit[]; + orgId: number; } diff --git a/yarn.lock b/yarn.lock index 169abd40ee4..df2e1cea37e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14582,6 +14582,13 @@ redux-logger@^3.0.6: dependencies: deep-diff "^0.3.5" +redux-mock-store@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d" + integrity sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA== + dependencies: + lodash.isplainobject "^4.0.6" + redux-thunk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" From 809d4b040aedb388f5836a39c8f75316321bfca0 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 14:52:17 +0100 Subject: [PATCH 088/144] changelog: add notes about closing #12546 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf2262f7d2..4bddf5e0f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) +* **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) # 6.0.0-beta1 (2019-01-30) From 7eb2558fc5d8d99588f41734c887fab8ac7c1f47 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 6 Feb 2019 15:06:27 +0100 Subject: [PATCH 089/144] Fix issue with graph legend color picker disapearing on color selection --- public/app/core/utils/ConfigProvider.tsx | 3 +-- public/app/plugins/panel/graph/graph.ts | 5 ++++- scripts/webpack/getThemeVariable.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx index 56b6fc3d8b9..cb3ad88b191 100644 --- a/public/app/core/utils/ConfigProvider.tsx +++ b/public/app/core/utils/ConfigProvider.tsx @@ -21,8 +21,7 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { return ( {config => { - const currentTheme = getCurrentThemeName(); - return {children}; + return {children}; }} ); diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 3800e147d9d..846d11ea475 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -28,6 +28,8 @@ import { GraphCtrl } from './module'; import { GrafanaThemeType, getValueFormat } from '@grafana/ui'; import { provideTheme } from 'app/core/utils/ConfigProvider'; +const LegendWithThemeProvider = provideTheme(Legend); + class GraphElement { ctrl: GraphCtrl; tooltip: any; @@ -44,6 +46,7 @@ class GraphElement { legendElem: HTMLElement; constructor(private scope, private elem, private timeSrv) { + this.ctrl = scope.ctrl; this.dashboard = this.ctrl.dashboard; this.panel = this.ctrl.panel; @@ -110,7 +113,7 @@ class GraphElement { onToggleAxis: this.ctrl.onToggleAxis, }; - const legendReactElem = React.createElement(provideTheme(Legend), legendProps); + const legendReactElem = React.createElement(LegendWithThemeProvider, legendProps); ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel()); } diff --git a/scripts/webpack/getThemeVariable.js b/scripts/webpack/getThemeVariable.js index 0db0a9842a8..6726f95d47c 100644 --- a/scripts/webpack/getThemeVariable.js +++ b/scripts/webpack/getThemeVariable.js @@ -29,7 +29,7 @@ function getThemeVariable(variablePath, themeName) { const variable = get(theme, variablePath.getValue()); if (!variable) { - throw new Error(`${variablePath} is not defined fo ${themeName}`); + throw new Error(`${variablePath.getValue()} is not defined for ${themeName.getValue()} theme`); } if (isHex(variable)) { From 27a1a9e8c5b8edf16110c280e0b8559dc25d95c9 Mon Sep 17 00:00:00 2001 From: ijin08 Date: Wed, 6 Feb 2019 15:45:40 +0100 Subject: [PATCH 090/144] removed unused theme variables, removed empty sections, aligned the order of sections in the files --- public/sass/_variables.dark.scss | 25 +----- public/sass/_variables.light.scss | 131 +++++++++++------------------- 2 files changed, 47 insertions(+), 109 deletions(-) diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 7b0ed869bdc..1e9ea77d3f1 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -4,9 +4,6 @@ $theme-name: dark; // Grays -// ------------------------- -$black: #000; - // ------------------------- $black: #000; $dark-1: #141414; @@ -19,7 +16,6 @@ $gray-2: #8e8e8e; $gray-3: #b3b3b3; $gray-4: #d8d9da; $gray-5: #ececec; -$gray-6: #f4f5f8; $gray-7: #fbfbfb; $gray-blue: #212327; @@ -34,7 +30,6 @@ $blue-dark: #005f81; $green: #299c46; $red: #d44a3a; $yellow: #ecbb13; -$pink: #ff4444; $purple: #9933cc; $variable: #32d1df; $orange: #eb7b18; @@ -68,7 +63,6 @@ $text-color-weak: $gray-2; $text-color-faint: $dark-5; $text-color-emphasis: $gray-5; -$text-shadow-strong: 1px 1px 4px $black; $text-shadow-faint: 1px 1px 4px rgb(45, 45, 45); // gradients @@ -100,8 +94,7 @@ $hr-border-color: $dark-4; // Panel // ------------------------- $panel-bg: #212124; -$panel-border-color: $dark-1; -$panel-border: solid 1px $panel-border-color; +$panel-border: solid 1px $dark-1; $panel-header-hover-bg: $dark-4; $panel-corner: $panel-bg; @@ -144,7 +137,6 @@ $scrollbarBorder: black; // ------------------------- $table-bg: transparent; // overall background-color $table-bg-accent: $dark-3; // for striping -$table-bg-hover: $dark-4; // for hover $table-border: $dark-3; // table and cell border $table-bg-odd: $dark-2; @@ -173,9 +165,6 @@ $btn-inverse-bg-hl: lighten($dark-3, 4%); $btn-inverse-text-color: $link-color; $btn-inverse-text-shadow: 0px 1px 0 rgba(0, 0, 0, 0.1); -$btn-active-bg: $gray-4; -$btn-active-text-color: $blue-dark; - $btn-link-color: $gray-3; $iconContainerBackground: $black; @@ -215,13 +204,11 @@ $dropdownBackground: $dark-3; $dropdownBorder: rgba(0, 0, 0, 0.2); $dropdownDividerTop: transparent; $dropdownDividerBottom: #444; -$dropdownDivider: $dropdownDividerBottom; $dropdownLinkColor: $text-color; $dropdownLinkColorHover: $white; $dropdownLinkColorActive: $white; -$dropdownLinkBackgroundActive: $dark-4; $dropdownLinkBackgroundHover: $dark-4; // COMPONENT VARIABLES @@ -264,9 +251,6 @@ $menu-dropdown-bg: $body-bg; $menu-dropdown-hover-bg: $dark-2; $menu-dropdown-shadow: 5px 5px 20px -5px $black; -// Breadcrumb -// ------------------------- - // Tabs // ------------------------- $tab-border-color: $dark-4; @@ -274,9 +258,6 @@ $tab-border-color: $dark-4; // Toolbar $toolbar-bg: $input-black; -// Pagination -// ------------------------- - // Form states and alerts // ------------------------- $warning-text-color: $warn; @@ -311,7 +292,6 @@ $tooltipBackground: $black; $tooltipColor: $gray-4; $tooltipArrowColor: $tooltipBackground; $tooltipBackgroundError: $brand-danger; -$tooltipBackgroundBrand: $brand-primary; // images $checkboxImageUrl: '../img/checkbox.png'; @@ -380,9 +360,7 @@ $checkbox-color: $dark-1; //Panel Edit // ------------------------- $panel-editor-shadow: 0 0 20px black; -$panel-editor-border: 1px solid $dark-3; $panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black); -$panel-editor-toolbar-view-bg: $input-black; $panel-editor-viz-item-shadow: 0 0 8px $dark-5; $panel-editor-viz-item-border: 1px solid $dark-5; $panel-editor-viz-item-shadow-hover: 0 0 4px $blue; @@ -390,7 +368,6 @@ $panel-editor-viz-item-border-hover: 1px solid $blue; $panel-editor-viz-item-bg: $input-black; $panel-editor-tabs-line-color: #e3e3e3; $panel-editor-viz-item-bg-hover: darken($blue, 47%); -$panel-editor-viz-item-bg-hover-active: darken($orange, 45%); $panel-options-group-border: none; $panel-options-group-header-bg: $gray-blue; diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 10c074e1481..1bc9da354fb 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -1,7 +1,3 @@ -// Cosmo 2.3.2 -// Variables -// -------------------------------------------------- - // Global values // -------------------------------------------------- @@ -10,10 +6,6 @@ $theme-name: light; // Grays // ------------------------- $black: #000; - -// ------------------------- -$black: #000; -$dark-1: #13161d; $dark-2: #1e2028; $dark-3: #303133; $dark-4: #35373f; @@ -31,13 +23,11 @@ $white: #fff; // Accent colors // ------------------------- $blue: #0083b3; -$blue-dark: #005f81; $blue-light: #00a8e6; $green: #3aa655; $red: #d44939; $yellow: #ff851b; $orange: #ff7941; -$pink: #e671b8; $purple: #9954bb; $variable: $blue; @@ -60,9 +50,9 @@ $critical: #ec2128; // Scaffolding // ------------------------- - $body-bg: $gray-7; $page-bg: $gray-7; + $body-color: $gray-1; $text-color: $gray-1; $text-color-strong: $dark-2; @@ -70,12 +60,17 @@ $text-color-weak: $gray-2; $text-color-faint: $gray-4; $text-color-emphasis: $dark-5; -$text-shadow-strong: none; $text-shadow-faint: none; $textShadow: none; // gradients -$brand-gradient: linear-gradient(to right, rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%); +$brand-gradient: linear-gradient( + to right, + rgba(255, 213, 0, 1) 0%, + rgba(255, 68, 0, 1) 99%, + rgba(255, 68, 0, 1) 100% +); + $page-gradient: linear-gradient(180deg, $white 10px, $gray-7 100px); $edit-gradient: linear-gradient(-60deg, $gray-7, #f5f6f9 70%, $gray-7 98%); @@ -96,10 +91,8 @@ $hr-border-color: $dark-3 !default; // Panel // ------------------------- - $panel-bg: $white; -$panel-border-color: $gray-5; -$panel-border: solid 1px $panel-border-color; +$panel-border: solid 1px $gray-5; $panel-header-hover-bg: $gray-6; $panel-corner: $gray-4; @@ -112,7 +105,6 @@ $divider-border-color: $gray-2; // Graphite Target Editor $tight-form-bg: #eaebee; - $tight-form-func-bg: $gray-5; $tight-form-func-highlight-bg: $gray-6; @@ -130,24 +122,23 @@ $list-item-bg: linear-gradient(135deg, $gray-5, $gray-6); //$card-background; $list-item-hover-bg: darken($gray-5, 5%); $list-item-link-color: $text-color; $list-item-shadow: $card-shadow; + $empty-list-cta-bg: $gray-6; -// Tables -// ------------------------- -$table-bg: transparent; // overall background-color -$table-bg-accent: $gray-5; // for striping -$table-bg-hover: $gray-5; // for hover -$table-bg-active: $table-bg-hover !default; -$table-border: $gray-3; // table and cell border - -$table-bg-odd: $gray-6; -$table-bg-hover: $gray-5; - // Scrollbars $scrollbarBackground: $gray-5; $scrollbarBackground2: $gray-5; $scrollbarBorder: $gray-4; +// Tables +// ------------------------- +$table-bg: transparent; // overall background-color +$table-bg-accent: $gray-5; // for striping +$table-border: $gray-3; // table and cell border + +$table-bg-odd: $gray-6; +$table-bg-hover: $gray-5; + // Buttons // ------------------------- $btn-primary-bg: $brand-primary; @@ -170,16 +161,14 @@ $btn-inverse-bg-hl: darken($gray-6, 5%); $btn-inverse-text-color: $gray-1; $btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4); -$btn-active-bg: $white; -$btn-active-text-color: $blue; - $btn-link-color: $gray-1; +$iconContainerBackground: $white; + $btn-divider-left: $gray-4; $btn-divider-right: $gray-7; -$btn-drag-image: '../img/grab_light.svg'; -$iconContainerBackground: $white; +$btn-drag-image: '../img/grab_light.svg'; // Forms // ------------------------- @@ -196,29 +185,8 @@ $input-label-bg: $gray-5; $input-label-border-color: $gray-5; $input-color-select-arrow: $gray-1; -// Sidemenu -// ------------------------- -$side-menu-bg: $dark-2; -$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6; -$side-menu-item-hover-bg: $gray-1; -$side-menu-shadow: 5px 0px 10px -5px $gray-1; -$side-menu-link-color: $gray-6; - -// Menu dropdowns -// ------------------------- -$menu-dropdown-bg: $gray-7; -$menu-dropdown-hover-bg: $gray-6; -$menu-dropdown-shadow: 5px 5px 10px -5px $gray-1; - -// Breadcrumb -// ------------------------- - -// Tabs -// ------------------------- -$tab-border-color: $gray-5; - -// Toolbar -$toolbar-bg: white; +// Input placeholder text color +$placeholderText: $gray-2; // search $search-shadow: 0 5px 30px 0 $gray-4; @@ -235,52 +203,52 @@ $dropdownBackground: $white; $dropdownBorder: $gray-4; $dropdownDividerTop: $gray-6; $dropdownDividerBottom: $white; -$dropdownDivider: $dropdownDividerTop; $dropdownLinkColor: $dark-3; $dropdownLinkColorHover: $link-color; $dropdownLinkColorActive: $link-color; -$dropdownLinkBackgroundActive: $gray-6; $dropdownLinkBackgroundHover: $gray-6; -// COMPONENT VARIABLES -// -------------------------------------------------- - -// Input placeholder text color -// ------------------------- -$placeholderText: $gray-2; - -// Hr border color -// ------------------------- -$hrBorder: $gray-3; - // Horizontal forms & lists // ------------------------- $horizontalComponentOffset: 180px; -// Wells -// ------------------------- - // Navbar // ------------------------- - $navbarHeight: 52px; + $navbarBackground: $white; $navbarBorder: 1px solid $gray-4; $navbarShadow: 0 0 3px #c1c1c1; $navbarLinkColor: #444; -$navbarBrandColor: $navbarLinkColor; - $navbarButtonBackground: lighten($navbarBackground, 3%); $navbarButtonBackgroundHighlight: lighten($navbarBackground, 5%); $navbar-button-border: $gray-4; -// Pagination +// Sidemenu // ------------------------- +$side-menu-bg: $dark-2; +$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6; +$side-menu-item-hover-bg: $gray-1; +$side-menu-shadow: 5px 0px 10px -5px $gray-1; +$side-menu-link-color: $gray-6; + +// Menu dropdowns +// ------------------------- +$menu-dropdown-bg: $gray-7; +$menu-dropdown-hover-bg: $gray-6; +$menu-dropdown-shadow: 5px 5px 10px -5px $gray-1; + +// Tabs +// ------------------------- +$tab-border-color: $gray-5; + +// Toolbar +$toolbar-bg: white; // Form states and alerts // ------------------------- @@ -302,6 +270,7 @@ $popover-shadow: 0 0 20px $white; $popover-help-bg: $blue; $popover-help-color: $gray-6; + $popover-error-bg: $btn-danger-bg; // Tooltips and popovers @@ -315,7 +284,6 @@ $tooltipBackground: $gray-1; $tooltipColor: $gray-7; $tooltipArrowColor: $tooltipBackground; // Used by Angular tooltip $tooltipBackgroundError: $brand-danger; -$tooltipBackgroundBrand: $brand-primary; // images $checkboxImageUrl: '../img/checkbox_white.png'; @@ -327,8 +295,6 @@ $info-box-border-color: lighten($blue, 20%); $footer-link-color: $gray-3; $footer-link-hover: $dark-5; -// collapse box - // json explorer $json-explorer-default-color: black; $json-explorer-string-color: green; @@ -348,9 +314,6 @@ $json-explorer-url-color: blue; $diff-label-bg: $gray-5; $diff-label-fg: $gray-2; -$diff-switch-bg: $gray-7; -$diff-switch-disabled: $gray-5; - $diff-arrow-color: $dark-3; $diff-group-bg: $gray-7; @@ -365,6 +328,7 @@ $diff-json-new: #664e33; $diff-json-changed-fg: $gray-6; $diff-json-changed-num: $gray-4; + $diff-json-icon: $gray-4; //Submenu @@ -388,9 +352,7 @@ $checkbox-color: $gray-7; //Panel Edit // ------------------------- $panel-editor-shadow: 0px 0px 8px $gray-3; -$panel-editor-border: 1px solid $dark-4; $panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3); -$panel-editor-toolbar-view-bg: $white; $panel-editor-viz-item-shadow: 0 0 4px $gray-3; $panel-editor-viz-item-border: 1px solid $gray-3; $panel-editor-viz-item-shadow-hover: 0 0 4px $blue-light; @@ -398,7 +360,6 @@ $panel-editor-viz-item-border-hover: 1px solid $blue-light; $panel-editor-viz-item-bg: $white; $panel-editor-tabs-line-color: $dark-5; $panel-editor-viz-item-bg-hover: lighten($blue, 62%); -$panel-editor-viz-item-bg-hover-active: lighten($orange, 34%); $panel-options-group-border: none; $panel-options-group-header-bg: $gray-5; From 3ef4d20f77fffbbe416e2aa8229aa6f91b6d9474 Mon Sep 17 00:00:00 2001 From: ijin08 Date: Wed, 6 Feb 2019 15:47:03 +0100 Subject: [PATCH 091/144] removed trailing whitespace --- public/sass/_variables.light.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 1bc9da354fb..97d7a374765 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -65,9 +65,9 @@ $textShadow: none; // gradients $brand-gradient: linear-gradient( - to right, - rgba(255, 213, 0, 1) 0%, - rgba(255, 68, 0, 1) 99%, + to right, + rgba(255, 213, 0, 1) 0%, + rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100% ); From 0e7b420d6a9d36ce8a676e09b95c01e8eacbad14 Mon Sep 17 00:00:00 2001 From: ijin08 Date: Wed, 6 Feb 2019 15:51:36 +0100 Subject: [PATCH 092/144] some changes i forgot to save in first push in variables.dark --- public/sass/_variables.dark.scss | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 1e9ea77d3f1..3f9f58a680f 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -64,6 +64,7 @@ $text-color-faint: $dark-5; $text-color-emphasis: $gray-5; $text-shadow-faint: 1px 1px 4px rgb(45, 45, 45); +$textShadow: none; // gradients $brand-gradient: linear-gradient( @@ -130,7 +131,6 @@ $empty-list-cta-bg: $gray-blue; // Scrollbars $scrollbarBackground: #404357; $scrollbarBackground2: #3a3a3a; - $scrollbarBorder: black; // Tables @@ -144,7 +144,6 @@ $table-bg-hover: $dark-3; // Buttons // ------------------------- - $btn-primary-bg: #ff6600; $btn-primary-bg-hl: #bc3e06; @@ -189,6 +188,9 @@ $input-label-bg: $gray-blue; $input-label-border-color: $dark-3; $input-color-select-arrow: $white; +// Input placeholder text color +$placeholderText: darken($text-color, 25%); + // Search $search-shadow: 0 0 30px 0 $black; $search-filter-box-bg: $gray-blue; @@ -211,19 +213,12 @@ $dropdownLinkColorActive: $white; $dropdownLinkBackgroundHover: $dark-4; -// COMPONENT VARIABLES -// -------------------------------------------------- - -// ------------------------- -$placeholderText: darken($text-color, 25%); - // Horizontal forms & lists // ------------------------- $horizontalComponentOffset: 180px; -// Wells +// Navbar // ------------------------- - $navbarHeight: 55px; $navbarBackground: $panel-bg; From 16e3c193ec6cf34e2c63b8d3a93dde6d86677c3f Mon Sep 17 00:00:00 2001 From: ijin08 Date: Wed, 6 Feb 2019 16:19:56 +0100 Subject: [PATCH 093/144] replaced some hex values with variables --- public/sass/_variables.dark.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 3f9f58a680f..149a1247b8e 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -104,12 +104,12 @@ $page-header-bg: linear-gradient(90deg, #292a2d, black); $page-header-shadow: inset 0px -4px 14px $dark-2; $page-header-border-color: $dark-4; -$divider-border-color: #555; +$divider-border-color: $gray-1; // Graphite Target Editor $tight-form-bg: $dark-3; -$tight-form-func-bg: #333334; -$tight-form-func-highlight-bg: #444445; +$tight-form-func-bg: $dark-4; +$tight-form-func-highlight-bg: $dark-5; $modal-backdrop-bg: #353c42; $code-tag-bg: $dark-1; From a60124a88cef42bb301a261a34469eeb930475a2 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 16:21:16 +0100 Subject: [PATCH 094/144] change UserToken from interface to struct --- pkg/api/login.go | 2 +- pkg/middleware/middleware.go | 6 +- pkg/middleware/middleware_test.go | 88 +++----- pkg/middleware/org_redirect_test.go | 16 +- pkg/middleware/quota_test.go | 8 +- pkg/models/context.go | 2 +- pkg/services/auth/auth.go | 16 +- pkg/services/auth/authtoken/auth_token.go | 30 +-- .../auth/authtoken/auth_token_test.go | 213 ++++++++++-------- pkg/services/auth/authtoken/model.go | 78 +++---- 10 files changed, 244 insertions(+), 215 deletions(-) diff --git a/pkg/api/login.go b/pkg/api/login.go index def24f983c1..48f8f237884 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -137,7 +137,7 @@ func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { hs.log.Error("failed to create auth token", "error", err) } - middleware.WriteSessionCookie(c, userToken.GetToken(), hs.Cfg.LoginMaxLifetimeDays) + middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetimeDays) } func (hs *HTTPServer) Logout(c *m.ReqContext) { diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 817372292b9..762a12d09d2 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -182,9 +182,9 @@ func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx * return false } - query := m.GetSignedInUserQuery{UserId: token.GetUserId(), OrgId: orgID} + query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} if err := bus.Dispatch(&query); err != nil { - ctx.Logger.Error("failed to get user with id", "userId", token.GetUserId(), "error", err) + ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err) return false } @@ -199,7 +199,7 @@ func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx * } if rotated { - WriteSessionCookie(ctx, token.GetToken(), setting.LoginMaxLifetimeDays) + WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetimeDays) } return true diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index fdcc56da3bf..5852f9d0bcd 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -157,10 +157,10 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 12, - token: unhashedToken, + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 12, + UnhashedToken: unhashedToken, }, nil } @@ -169,8 +169,8 @@ func TestMiddlewareContext(t *testing.T) { Convey("should init context with user info", func() { So(sc.context.IsSignedIn, ShouldBeTrue) So(sc.context.UserId, ShouldEqual, 12) - So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) - So(sc.context.UserToken.GetToken(), ShouldEqual, "token") + So(sc.context.UserToken.UserId, ShouldEqual, 12) + So(sc.context.UserToken.UnhashedToken, ShouldEqual, "token") }) Convey("should not set cookie", func() { @@ -186,15 +186,15 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 12, - token: unhashedToken, + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 12, + UnhashedToken: "", }, nil } - sc.userAuthTokenService.tryRotateTokenProvider = func(userToken auth.UserToken, clientIP, userAgent string) (bool, error) { - userToken.(fakeUserToken).SetToken("rotated") + sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *auth.UserToken, clientIP, userAgent string) (bool, error) { + userToken.UnhashedToken = "rotated" return true, nil } @@ -216,8 +216,8 @@ func TestMiddlewareContext(t *testing.T) { Convey("should init context with user info", func() { So(sc.context.IsSignedIn, ShouldBeTrue) So(sc.context.UserId, ShouldEqual, 12) - So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) - So(sc.context.UserToken.GetToken(), ShouldEqual, "rotated") + So(sc.context.UserToken.UserId, ShouldEqual, 12) + So(sc.context.UserToken.UnhashedToken, ShouldEqual, "rotated") }) Convey("should set cookie", func() { @@ -228,7 +228,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { return nil, authtoken.ErrAuthTokenNotFound } @@ -679,70 +679,48 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) -type fakeUserToken interface { - auth.UserToken - SetToken(token string) -} - -type userTokenImpl struct { - userId int64 - token string -} - -func (ut *userTokenImpl) GetUserId() int64 { - return ut.userId -} - -func (ut *userTokenImpl) GetToken() string { - return ut.token -} - -func (ut *userTokenImpl) SetToken(token string) { - ut.token = token -} - type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) - tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) - revokeTokenProvider func(token auth.UserToken) error + createTokenProvider func(userId int64, clientIP, userAgent string) (*auth.UserToken, error) + tryRotateTokenProvider func(token *auth.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (*auth.UserToken, error) + revokeTokenProvider func(token *auth.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 0, - token: "", + createTokenProvider: func(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 0, + UnhashedToken: "", }, nil }, - tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { + tryRotateTokenProvider: func(token *auth.UserToken, clientIP, userAgent string) (bool, error) { return false, nil }, - lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 0, - token: "", + lookupTokenProvider: func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 0, + UnhashedToken: "", }, nil }, - revokeTokenProvider: func(token auth.UserToken) error { + revokeTokenProvider: func(token *auth.UserToken) error { return nil }, } } -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*auth.UserToken, error) { return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { +func (s *fakeUserAuthTokenService) TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) { return s.tryRotateTokenProvider(token, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { +func (s *fakeUserAuthTokenService) RevokeToken(token *auth.UserToken) error { return s.revokeTokenProvider(token) } diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index c7479b3e9bc..0cc88016f3a 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -26,10 +26,10 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 12, - token: "", + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 0, + UnhashedToken: "", }, nil } @@ -52,10 +52,10 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 12, - token: "", + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 12, + UnhashedToken: "", }, nil } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index af22f41deba..df2bf930426 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -81,10 +81,10 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { - return &userTokenImpl{ - userId: 12, - token: "", + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ + UserId: 12, + UnhashedToken: "", }, nil } diff --git a/pkg/models/context.go b/pkg/models/context.go index da63db63f45..bba0024c0f2 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -14,7 +14,7 @@ import ( type ReqContext struct { *macaron.Context *SignedInUser - UserToken auth.UserToken + UserToken *auth.UserToken // This should only be used by the auth_proxy Session session.SessionStore diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index 31316f473f5..727ceffff79 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -1,6 +1,16 @@ package auth -type UserToken interface { - GetUserId() int64 - GetToken() string +type UserToken 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 } diff --git a/pkg/services/auth/authtoken/auth_token.go b/pkg/services/auth/authtoken/auth_token.go index 47aa925fd4d..a993a37dbf3 100644 --- a/pkg/services/auth/authtoken/auth_token.go +++ b/pkg/services/auth/authtoken/auth_token.go @@ -40,7 +40,7 @@ func (s *UserAuthTokenServiceImpl) Init() error { return nil } -func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { +func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { clientIP = util.ParseIPAddress(clientIP) token, err := util.RandomHex(16) if err != nil { @@ -72,10 +72,13 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken) - return userAuthToken.toUserToken() + var userToken auth.UserToken + err = userAuthToken.toUserToken(&userToken) + + return &userToken, err } -func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (auth.UserToken, error) { +func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*auth.UserToken, error) { hashedToken := hashToken(unhashedToken) if setting.Env == setting.DEV { s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) @@ -133,18 +136,19 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (auth.UserT } model.UnhashedToken = unhashedToken - return model.toUserToken() + + var userToken auth.UserToken + err = model.toUserToken(&userToken) + + return &userToken, err } -func (s *UserAuthTokenServiceImpl) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { +func (s *UserAuthTokenServiceImpl) TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) { if token == nil { return false, nil } - model, err := extractModelFromToken(token) - if err != nil { - return false, err - } + model := userAuthTokenFromUserToken(token) now := getTime() @@ -191,21 +195,19 @@ func (s *UserAuthTokenServiceImpl) TryRotateToken(token auth.UserToken, clientIP s.log.Debug("auth token rotated", "affected", affected, "auth_token_id", model.Id, "userId", model.UserId) if affected > 0 { model.UnhashedToken = newToken + model.toUserToken(token) return true, nil } return false, nil } -func (s *UserAuthTokenServiceImpl) RevokeToken(token auth.UserToken) error { +func (s *UserAuthTokenServiceImpl) RevokeToken(token *auth.UserToken) error { if token == nil { return ErrAuthTokenNotFound } - model, err := extractModelFromToken(token) - if err != nil { - return err - } + model := userAuthTokenFromUserToken(token) rowsAffected, err := s.SQLStore.NewSession().Delete(model) if err != nil { diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/authtoken/auth_token_test.go index 7ecb67b2ebf..a1b70db60fb 100644 --- a/pkg/services/auth/authtoken/auth_token_test.go +++ b/pkg/services/auth/authtoken/auth_token_test.go @@ -1,12 +1,15 @@ package authtoken import ( + "encoding/json" "testing" "time" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/sqlstore" . "github.com/smartystreets/goconvey/convey" ) @@ -25,28 +28,24 @@ func TestUserAuthToken(t *testing.T) { Convey("When creating token", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) - So(model.AuthTokenSeen, ShouldBeFalse) + So(userToken, ShouldNotBeNil) + So(userToken.AuthTokenSeen, ShouldBeFalse) Convey("When lookup unhashed token should return user auth token", func() { - userToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + userToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) - lookedUpModel, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) - So(lookedUpModel.UserId, ShouldEqual, userID) - So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + So(userToken, ShouldNotBeNil) + So(userToken.UserId, ShouldEqual, userID) + So(userToken.AuthTokenSeen, ShouldBeTrue) - storedAuthToken, err := ctx.getAuthTokenByID(lookedUpModel.Id) + storedAuthToken, err := ctx.getAuthTokenByID(userToken.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() { - userToken, err := userAuthTokenService.LookupToken(model.AuthToken) + userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken) So(err, ShouldEqual, ErrAuthTokenNotFound) So(userToken, ShouldBeNil) }) @@ -55,7 +54,7 @@ func TestUserAuthToken(t *testing.T) { err = userAuthTokenService.RevokeToken(userToken) So(err, ShouldBeNil) - model, err := ctx.getAuthTokenByID(model.Id) + model, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) So(model, ShouldBeNil) }) @@ -66,10 +65,8 @@ func TestUserAuthToken(t *testing.T) { }) Convey("revoking non-existing token should return error", func() { - model.Id = 1000 - nonExistingToken, err := model.toUserToken() - So(err, ShouldBeNil) - err = userAuthTokenService.RevokeToken(nonExistingToken) + userToken.Id = 1000 + err = userAuthTokenService.RevokeToken(userToken) So(err, ShouldEqual, ErrAuthTokenNotFound) }) }) @@ -77,17 +74,8 @@ func TestUserAuthToken(t *testing.T) { Convey("expires correctly", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) - _, err = userAuthTokenService.LookupToken(model.UnhashedToken) - So(err, ShouldBeNil) - - model, err = ctx.getAuthTokenByID(model.Id) - So(err, ShouldBeNil) - - userToken, err = model.toUserToken() + userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) getTime = func() time.Time { @@ -98,14 +86,14 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - _, err = userAuthTokenService.LookupToken(model.UnhashedToken) + userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) - stillGood, err := userAuthTokenService.LookupToken(model.UnhashedToken) + stillGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) So(stillGood, ShouldNotBeNil) - model, err = ctx.getAuthTokenByID(model.Id) + model, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) Convey("when rotated_at is 6:59:59 ago should find token", func() { @@ -113,7 +101,7 @@ func TestUserAuthToken(t *testing.T) { return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour).Add(-time.Second) } - stillGood, err = userAuthTokenService.LookupToken(stillGood.GetToken()) + stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken) So(err, ShouldBeNil) So(stillGood, ShouldNotBeNil) }) @@ -123,7 +111,7 @@ func TestUserAuthToken(t *testing.T) { return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour) } - notGood, err := userAuthTokenService.LookupToken(userToken.GetToken()) + notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldEqual, ErrAuthTokenNotFound) So(notGood, ShouldBeNil) }) @@ -137,7 +125,7 @@ func TestUserAuthToken(t *testing.T) { return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour).Add(-time.Second) } - stillGood, err = userAuthTokenService.LookupToken(stillGood.GetToken()) + stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken) So(err, ShouldBeNil) So(stillGood, ShouldNotBeNil) }) @@ -151,7 +139,7 @@ func TestUserAuthToken(t *testing.T) { return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour) } - notGood, err := userAuthTokenService.LookupToken(userToken.GetToken()) + notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldEqual, ErrAuthTokenNotFound) So(notGood, ShouldBeNil) }) @@ -160,37 +148,35 @@ func TestUserAuthToken(t *testing.T) { Convey("can properly rotate tokens", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) - prevToken := model.AuthToken - unhashedPrev := model.UnhashedToken + prevToken := userToken.AuthToken + unhashedPrev := userToken.UnhashedToken rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent") So(err, ShouldBeNil) So(rotated, ShouldBeFalse) - updated, err := ctx.markAuthTokenAsSeen(model.Id) + updated, err := ctx.markAuthTokenAsSeen(userToken.Id) So(err, ShouldBeNil) So(updated, ShouldBeTrue) - model, err = ctx.getAuthTokenByID(model.Id) - So(err, ShouldBeNil) - tok, err := model.toUserToken() + model, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) + var tok auth.UserToken + model.toUserToken(&tok) + getTime = func() time.Time { return t.Add(time.Hour) } - rotated, err = userAuthTokenService.TryRotateToken(tok, "192.168.10.12:1234", "a new user agent") + rotated, err = userAuthTokenService.TryRotateToken(&tok, "192.168.10.12:1234", "a new user agent") So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - unhashedToken := model.UnhashedToken + unhashedToken := tok.UnhashedToken - model, err = ctx.getAuthTokenByID(model.Id) + model, err = ctx.getAuthTokenByID(tok.Id) So(err, ShouldBeNil) model.UnhashedToken = unhashedToken @@ -205,17 +191,15 @@ func TestUserAuthToken(t *testing.T) { lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) So(err, ShouldBeNil) - lookedUpModel, err := extractModelFromToken(lookedUpUserToken) - So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) - So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) - So(lookedUpModel.SeenAt, ShouldEqual, getTime().Unix()) + So(lookedUpUserToken, ShouldNotBeNil) + So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue) + So(lookedUpUserToken.SeenAt, ShouldEqual, getTime().Unix()) lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev) So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) - So(lookedUpModel.Id, ShouldEqual, model.Id) - So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + So(lookedUpUserToken, ShouldNotBeNil) + So(lookedUpUserToken.Id, ShouldEqual, model.Id) + So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue) getTime = func() time.Time { return t.Add(time.Hour + (2 * time.Minute)) @@ -223,12 +207,10 @@ func TestUserAuthToken(t *testing.T) { lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev) So(err, ShouldBeNil) - lookedUpModel, err = extractModelFromToken(lookedUpUserToken) - So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) - So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) + So(lookedUpUserToken, ShouldNotBeNil) + So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue) - lookedUpModel, err = ctx.getAuthTokenByID(lookedUpModel.Id) + lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id) So(err, ShouldBeNil) So(lookedUpModel, ShouldNotBeNil) So(lookedUpModel.AuthTokenSeen, ShouldBeFalse) @@ -237,7 +219,7 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - model, err = ctx.getAuthTokenByID(model.Id) + model, err = ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) So(model, ShouldNotBeNil) So(model.SeenAt, ShouldEqual, 0) @@ -246,11 +228,9 @@ func TestUserAuthToken(t *testing.T) { Convey("keeps prev token valid for 1 minute after it is confirmed", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) + So(userToken, ShouldNotBeNil) - lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) So(lookedUpUserToken, ShouldNotBeNil) @@ -258,7 +238,7 @@ func TestUserAuthToken(t *testing.T) { return t.Add(10 * time.Minute) } - prevToken := model.UnhashedToken + prevToken := userToken.UnhashedToken rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox") So(err, ShouldBeNil) So(rotated, ShouldBeTrue) @@ -267,7 +247,7 @@ func TestUserAuthToken(t *testing.T) { return t.Add(20 * time.Minute) } - currentUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + currentUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) So(currentUserToken, ShouldNotBeNil) @@ -279,23 +259,17 @@ func TestUserAuthToken(t *testing.T) { Convey("will not mark token unseen when prev and current are the same", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) + So(userToken, ShouldNotBeNil) - lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken) + lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) - lookedUpModel, err := extractModelFromToken(lookedUpUserToken) - So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) + So(lookedUpUserToken, ShouldNotBeNil) - lookedUpUserToken, err = userAuthTokenService.LookupToken(model.UnhashedToken) + lookedUpUserToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken) So(err, ShouldBeNil) - lookedUpModel, err = extractModelFromToken(lookedUpUserToken) - So(err, ShouldBeNil) - So(lookedUpModel, ShouldNotBeNil) + So(lookedUpUserToken, ShouldNotBeNil) - lookedUpModel, err = ctx.getAuthTokenByID(lookedUpModel.Id) + lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id) So(err, ShouldBeNil) So(lookedUpModel, ShouldNotBeNil) So(lookedUpModel.AuthTokenSeen, ShouldBeTrue) @@ -304,14 +278,12 @@ func TestUserAuthToken(t *testing.T) { Convey("Rotate token", func() { userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") So(err, ShouldBeNil) - model, err := extractModelFromToken(userToken) - So(err, ShouldBeNil) - So(model, ShouldNotBeNil) + So(userToken, ShouldNotBeNil) - prevToken := model.AuthToken + prevToken := userToken.AuthToken Convey("Should rotate current token and previous token when auth token seen", func() { - updated, err := ctx.markAuthTokenAsSeen(model.Id) + updated, err := ctx.markAuthTokenAsSeen(userToken.Id) So(err, ShouldBeNil) So(updated, ShouldBeTrue) @@ -323,7 +295,7 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - storedToken, err := ctx.getAuthTokenByID(model.Id) + storedToken, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) So(storedToken, ShouldNotBeNil) So(storedToken.AuthTokenSeen, ShouldBeFalse) @@ -332,7 +304,7 @@ func TestUserAuthToken(t *testing.T) { prevToken = storedToken.AuthToken - updated, err = ctx.markAuthTokenAsSeen(model.Id) + updated, err = ctx.markAuthTokenAsSeen(userToken.Id) So(err, ShouldBeNil) So(updated, ShouldBeTrue) @@ -344,7 +316,7 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - storedToken, err = ctx.getAuthTokenByID(model.Id) + storedToken, err = ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) So(storedToken, ShouldNotBeNil) So(storedToken.AuthTokenSeen, ShouldBeFalse) @@ -353,7 +325,7 @@ func TestUserAuthToken(t *testing.T) { }) Convey("Should rotate current token, but keep previous token when auth token not seen", func() { - model.RotatedAt = getTime().Add(-2 * time.Minute).Unix() + userToken.RotatedAt = getTime().Add(-2 * time.Minute).Unix() getTime = func() time.Time { return t.Add(2 * time.Minute) @@ -363,7 +335,7 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) So(rotated, ShouldBeTrue) - storedToken, err := ctx.getAuthTokenByID(model.Id) + storedToken, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) So(storedToken, ShouldNotBeNil) So(storedToken.AuthTokenSeen, ShouldBeFalse) @@ -372,6 +344,71 @@ func TestUserAuthToken(t *testing.T) { }) }) + Convey("When populating userAuthToken from UserToken should copy all properties", func() { + ut := auth.UserToken{ + Id: 1, + UserId: 2, + AuthToken: "a", + PrevAuthToken: "b", + UserAgent: "c", + ClientIp: "d", + AuthTokenSeen: true, + SeenAt: 3, + RotatedAt: 4, + CreatedAt: 5, + UpdatedAt: 6, + UnhashedToken: "e", + } + utBytes, err := json.Marshal(ut) + So(err, ShouldBeNil) + utJSON, err := simplejson.NewJson(utBytes) + So(err, ShouldBeNil) + utMap := utJSON.MustMap() + + var uat userAuthToken + uat.fromUserToken(&ut) + uatBytes, err := json.Marshal(uat) + So(err, ShouldBeNil) + uatJSON, err := simplejson.NewJson(uatBytes) + So(err, ShouldBeNil) + uatMap := uatJSON.MustMap() + + So(uatMap, ShouldResemble, utMap) + }) + + Convey("When populating userToken from userAuthToken should copy all properties", func() { + uat := userAuthToken{ + Id: 1, + UserId: 2, + AuthToken: "a", + PrevAuthToken: "b", + UserAgent: "c", + ClientIp: "d", + AuthTokenSeen: true, + SeenAt: 3, + RotatedAt: 4, + CreatedAt: 5, + UpdatedAt: 6, + UnhashedToken: "e", + } + uatBytes, err := json.Marshal(uat) + So(err, ShouldBeNil) + uatJSON, err := simplejson.NewJson(uatBytes) + So(err, ShouldBeNil) + uatMap := uatJSON.MustMap() + + var ut auth.UserToken + err = uat.toUserToken(&ut) + So(err, ShouldBeNil) + utBytes, err := json.Marshal(ut) + So(err, ShouldBeNil) + utJSON, err := simplejson.NewJson(utBytes) + So(err, ShouldBeNil) + utMap := utJSON.MustMap() + + So(utMap, ShouldResemble, uatMap) + }) + Reset(func() { getTime = time.Now }) diff --git a/pkg/services/auth/authtoken/model.go b/pkg/services/auth/authtoken/model.go index 8bd89c68b04..730f615e294 100644 --- a/pkg/services/auth/authtoken/model.go +++ b/pkg/services/auth/authtoken/model.go @@ -27,50 +27,52 @@ type userAuthToken struct { UnhashedToken string `xorm:"-"` } -func (uat *userAuthToken) toUserToken() (auth.UserToken, error) { +func userAuthTokenFromUserToken(ut *auth.UserToken) *userAuthToken { + var uat userAuthToken + uat.fromUserToken(ut) + return &uat +} + +func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) { + uat.Id = ut.Id + uat.UserId = ut.UserId + uat.AuthToken = ut.AuthToken + uat.PrevAuthToken = ut.PrevAuthToken + uat.UserAgent = ut.UserAgent + uat.ClientIp = ut.ClientIp + uat.AuthTokenSeen = ut.AuthTokenSeen + uat.SeenAt = ut.SeenAt + uat.RotatedAt = ut.RotatedAt + uat.CreatedAt = ut.CreatedAt + uat.UpdatedAt = ut.UpdatedAt + uat.UnhashedToken = ut.UnhashedToken +} + +func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error { if uat == nil { - return nil, fmt.Errorf("needs pointer to userAuthToken struct") + return fmt.Errorf("needs pointer to userAuthToken struct") } - return &userTokenImpl{ - userAuthToken: uat, - }, nil -} + ut.Id = uat.Id + ut.UserId = uat.UserId + ut.AuthToken = uat.AuthToken + ut.PrevAuthToken = uat.PrevAuthToken + ut.UserAgent = uat.UserAgent + ut.ClientIp = uat.ClientIp + ut.AuthTokenSeen = uat.AuthTokenSeen + ut.SeenAt = uat.SeenAt + ut.RotatedAt = uat.RotatedAt + ut.CreatedAt = uat.CreatedAt + ut.UpdatedAt = uat.UpdatedAt + ut.UnhashedToken = uat.UnhashedToken -type userToken interface { - auth.UserToken - GetModel() *userAuthToken -} - -type userTokenImpl struct { - *userAuthToken -} - -func (ut *userTokenImpl) GetUserId() int64 { - return ut.UserId -} - -func (ut *userTokenImpl) GetToken() string { - return ut.UnhashedToken -} - -func (ut *userTokenImpl) GetModel() *userAuthToken { - return ut.userAuthToken -} - -func extractModelFromToken(token auth.UserToken) (*userAuthToken, error) { - ut, ok := token.(userToken) - if !ok { - return nil, fmt.Errorf("failed to cast token") - } - - return ut.GetModel(), nil + return nil } // UserAuthTokenService are used for generating and validating user auth tokens type UserAuthTokenService interface { - CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) - LookupToken(unhashedToken string) (auth.UserToken, error) - TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) - RevokeToken(token auth.UserToken) error + CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) + LookupToken(unhashedToken string) (*auth.UserToken, error) + TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) + RevokeToken(token *auth.UserToken) error } From eb879062f992b38779380c4f12e974271d196b19 Mon Sep 17 00:00:00 2001 From: corpglory-dev Date: Wed, 6 Feb 2019 18:32:22 +0300 Subject: [PATCH 095/144] Rename version_test to version.test --- public/test/core/utils/{version_test.ts => version.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename public/test/core/utils/{version_test.ts => version.test.ts} (100%) diff --git a/public/test/core/utils/version_test.ts b/public/test/core/utils/version.test.ts similarity index 100% rename from public/test/core/utils/version_test.ts rename to public/test/core/utils/version.test.ts From 43b5eba8ee1196c94067c0804602ffb2695a0427 Mon Sep 17 00:00:00 2001 From: corpglory-dev Date: Wed, 6 Feb 2019 18:33:24 +0300 Subject: [PATCH 096/144] Add failing test --- public/test/core/utils/version.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/test/core/utils/version.test.ts b/public/test/core/utils/version.test.ts index 91330389e24..47a31d99e69 100644 --- a/public/test/core/utils/version.test.ts +++ b/public/test/core/utils/version.test.ts @@ -44,6 +44,7 @@ describe('SemVersion', () => { { values: ['3.1.1-beta1', '3.1'], expected: true }, { values: ['3.4.5', '4'], expected: false }, { values: ['3.4.5', '3.5'], expected: false }, + { values: ['6.0.0', '5.2.0'], expected: true }, ]; cases.forEach(testCase => { expect(isVersionGtOrEq(testCase.values[0], testCase.values[1])).toBe(testCase.expected); From 8678620730b26dfa8a07bd0f01d9345d6b266ffd Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 16:45:48 +0100 Subject: [PATCH 097/144] move UserToken and UserTokenService to models package --- pkg/api/http_server.go | 17 ++++---- pkg/api/login.go | 4 +- pkg/middleware/middleware.go | 5 +-- pkg/middleware/middleware_test.go | 41 +++++++++---------- pkg/middleware/org_redirect_test.go | 10 ++--- pkg/middleware/quota_test.go | 5 +-- pkg/models/context.go | 3 +- pkg/models/user_token.go | 32 +++++++++++++++ pkg/services/auth/auth.go | 15 ------- pkg/services/auth/authtoken/auth_token.go | 15 ++++--- .../auth/authtoken/auth_token_test.go | 8 ++-- pkg/services/auth/authtoken/model.go | 16 ++------ 12 files changed, 85 insertions(+), 86 deletions(-) create mode 100644 pkg/models/user_token.go diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index a0a65d73244..cadf6896bf4 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -21,7 +21,6 @@ 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/authtoken" "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" @@ -48,14 +47,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:""` - AuthTokenService authtoken.UserAuthTokenService `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 models.UserTokenService `inject:""` } func (hs *HTTPServer) Init() error { diff --git a/pkg/api/login.go b/pkg/api/login.go index 48f8f237884..106a48dd6a8 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -5,8 +5,6 @@ import ( "net/http" "net/url" - "github.com/grafana/grafana/pkg/services/auth/authtoken" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" @@ -141,7 +139,7 @@ func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { } func (hs *HTTPServer) Logout(c *m.ReqContext) { - if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != authtoken.ErrAuthTokenNotFound { + if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != m.ErrUserTokenNotFound { hs.log.Error("failed to revoke auth token", "error", err) } diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 762a12d09d2..fa335eb10d9 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -10,7 +10,6 @@ import ( "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/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -24,7 +23,7 @@ var ( ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ) -func GetContextHandler(ats authtoken.UserAuthTokenService) macaron.Handler { +func GetContextHandler(ats m.UserTokenService) macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ Context: c, @@ -169,7 +168,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } -func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx *m.ReqContext, orgID int64) bool { +func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool { rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 5852f9d0bcd..7908be6f225 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,7 +11,6 @@ import ( 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/auth" "github.com/grafana/grafana/pkg/services/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" @@ -157,8 +156,8 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 12, UnhashedToken: unhashedToken, }, nil @@ -186,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *auth.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -228,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return nil, authtoken.ErrAuthTokenNotFound } @@ -680,47 +679,47 @@ type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (*auth.UserToken, error) - tryRotateTokenProvider func(token *auth.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (*auth.UserToken, error) - revokeTokenProvider func(token *auth.UserToken) error + createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error) + tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (*m.UserToken, error) + revokeTokenProvider func(token *m.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { - return &auth.UserToken{ + createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - tryRotateTokenProvider: func(token *auth.UserToken, clientIP, userAgent string) (bool, error) { + tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) { return false, nil }, - lookupTokenProvider: func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - revokeTokenProvider: func(token *auth.UserToken) error { + revokeTokenProvider: func(token *m.UserToken) error { return nil }, } } -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) { return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*auth.UserToken, error) { +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) { return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) { +func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) { return s.tryRotateTokenProvider(token, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) RevokeToken(token *auth.UserToken) error { +func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error { return s.revokeTokenProvider(token) } diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 0cc88016f3a..e01d1a68d21 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -3,8 +3,6 @@ package middleware import ( "testing" - "github.com/grafana/grafana/pkg/services/auth" - "fmt" "github.com/grafana/grafana/pkg/bus" @@ -26,8 +24,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 0, UnhashedToken: "", }, nil @@ -52,8 +50,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index df2bf930426..e2a6ef63377 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/bus" 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/smartystreets/goconvey/convey" @@ -81,8 +80,8 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*auth.UserToken, error) { - return &auth.UserToken{ + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/models/context.go b/pkg/models/context.go index bba0024c0f2..b0c6ec9226d 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -14,7 +13,7 @@ import ( type ReqContext struct { *macaron.Context *SignedInUser - UserToken *auth.UserToken + UserToken *UserToken // This should only be used by the auth_proxy Session session.SessionStore diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go new file mode 100644 index 00000000000..c8084cf1eba --- /dev/null +++ b/pkg/models/user_token.go @@ -0,0 +1,32 @@ +package models + +import "errors" + +// Typed errors +var ( + ErrUserTokenNotFound = errors.New("user token not found") +) + +// UserToken represents a user token +type UserToken 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 +} + +// UserTokenService are used for generating and validating user tokens +type UserTokenService interface { + CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error) + LookupToken(unhashedToken string) (*UserToken, error) + TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error) + RevokeToken(token *UserToken) error +} diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index 727ceffff79..8832b06d188 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -1,16 +1 @@ package auth - -type UserToken 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 -} diff --git a/pkg/services/auth/authtoken/auth_token.go b/pkg/services/auth/authtoken/auth_token.go index a993a37dbf3..eedf38e8141 100644 --- a/pkg/services/auth/authtoken/auth_token.go +++ b/pkg/services/auth/authtoken/auth_token.go @@ -5,11 +5,10 @@ import ( "encoding/hex" "time" - "github.com/grafana/grafana/pkg/services/auth" - "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" @@ -40,7 +39,7 @@ func (s *UserAuthTokenServiceImpl) Init() error { return nil } -func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) { +func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { clientIP = util.ParseIPAddress(clientIP) token, err := util.RandomHex(16) if err != nil { @@ -72,13 +71,13 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken) - var userToken auth.UserToken + var userToken models.UserToken err = userAuthToken.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*auth.UserToken, error) { +func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*models.UserToken, error) { hashedToken := hashToken(unhashedToken) if setting.Env == setting.DEV { s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) @@ -137,13 +136,13 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*auth.User model.UnhashedToken = unhashedToken - var userToken auth.UserToken + var userToken models.UserToken err = model.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenServiceImpl) TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) { +func (s *UserAuthTokenServiceImpl) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { if token == nil { return false, nil } @@ -202,7 +201,7 @@ func (s *UserAuthTokenServiceImpl) TryRotateToken(token *auth.UserToken, clientI return false, nil } -func (s *UserAuthTokenServiceImpl) RevokeToken(token *auth.UserToken) error { +func (s *UserAuthTokenServiceImpl) RevokeToken(token *models.UserToken) error { if token == nil { return ErrAuthTokenNotFound } diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/authtoken/auth_token_test.go index a1b70db60fb..3021b0efa4b 100644 --- a/pkg/services/auth/authtoken/auth_token_test.go +++ b/pkg/services/auth/authtoken/auth_token_test.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" . "github.com/smartystreets/goconvey/convey" ) @@ -163,7 +163,7 @@ func TestUserAuthToken(t *testing.T) { model, err := ctx.getAuthTokenByID(userToken.Id) So(err, ShouldBeNil) - var tok auth.UserToken + var tok models.UserToken model.toUserToken(&tok) getTime = func() time.Time { @@ -345,7 +345,7 @@ func TestUserAuthToken(t *testing.T) { }) Convey("When populating userAuthToken from UserToken should copy all properties", func() { - ut := auth.UserToken{ + ut := models.UserToken{ Id: 1, UserId: 2, AuthToken: "a", @@ -397,7 +397,7 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) uatMap := uatJSON.MustMap() - var ut auth.UserToken + var ut models.UserToken err = uat.toUserToken(&ut) So(err, ShouldBeNil) utBytes, err := json.Marshal(ut) diff --git a/pkg/services/auth/authtoken/model.go b/pkg/services/auth/authtoken/model.go index 730f615e294..64123f02bc6 100644 --- a/pkg/services/auth/authtoken/model.go +++ b/pkg/services/auth/authtoken/model.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/models" ) // Typed errors @@ -27,13 +27,13 @@ type userAuthToken struct { UnhashedToken string `xorm:"-"` } -func userAuthTokenFromUserToken(ut *auth.UserToken) *userAuthToken { +func userAuthTokenFromUserToken(ut *models.UserToken) *userAuthToken { var uat userAuthToken uat.fromUserToken(ut) return &uat } -func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) { +func (uat *userAuthToken) fromUserToken(ut *models.UserToken) { uat.Id = ut.Id uat.UserId = ut.UserId uat.AuthToken = ut.AuthToken @@ -48,7 +48,7 @@ func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) { uat.UnhashedToken = ut.UnhashedToken } -func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error { +func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { if uat == nil { return fmt.Errorf("needs pointer to userAuthToken struct") } @@ -68,11 +68,3 @@ func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error { return nil } - -// UserAuthTokenService are used for generating and validating user auth tokens -type UserAuthTokenService interface { - CreateToken(userId int64, clientIP, userAgent string) (*auth.UserToken, error) - LookupToken(unhashedToken string) (*auth.UserToken, error) - TryRotateToken(token *auth.UserToken, clientIP, userAgent string) (bool, error) - RevokeToken(token *auth.UserToken) error -} From 8ae066ab5d30e9049b186479fda64b62de50d544 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 17:02:57 +0100 Subject: [PATCH 098/144] move authtoken package into auth package --- pkg/cmd/grafana-server/server.go | 1 + pkg/services/auth/auth.go | 1 - .../auth/{authtoken => }/auth_token.go | 26 ++++++++----------- .../auth/{authtoken => }/auth_token_test.go | 19 +++++++------- pkg/services/auth/{authtoken => }/model.go | 16 ++++++------ .../session_cleanup.go => token_cleanup.go} | 6 ++--- ..._cleanup_test.go => token_cleanup_test.go} | 2 +- 7 files changed, 34 insertions(+), 37 deletions(-) delete mode 100644 pkg/services/auth/auth.go rename pkg/services/auth/{authtoken => }/auth_token.go (89%) rename pkg/services/auth/{authtoken => }/auth_token_test.go (97%) rename pkg/services/auth/{authtoken => }/model.go (88%) rename pkg/services/auth/{authtoken/session_cleanup.go => token_cleanup.go} (87%) rename pkg/services/auth/{authtoken/session_cleanup_test.go => token_cleanup_test.go} (99%) diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 4781361b9b9..f663e6be895 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -32,6 +32,7 @@ import ( _ "github.com/grafana/grafana/pkg/metrics" _ "github.com/grafana/grafana/pkg/plugins" _ "github.com/grafana/grafana/pkg/services/alerting" + _ "github.com/grafana/grafana/pkg/services/auth" _ "github.com/grafana/grafana/pkg/services/cleanup" _ "github.com/grafana/grafana/pkg/services/notifications" _ "github.com/grafana/grafana/pkg/services/provisioning" diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go deleted file mode 100644 index 8832b06d188..00000000000 --- a/pkg/services/auth/auth.go +++ /dev/null @@ -1 +0,0 @@ -package auth diff --git a/pkg/services/auth/authtoken/auth_token.go b/pkg/services/auth/auth_token.go similarity index 89% rename from pkg/services/auth/authtoken/auth_token.go rename to pkg/services/auth/auth_token.go index eedf38e8141..ef5dccd779f 100644 --- a/pkg/services/auth/authtoken/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -1,4 +1,4 @@ -package authtoken +package auth import ( "crypto/sha256" @@ -16,30 +16,26 @@ import ( ) func init() { - registry.Register(®istry.Descriptor{ - Name: "AuthTokenService", - Instance: &UserAuthTokenServiceImpl{}, - InitPriority: registry.Low, - }) + registry.RegisterService(&UserAuthTokenService{}) } var getTime = time.Now const urgentRotateTime = 1 * time.Minute -type UserAuthTokenServiceImpl struct { +type UserAuthTokenService struct { SQLStore *sqlstore.SqlStore `inject:""` ServerLockService *serverlock.ServerLockService `inject:""` Cfg *setting.Cfg `inject:""` log log.Logger } -func (s *UserAuthTokenServiceImpl) Init() error { +func (s *UserAuthTokenService) Init() error { s.log = log.New("auth") return nil } -func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { +func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { clientIP = util.ParseIPAddress(clientIP) token, err := util.RandomHex(16) if err != nil { @@ -77,7 +73,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent return &userToken, err } -func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*models.UserToken, error) { +func (s *UserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) { hashedToken := hashToken(unhashedToken) if setting.Env == setting.DEV { s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) @@ -95,7 +91,7 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*models.Us } if !exists { - return nil, ErrAuthTokenNotFound + return nil, models.ErrUserTokenNotFound } if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen { @@ -142,7 +138,7 @@ func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*models.Us return &userToken, err } -func (s *UserAuthTokenServiceImpl) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { +func (s *UserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { if token == nil { return false, nil } @@ -201,9 +197,9 @@ func (s *UserAuthTokenServiceImpl) TryRotateToken(token *models.UserToken, clien return false, nil } -func (s *UserAuthTokenServiceImpl) RevokeToken(token *models.UserToken) error { +func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error { if token == nil { - return ErrAuthTokenNotFound + return models.ErrUserTokenNotFound } model := userAuthTokenFromUserToken(token) @@ -215,7 +211,7 @@ func (s *UserAuthTokenServiceImpl) RevokeToken(token *models.UserToken) error { if rowsAffected == 0 { s.log.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) - return ErrAuthTokenNotFound + return models.ErrUserTokenNotFound } s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) diff --git a/pkg/services/auth/authtoken/auth_token_test.go b/pkg/services/auth/auth_token_test.go similarity index 97% rename from pkg/services/auth/authtoken/auth_token_test.go rename to pkg/services/auth/auth_token_test.go index 3021b0efa4b..3313af9f87f 100644 --- a/pkg/services/auth/authtoken/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -1,4 +1,4 @@ -package authtoken +package auth import ( "encoding/json" @@ -46,7 +46,7 @@ func TestUserAuthToken(t *testing.T) { Convey("When lookup hashed token should return user auth token not found error", func() { userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken) - So(err, ShouldEqual, ErrAuthTokenNotFound) + So(err, ShouldEqual, models.ErrUserTokenNotFound) So(userToken, ShouldBeNil) }) @@ -61,13 +61,13 @@ func TestUserAuthToken(t *testing.T) { Convey("revoking nil token should return error", func() { err = userAuthTokenService.RevokeToken(nil) - So(err, ShouldEqual, ErrAuthTokenNotFound) + So(err, ShouldEqual, models.ErrUserTokenNotFound) }) Convey("revoking non-existing token should return error", func() { userToken.Id = 1000 err = userAuthTokenService.RevokeToken(userToken) - So(err, ShouldEqual, ErrAuthTokenNotFound) + So(err, ShouldEqual, models.ErrUserTokenNotFound) }) }) @@ -112,7 +112,7 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) - So(err, ShouldEqual, ErrAuthTokenNotFound) + So(err, ShouldEqual, models.ErrUserTokenNotFound) So(notGood, ShouldBeNil) }) @@ -140,7 +140,7 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken) - So(err, ShouldEqual, ErrAuthTokenNotFound) + So(err, ShouldEqual, models.ErrUserTokenNotFound) So(notGood, ShouldBeNil) }) }) @@ -164,7 +164,8 @@ func TestUserAuthToken(t *testing.T) { So(err, ShouldBeNil) var tok models.UserToken - model.toUserToken(&tok) + err = model.toUserToken(&tok) + So(err, ShouldBeNil) getTime = func() time.Time { return t.Add(time.Hour) @@ -419,7 +420,7 @@ func createTestContext(t *testing.T) *testContext { t.Helper() sqlstore := sqlstore.InitTestDB(t) - tokenService := &UserAuthTokenServiceImpl{ + tokenService := &UserAuthTokenService{ SQLStore: sqlstore, Cfg: &setting.Cfg{ LoginMaxInactiveLifetimeDays: 7, @@ -438,7 +439,7 @@ func createTestContext(t *testing.T) *testContext { type testContext struct { sqlstore *sqlstore.SqlStore - tokenService *UserAuthTokenServiceImpl + tokenService *UserAuthTokenService } func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) { diff --git a/pkg/services/auth/authtoken/model.go b/pkg/services/auth/model.go similarity index 88% rename from pkg/services/auth/authtoken/model.go rename to pkg/services/auth/model.go index 64123f02bc6..36652e70436 100644 --- a/pkg/services/auth/authtoken/model.go +++ b/pkg/services/auth/model.go @@ -1,17 +1,11 @@ -package authtoken +package auth import ( - "errors" "fmt" "github.com/grafana/grafana/pkg/models" ) -// Typed errors -var ( - ErrAuthTokenNotFound = errors.New("user auth token not found") -) - type userAuthToken struct { Id int64 UserId int64 @@ -33,7 +27,11 @@ func userAuthTokenFromUserToken(ut *models.UserToken) *userAuthToken { return &uat } -func (uat *userAuthToken) fromUserToken(ut *models.UserToken) { +func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { + if uat == nil { + return fmt.Errorf("needs pointer to userAuthToken struct") + } + uat.Id = ut.Id uat.UserId = ut.UserId uat.AuthToken = ut.AuthToken @@ -46,6 +44,8 @@ func (uat *userAuthToken) fromUserToken(ut *models.UserToken) { uat.CreatedAt = ut.CreatedAt uat.UpdatedAt = ut.UpdatedAt uat.UnhashedToken = ut.UnhashedToken + + return nil } func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { diff --git a/pkg/services/auth/authtoken/session_cleanup.go b/pkg/services/auth/token_cleanup.go similarity index 87% rename from pkg/services/auth/authtoken/session_cleanup.go rename to pkg/services/auth/token_cleanup.go index 2b8dfb7b4e2..d0e12c9c0e1 100644 --- a/pkg/services/auth/authtoken/session_cleanup.go +++ b/pkg/services/auth/token_cleanup.go @@ -1,11 +1,11 @@ -package authtoken +package auth import ( "context" "time" ) -func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { +func (srv *UserAuthTokenService) Run(ctx context.Context) error { if srv.Cfg.ExpiredTokensCleanupIntervalDays <= 0 { srv.log.Debug("cleanup of expired auth tokens are disabled") return nil @@ -31,7 +31,7 @@ func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error { } } -func (srv *UserAuthTokenServiceImpl) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) { +func (srv *UserAuthTokenService) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) { createdBefore := getTime().Add(-maxLifetime) rotatedBefore := getTime().Add(-maxInactiveLifetime) diff --git a/pkg/services/auth/authtoken/session_cleanup_test.go b/pkg/services/auth/token_cleanup_test.go similarity index 99% rename from pkg/services/auth/authtoken/session_cleanup_test.go rename to pkg/services/auth/token_cleanup_test.go index 7b611b3263c..410764d3f8d 100644 --- a/pkg/services/auth/authtoken/session_cleanup_test.go +++ b/pkg/services/auth/token_cleanup_test.go @@ -1,4 +1,4 @@ -package authtoken +package auth import ( "fmt" From 7762d72ae30537058bd89ff0747846dabdda9f0f Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 6 Feb 2019 17:03:42 +0100 Subject: [PATCH 099/144] Updated stories to use new theming --- packages/grafana-ui/.storybook/config.ts | 7 +++- .../grafana-ui/.storybook/webpack.config.js | 16 +++++++- .../ColorPicker/ColorPicker.story.tsx | 32 +++++++-------- .../ColorPicker/ColorPickerPopover.story.tsx | 41 +++++++------------ .../ColorPicker/NamedColorsPalette.story.tsx | 15 +++++-- .../ColorPicker/SpectrumPalette.story.tsx | 9 ++-- .../src/utils/storybook/withTheme.tsx | 3 +- 7 files changed, 64 insertions(+), 59 deletions(-) diff --git a/packages/grafana-ui/.storybook/config.ts b/packages/grafana-ui/.storybook/config.ts index 9e50c6b501a..434e717bbab 100644 --- a/packages/grafana-ui/.storybook/config.ts +++ b/packages/grafana-ui/.storybook/config.ts @@ -1,10 +1,15 @@ -import { configure } from '@storybook/react'; +import { configure, addDecorator } from '@storybook/react'; +import { withKnobs } from '@storybook/addon-knobs'; +import { withTheme } from '../src/utils/storybook/withTheme'; import '../../../public/sass/grafana.light.scss'; // automatically import all files ending in *.stories.tsx const req = require.context('../src/components', true, /.story.tsx$/); +addDecorator(withKnobs); +addDecorator(withTheme); + function loadStories() { req.keys().forEach(req); } diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js index 44de73a1e18..4f27b71bb60 100644 --- a/packages/grafana-ui/.storybook/webpack.config.js +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -1,7 +1,7 @@ const path = require('path'); +const getThemeVariable = require('../../../scripts/webpack/getThemeVariable'); module.exports = (baseConfig, env, config) => { - config.module.rules.push({ test: /\.(ts|tsx)$/, use: [ @@ -33,7 +33,15 @@ module.exports = (baseConfig, env, config) => { config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' }, }, }, - { loader: 'sass-loader', options: { sourceMap: false } }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + functions: { + 'getThemeVariable($themeVar, $themeName: dark)': getThemeVariable, + }, + }, + }, ], }); @@ -52,5 +60,9 @@ module.exports = (baseConfig, env, config) => { }); config.resolve.extensions.push('.ts', '.tsx'); + + // Remove pure js loading rules as Storybook's Babel config is causing problems when mixing ES6 and CJS + // More about the problem we encounter: https://github.com/webpack/webpack/issues/4039 + config.module.rules = config.module.rules.filter(rule => rule.test.toString() !== /\.(mjs|jsx?)$/.toString()); return config; }; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx index 19ae2fda978..1fb31e86d72 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -1,46 +1,43 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { withKnobs, boolean } from '@storybook/addon-knobs'; +import { boolean } from '@storybook/addon-knobs'; import { SeriesColorPicker, ColorPicker } from './ColorPicker'; import { action } from '@storybook/addon-actions'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { UseState } from '../../utils/storybook/UseState'; -import { getThemeKnob } from '../../utils/storybook/themeKnob'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; const getColorPickerKnobs = () => { return { - selectedTheme: getThemeKnob(), enableNamedColors: boolean('Enable named colors', false), }; }; const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module); -ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs); +ColorPickerStories.addDecorator(withCenteredStory); ColorPickerStories.add('default', () => { - const { selectedTheme, enableNamedColors } = getColorPickerKnobs(); + const { enableNamedColors } = getColorPickerKnobs(); + return ( {(selectedColor, updateSelectedColor) => { - return ( - { - action('Color changed')(color); - updateSelectedColor(color); - }} - theme={selectedTheme || undefined} - /> - ); + return renderComponentWithTheme(ColorPicker, { + enableNamedColors, + color: selectedColor, + onChange: (color: any) => { + action('Color changed')(color); + updateSelectedColor(color); + }, + }); }} ); }); ColorPickerStories.add('Series color picker', () => { - const { selectedTheme, enableNamedColors } = getColorPickerKnobs(); + const { enableNamedColors } = getColorPickerKnobs(); return ( @@ -52,7 +49,6 @@ ColorPickerStories.add('Series color picker', () => { onToggleAxis={() => {}} color={selectedColor} onChange={color => updateSelectedColor(color)} - theme={selectedTheme || undefined} >
Open color picker
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx index dc51819a413..d749588ee31 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx @@ -1,40 +1,27 @@ -import React from 'react'; import { storiesOf } from '@storybook/react'; import { ColorPickerPopover } from './ColorPickerPopover'; -import { withKnobs } from '@storybook/addon-knobs'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; -import { getThemeKnob } from '../../utils/storybook/themeKnob'; import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; - +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module); -ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs); +ColorPickerPopoverStories.addDecorator(withCenteredStory); ColorPickerPopoverStories.add('default', () => { - const selectedTheme = getThemeKnob(); - - return ( - { - console.log(color); - }} - theme={selectedTheme || undefined} - /> - ); + return renderComponentWithTheme(ColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); }); ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => { - const selectedTheme = getThemeKnob(); - - return ( - { - console.log(color); - }} - theme={selectedTheme || undefined} - /> - ); + return renderComponentWithTheme(SeriesColorPickerPopover, { + color: '#BC67E6', + onChange: (color: any) => { + console.log(color); + }, + }); }); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx index af5de3b2a2d..f4901b28bfd 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { NamedColorsPalette } from './NamedColorsPalette'; import { getColorName, getColorDefinitionByName } from '../../utils/namedColorsPalette'; -import { withKnobs, select } from '@storybook/addon-knobs'; +import { select } from '@storybook/addon-knobs'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; import { UseState } from '../../utils/storybook/UseState'; const BasicGreen = getColorDefinitionByName('green'); @@ -12,7 +13,7 @@ const LightBlue = getColorDefinitionByName('light-blue'); const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module); -NamedColorsPaletteStories.addDecorator(withKnobs).addDecorator(withCenteredStory); +NamedColorsPaletteStories.addDecorator(withCenteredStory); NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => { const selectedColor = select( @@ -28,7 +29,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors', return ( {(selectedColor, updateSelectedColor) => { - return ; + return renderComponentWithTheme(NamedColorsPalette, { + color: selectedColor, + onChange: updateSelectedColor, + }); }} ); @@ -45,7 +49,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors', return ( {(selectedColor, updateSelectedColor) => { - return ; + return renderComponentWithTheme(NamedColorsPalette, { + color: getColorName(selectedColor), + onChange: updateSelectedColor, + }); }} ); diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx index b4fdaf69ed9..5fb6c569605 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx @@ -1,22 +1,19 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { withKnobs } from '@storybook/addon-knobs'; import SpectrumPalette from './SpectrumPalette'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { UseState } from '../../utils/storybook/UseState'; -import { getThemeKnob } from '../../utils/storybook/themeKnob'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module); -SpectrumPaletteStories.addDecorator(withCenteredStory).addDecorator(withKnobs); +SpectrumPaletteStories.addDecorator(withCenteredStory); SpectrumPaletteStories.add('default', () => { - const selectedTheme = getThemeKnob(); - return ( {(selectedColor, updateSelectedColor) => { - return ; + return renderComponentWithTheme(SpectrumPalette, { color: selectedColor, onChange: updateSelectedColor }); }} ); diff --git a/packages/grafana-ui/src/utils/storybook/withTheme.tsx b/packages/grafana-ui/src/utils/storybook/withTheme.tsx index b1a9bca013a..5417af1de05 100644 --- a/packages/grafana-ui/src/utils/storybook/withTheme.tsx +++ b/packages/grafana-ui/src/utils/storybook/withTheme.tsx @@ -9,7 +9,6 @@ const ThemableStory: React.FunctionComponent<{}> = ({ children }) => { const themeKnob = select( 'Theme', { - Default: GrafanaThemeType.Dark, Light: GrafanaThemeType.Light, Dark: GrafanaThemeType.Dark, }, @@ -24,6 +23,8 @@ const ThemableStory: React.FunctionComponent<{}> = ({ children }) => { ); }; +// Temporary solution. When we update to Storybook V5 we will be able to pass data from decorator to story +// https://github.com/storybooks/storybook/issues/340#issuecomment-456013702 export const renderComponentWithTheme = (component: React.ComponentType, props: any) => { return ( From 1e4c6b4b527df387946c34d13d3a8b7e58aa20df Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 6 Feb 2019 17:05:22 +0100 Subject: [PATCH 100/144] Added test for SASS variable retrieval function from JS definition --- jest.config.js | 2 ++ packages/grafana-ui/src/themes/index.js | 11 ++++--- scripts/webpack/getThemeVariable.js | 4 +-- scripts/webpack/getThemeVariable.test.js | 40 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 scripts/webpack/getThemeVariable.test.js diff --git a/jest.config.js b/jest.config.js index c5c6bcb9f5f..248435c43f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,11 +6,13 @@ module.exports = { }, "moduleDirectories": ["node_modules", "public"], "roots": [ + "/scripts", "/public/app", "/public/test", "/packages" ], "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": ["webpack.test.js"], "moduleFileExtensions": [ "ts", "tsx", diff --git a/packages/grafana-ui/src/themes/index.js b/packages/grafana-ui/src/themes/index.js index c88cf137574..91ba25349fd 100644 --- a/packages/grafana-ui/src/themes/index.js +++ b/packages/grafana-ui/src/themes/index.js @@ -1,15 +1,16 @@ const darkTheme = require('./dark'); const lightTheme = require('./light'); -const getTheme = name => (name === 'light' ? lightTheme : darkTheme); +let mockedTheme; + +let getTheme = name => mockedTheme || (name === 'light' ? lightTheme : darkTheme); const mockTheme = mock => { - const originalGetTheme = getTheme; - getTheme = () => mock; - return () => (getTheme = originalGetTheme); + mockedTheme = mock; + return () => (mockedTheme = null); }; module.exports = { getTheme, - mockTheme + mockTheme, }; diff --git a/scripts/webpack/getThemeVariable.js b/scripts/webpack/getThemeVariable.js index 6726f95d47c..3bd9b2a53d0 100644 --- a/scripts/webpack/getThemeVariable.js +++ b/scripts/webpack/getThemeVariable.js @@ -29,12 +29,12 @@ function getThemeVariable(variablePath, themeName) { const variable = get(theme, variablePath.getValue()); if (!variable) { - throw new Error(`${variablePath.getValue()} is not defined for ${themeName.getValue()} theme`); + throw new Error(`${variablePath.getValue()} is not defined for ${themeName.getValue()}`); } if (isHex(variable)) { const rgb = new tinycolor(variable).toRgb(); - const color = sass.types.Color(rgb.r, rgb.g, rgb.b); + const color = new sass.types.Color(rgb.r, rgb.g, rgb.b); return color; } diff --git a/scripts/webpack/getThemeVariable.test.js b/scripts/webpack/getThemeVariable.test.js new file mode 100644 index 00000000000..78083330890 --- /dev/null +++ b/scripts/webpack/getThemeVariable.test.js @@ -0,0 +1,40 @@ +const sass = require('node-sass'); +const getThemeVariable = require('./getThemeVariable'); +const { mockTheme } = require('@grafana/ui'); + +const themeMock = { + color: { + background: '#ff0000', + }, + spacing: { + padding: '2em', + }, + typography: { + fontFamily: 'Arial, sans-serif', + }, +}; + +describe('Variables retrieval', () => { + const restoreTheme = mockTheme(themeMock); + + afterAll(() => { + restoreTheme(); + }); + + it('returns sass Color for color values', () => { + const result = getThemeVariable({ getValue: () => 'color.background' }, { getValue: () => {} }); + expect(result).toBeInstanceOf(sass.types.Color); + }); + it('returns sass Number for dimension values', () => { + const result = getThemeVariable({ getValue: () => 'spacing.padding' }, { getValue: () => {} }); + expect(result).toBeInstanceOf(sass.types.Number); + }); + it('returns sass String for string values', () => { + const result = getThemeVariable({ getValue: () => 'typography.fontFamily' }, { getValue: () => {} }); + expect(result).toBeInstanceOf(sass.types.String); + }); + + it('throws for unknown theme paths', () => { + expect(() => getThemeVariable({ getValue: () => 'what.ever' }, { getValue: () => {} })).toThrow(); + }); +}); From dc6b27d123bd069a299a5ce8007fc14e98798fff Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 6 Feb 2019 17:05:43 +0100 Subject: [PATCH 101/144] Minor cleanup --- packages/grafana-ui/src/themes/dark.js | 2 -- .../grafana-ui/src/utils/storybook/themeKnob.ts | 14 -------------- 2 files changed, 16 deletions(-) delete mode 100644 packages/grafana-ui/src/utils/storybook/themeKnob.ts diff --git a/packages/grafana-ui/src/themes/dark.js b/packages/grafana-ui/src/themes/dark.js index 3031018bd56..553eb537093 100644 --- a/packages/grafana-ui/src/themes/dark.js +++ b/packages/grafana-ui/src/themes/dark.js @@ -1,5 +1,3 @@ - - const defaultTheme = require('./default'); const tinycolor = require('tinycolor2'); diff --git a/packages/grafana-ui/src/utils/storybook/themeKnob.ts b/packages/grafana-ui/src/utils/storybook/themeKnob.ts deleted file mode 100644 index a3733462bea..00000000000 --- a/packages/grafana-ui/src/utils/storybook/themeKnob.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { select } from '@storybook/addon-knobs'; -import { GrafanaTheme } from '../../types'; - -export const getThemeKnob = (defaultTheme: GrafanaTheme = GrafanaTheme.Dark) => { - return select( - 'Theme', - { - Default: defaultTheme, - Light: GrafanaTheme.Light, - Dark: GrafanaTheme.Dark, - }, - defaultTheme - ); -}; From 836501186f314c78a69f5ab88ca86b1d1925625c Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 17:30:17 +0100 Subject: [PATCH 102/144] fix --- pkg/middleware/middleware_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 7908be6f225..8545c3856c9 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,7 +11,6 @@ import ( 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/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -228,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { - return nil, authtoken.ErrAuthTokenNotFound + return nil, m.ErrUserTokenNotFound } sc.fakeReq("GET", "/").exec() From 8574dca081002f36e482b572517d8f05fd44453f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 17:31:52 +0100 Subject: [PATCH 103/144] making changes suggested in review and improving typings --- .../dashboard/services/DashboardSrv.ts | 30 +++++++------- .../dashboard/state/DashboardModel.ts | 6 +-- .../app/features/dashboard/state/actions.ts | 7 +--- .../features/dashboard/state/initDashboard.ts | 37 ++++++++++------- public/app/routes/GrafanaCtrl.ts | 11 +++-- public/app/types/dashboard.ts | 41 +++++++++++++++++-- public/app/types/store.ts | 10 +++++ 7 files changed, 97 insertions(+), 45 deletions(-) diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 88eb58ad345..4030765a5a3 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -5,7 +5,7 @@ import { DashboardModel } from '../state/DashboardModel'; import { removePanel } from '../utils/panel'; export class DashboardSrv { - dash: DashboardModel; + dashboard: DashboardModel; /** @ngInject */ constructor(private backendSrv, private $rootScope, private $location) { @@ -19,11 +19,11 @@ export class DashboardSrv { } setCurrent(dashboard: DashboardModel) { - this.dash = dashboard; + this.dashboard = dashboard; } getCurrent(): DashboardModel { - return this.dash; + return this.dashboard; } onRemovePanel = (panelId: number) => { @@ -124,10 +124,10 @@ export class DashboardSrv { } postSave(clone, data) { - this.dash.version = data.version; + this.dashboard.version = data.version; // important that these happens before location redirect below - this.$rootScope.appEvent('dashboard-saved', this.dash); + this.$rootScope.appEvent('dashboard-saved', this.dashboard); this.$rootScope.appEvent('alert-success', ['Dashboard saved']); const newUrl = locationUtil.stripBaseFromUrl(data.url); @@ -137,12 +137,12 @@ export class DashboardSrv { this.$location.url(newUrl).replace(); } - return this.dash; + return this.dashboard; } save(clone, options) { options = options || {}; - options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId; + options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId; return this.backendSrv .saveDashboard(clone, options) @@ -152,26 +152,26 @@ export class DashboardSrv { saveDashboard(options?, clone?) { if (clone) { - this.setCurrent(this.create(clone, this.dash.meta)); + this.setCurrent(this.create(clone, this.dashboard.meta)); } - if (this.dash.meta.provisioned) { + if (this.dashboard.meta.provisioned) { return this.showDashboardProvisionedModal(); } - if (!this.dash.meta.canSave && options.makeEditable !== true) { + if (!this.dashboard.meta.canSave && options.makeEditable !== true) { return Promise.resolve(); } - if (this.dash.title === 'New dashboard') { + if (this.dashboard.title === 'New dashboard') { return this.showSaveAsModal(); } - if (this.dash.version > 0) { + if (this.dashboard.version > 0) { return this.showSaveModal(); } - return this.save(this.dash.getSaveModelClone(), options); + return this.save(this.dashboard.getSaveModelClone(), options); } saveJSONDashboard(json: string) { @@ -212,8 +212,8 @@ export class DashboardSrv { } return promise.then(res => { - if (this.dash && this.dash.id === dashboardId) { - this.dash.meta.isStarred = res; + if (this.dashboard && this.dashboard.id === dashboardId) { + this.dashboard.meta.isStarred = res; } return res; }); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 743eb61f97d..aec6421d12a 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -15,7 +15,7 @@ import sortByKeys from 'app/core/utils/sort_by_keys'; import { PanelModel } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { TimeRange } from '@grafana/ui/src'; -import { UrlQueryValue } from 'app/types'; +import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types'; export class DashboardModel { id: any; @@ -49,7 +49,7 @@ export class DashboardModel { // repeat process cycles iteration: number; - meta: any; + meta: DashboardMeta; events: Emitter; static nonPersistedProperties: { [str: string]: boolean } = { @@ -887,7 +887,7 @@ export class DashboardModel { } // add back navbar height - if (kioskMode === 'tv') { + if (kioskMode === KIOSK_MODE_TV) { visibleHeight += 55; } diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index c25388bbcb8..50ff004ad48 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,6 +1,3 @@ -// Libaries -import { ThunkAction } from 'redux-thunk'; - // Services & Utils import { getBackendSrv } from 'app/core/services/backend_srv'; import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux'; @@ -11,7 +8,7 @@ import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { StoreState } from 'app/types'; +import { ThunkResult } from 'app/types'; import { DashboardAcl, DashboardAclDTO, @@ -26,8 +23,6 @@ export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); export const setDashboardLoadingSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_LOADING_SLOW').create(); -export type ThunkResult = ThunkAction; - export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { const permissions = await getBackendSrv().get(`/api/dashboards/id/${id}/permissions`); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 2c68435b313..941ac332f3c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,10 +1,8 @@ -// Libraries -import { ThunkDispatch } from 'redux-thunk'; - // Services & Utils import { createErrorNotification } from 'app/core/copy/appNotification'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; import { VariableSrv } from 'app/features/templating/variable_srv'; @@ -14,14 +12,19 @@ import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; -import { setDashboardLoadingState, ThunkResult, setDashboardModel, setDashboardLoadingSlow } from './actions'; +import { setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; // Types -import { DashboardLoadingState, DashboardRouteInfo, StoreState } from 'app/types'; +import { + DashboardLoadingState, + DashboardRouteInfo, + StoreState, + ThunkDispatch, + ThunkResult, + DashboardDTO, +} from 'app/types'; import { DashboardModel } from './DashboardModel'; -export type Dispatch = ThunkDispatch; - export interface InitDashboardArgs { $injector: any; $scope: any; @@ -33,7 +36,7 @@ export interface InitDashboardArgs { fixUrl: boolean; } -async function redirectToNewUrl(slug: string, dispatch: Dispatch, currentPath: string) { +async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) { const res = await getBackendSrv().getDashboardBySlug(slug); if (res) { @@ -49,18 +52,22 @@ async function redirectToNewUrl(slug: string, dispatch: Dispatch, currentPath: s } } -async function fetchDashboard(args: InitDashboardArgs, dispatch: Dispatch, getState: () => StoreState): Promise { +async function fetchDashboard( + args: InitDashboardArgs, + dispatch: ThunkDispatch, + getState: () => StoreState +): Promise { try { switch (args.routeInfo) { case DashboardRouteInfo.Home: { // load home dash - const dashDTO = await getBackendSrv().get('/api/dashboards/home'); + const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home'); // if user specified a custom home dashboard redirect to that if (dashDTO.redirectUri) { const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); dispatch(updateLocation({ path: newUrl, replace: true })); - return; + return null; } // disable some actions on the default home dashboard @@ -76,8 +83,8 @@ async function fetchDashboard(args: InitDashboardArgs, dispatch: Dispatch, getSt return null; } - const loaderSrv = args.$injector.get('dashboardLoaderSrv'); - const dashDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); + const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv'); + const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); if (args.fixUrl && dashDTO.meta.url) { // check if the current url is correct (might be old slug) @@ -95,6 +102,8 @@ async function fetchDashboard(args: InitDashboardArgs, dispatch: Dispatch, getSt case DashboardRouteInfo.New: { return getNewDashboardModelData(args.urlFolderId); } + default: + throw { message: 'Unknown route ' + args.routeInfo }; } } catch (err) { dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); @@ -149,7 +158,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { } // add missing orgId query param - const storeState = getState() ; + const storeState = getState(); if (!storeState.location.query.orgId) { dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true })); } diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index c2d5dd4d69d..a6d97856e74 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -1,9 +1,11 @@ -import config from 'app/core/config'; +// Libraries import _ from 'lodash'; import $ from 'jquery'; import Drop from 'tether-drop'; -import { colors } from '@grafana/ui'; +// Utils and servies +import { colors } from '@grafana/ui'; +import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import { profiler } from 'app/core/profiler'; import appEvents from 'app/core/app_events'; @@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader'; import { configureStore } from 'app/store/configureStore'; +// Types +import { KioskUrlValue } from 'app/types'; + export class GrafanaCtrl { /** @ngInject */ constructor( @@ -67,7 +72,7 @@ export class GrafanaCtrl { } } -function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) { +function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) { body.removeClass('view-mode--tv'); body.removeClass('view-mode--kiosk'); body.removeClass('view-mode--inactive'); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 39d7e3cba8a..fac8761fa7a 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,10 +1,40 @@ import { DashboardAcl } from './acl'; export interface MutableDashboard { - meta: { - fullscreen: boolean; - isEditing: boolean; - }; + meta: DashboardMeta; +} + +export interface DashboardDTO { + redirectUri?: string; + dashboard: DashboardDataDTO; + meta: DashboardMeta; +} + +export interface DashboardMeta { + canSave?: boolean; + canEdit?: boolean; + canShare?: boolean; + canStar?: boolean; + canAdmin?: boolean; + url?: string; + folderId?: number; + fullscreen?: boolean; + isEditing?: boolean; + canMakeEditable?: boolean; + submenuEnabled?: boolean; + provisioned?: boolean; + focusPanelId?: boolean; + isStarred?: boolean; + showSettings?: boolean; + expires: string; + isSnapshot?: boolean; + folderTitle?: string; + folderUrl?: string; + created?: string; +} + +export interface DashboardDataDTO { + title: string; } export enum DashboardRouteInfo { @@ -22,6 +52,9 @@ export enum DashboardLoadingState { Done = 'Done', } +export const KIOSK_MODE_TV = 'tv'; +export type KioskUrlValue = 'tv' | '1' | true; + export interface DashboardState { model: MutableDashboard | null; loadingState: DashboardLoadingState; diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 78832052e96..72a8cf066f1 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -1,3 +1,6 @@ +import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk'; +import { ActionOf } from 'app/core/redux'; + import { NavIndex } from './navModel'; import { LocationState } from './location'; import { AlertRulesState } from './alerting'; @@ -27,3 +30,10 @@ export interface StoreState { user: UserState; plugins: PluginsState; } + +/* + * Utility type to get strongly types thunks + */ +export type ThunkResult = ThunkAction>; + +export type ThunkDispatch = GenericThunkDispatch; From ee132c1091dc2fc29caaf5e38a3886da6fc82283 Mon Sep 17 00:00:00 2001 From: corpglory-dev Date: Wed, 6 Feb 2019 19:59:28 +0300 Subject: [PATCH 104/144] Fix SemVersion.isGtOrEq --- public/app/core/utils/version.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/public/app/core/utils/version.ts b/public/app/core/utils/version.ts index 1131e1d2ab8..746de761fa3 100644 --- a/public/app/core/utils/version.ts +++ b/public/app/core/utils/version.ts @@ -20,12 +20,25 @@ export class SemVersion { isGtOrEq(version: string): boolean { const compared = new SemVersion(version); - return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch); + + for (let i = 0; i < this.comparable.length; ++i) { + if (this.comparable[i] > compared.comparable[i]) { + return true; + } + if (this.comparable[i] < compared.comparable[i]) { + return false; + } + } + return true; } isValid(): boolean { return _.isNumber(this.major); } + + get comparable() { + return [this.major, this.minor, this.patch]; + } } export function isVersionGtOrEq(a: string, b: string): boolean { From dd0afd0a0beae53d0bdaa8232811862cf17d49da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 19:42:04 +0100 Subject: [PATCH 105/144] Big refactoring for dashboard init redux actions --- .../app/core/components/AlertBox/AlertBox.tsx | 41 ++++++ .../AppNotifications/AppNotificationItem.tsx | 20 ++- public/app/core/copy/appNotification.ts | 15 +-- public/app/core/utils/errors.ts | 15 +++ .../components/DashNav/DashNavCtrl.ts | 117 ------------------ .../dashboard/components/DashNav/index.ts | 1 - .../containers/DashboardPage.test.tsx | 12 +- .../dashboard/containers/DashboardPage.tsx | 54 +++++--- .../dashboard/containers/SoloPanelPage.tsx | 1 - .../app/features/dashboard/state/actions.ts | 28 ++++- .../dashboard/state/initDashboard.test.ts | 10 +- .../features/dashboard/state/initDashboard.ts | 33 +++-- .../app/features/dashboard/state/reducers.ts | 70 ++++++++--- public/app/types/dashboard.ts | 19 ++- public/sass/pages/_dashboard.scss | 5 + 15 files changed, 227 insertions(+), 214 deletions(-) create mode 100644 public/app/core/components/AlertBox/AlertBox.tsx create mode 100644 public/app/core/utils/errors.ts delete mode 100644 public/app/features/dashboard/components/DashNav/DashNavCtrl.ts diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx new file mode 100644 index 00000000000..2de43bbd0b2 --- /dev/null +++ b/public/app/core/components/AlertBox/AlertBox.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent } from 'react'; +import { AppNotificationSeverity } from 'app/types'; + +interface Props { + title: string; + icon?: string; + text?: string; + severity: AppNotificationSeverity; + onClose?: () => void; +} + +function getIconFromSeverity(severity: AppNotificationSeverity): string { + switch (severity) { + case AppNotificationSeverity.Error: { + return 'fa fa-exclamation-triangle'; + } + case AppNotificationSeverity.Success: { + return 'fa fa-check'; + } + default: return null; + } +} + +export const AlertBox: FunctionComponent = ({ title, icon, text, severity, onClose }) => { + return ( +
+
+ +
+
+
{title}
+ {text &&
{text}
} +
+ {onClose && ( + + )} +
+ ); +}; diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx index 6b4b268eb13..d1fc506d54c 100644 --- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { AppNotification } from 'app/types'; +import { AlertBox } from '../AlertBox/AlertBox'; interface Props { appNotification: AppNotification; @@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component { const { appNotification, onClearNotification } = this.props; return ( -
-
- -
-
-
{appNotification.title}
-
{appNotification.text}
-
- -
+ onClearNotification(appNotification.id)} + /> ); } } diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 0062cd08fa6..2869c121fa8 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,5 +1,5 @@ -import _ from 'lodash'; import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; +import { getMessageFromError } from 'app/core/utils/errors'; const defaultSuccessNotification: AppNotification = { title: '', @@ -33,21 +33,10 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti }); export const createErrorNotification = (title: string, text?: any): AppNotification => { - // Handling if text is an error object - if (text && !_.isString(text)) { - if (text.message) { - text = text.message; - } else if (text.data && text.data.message) { - text = text.data.message; - } else { - text = text.toString(); - } - } - return { ...defaultErrorNotification, title: title, - text: text, + text: getMessageFromError(text), id: Date.now(), }; }; diff --git a/public/app/core/utils/errors.ts b/public/app/core/utils/errors.ts new file mode 100644 index 00000000000..52a7c39f713 --- /dev/null +++ b/public/app/core/utils/errors.ts @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +export function getMessageFromError(err: any): string | null { + if (err && !_.isString(err)) { + if (err.message) { + return err.message; + } else if (err.data && err.data.message) { + return err.data.message; + } else { + return JSON.stringify(err); + } + } + + return null; +} diff --git a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts deleted file mode 100644 index fbf84d354e3..00000000000 --- a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts +++ /dev/null @@ -1,117 +0,0 @@ -import moment from 'moment'; -import angular from 'angular'; -import { appEvents, NavModel } from 'app/core/core'; -import { DashboardModel } from '../../state/DashboardModel'; - -export class DashNavCtrl { - dashboard: DashboardModel; - navModel: NavModel; - titleTooltip: string; - - /** @ngInject */ - constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) { - if (this.dashboard.meta.isSnapshot) { - const meta = this.dashboard.meta; - this.titleTooltip = 'Created:  ' + moment(meta.created).calendar(); - if (meta.expires) { - this.titleTooltip += '
Expires:  ' + moment(meta.expires).fromNow() + '
'; - } - } - } - - toggleSettings() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else { - search.editview = 'settings'; - } - this.$location.search(search); - } - - toggleViewMode() { - appEvents.emit('toggle-kiosk-mode'); - } - - close() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else if (search.fullscreen) { - delete search.fullscreen; - delete search.edit; - delete search.tab; - delete search.panelId; - } - this.$location.search(search); - } - - starDashboard() { - this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => { - this.dashboard.meta.isStarred = newState; - }); - } - - shareDashboard(tabIndex) { - const modalScope = this.$scope.$new(); - modalScope.tabIndex = tabIndex; - modalScope.dashboard = this.dashboard; - - appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - scope: modalScope, - }); - } - - hideTooltip(evt) { - angular.element(evt.currentTarget).tooltip('hide'); - } - - saveDashboard() { - return this.dashboardSrv.saveDashboard(); - } - - showSearch() { - if (this.dashboard.meta.fullscreen) { - this.close(); - return; - } - - appEvents.emit('show-dash-search'); - } - - addPanel() { - appEvents.emit('dash-scroll', { animate: true, evt: 0 }); - - if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') { - return; // Return if the "Add panel" exists already - } - - this.dashboard.addPanel({ - type: 'add-panel', - gridPos: { x: 0, y: 0, w: 12, h: 8 }, - title: 'Panel Title', - }); - } - - navItemClicked(navItem, evt) { - if (navItem.clickHandler) { - navItem.clickHandler(); - evt.preventDefault(); - } - } -} - -export function dashNavDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/DashNav/template.html', - controller: DashNavCtrl, - bindToController: true, - controllerAs: 'ctrl', - transclude: true, - scope: { dashboard: '=' }, - }; -} - -angular.module('grafana.directives').directive('dashnav', dashNavDirective); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts index cfa9003cd8a..be07fd0d2a3 100644 --- a/public/app/features/dashboard/components/DashNav/index.ts +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -1,3 +1,2 @@ -export { DashNavCtrl } from './DashNavCtrl'; import DashNav from './DashNav'; export { DashNav }; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 59e71c69757..7ef4918aa90 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { DashboardPage, Props, State } from './DashboardPage'; import { DashboardModel } from '../state'; -import { setDashboardModel } from '../state/actions'; -import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; +import { cleanUpDashboard } from '../state/actions'; +import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; jest.mock('sass/_variables.scss', () => ({ panelhorizontalpadding: 10, @@ -22,13 +22,13 @@ function setup(propOverrides?: Partial): ShallowWrapper { canEdit: true, canSave: true, }); - wrapper.setProps({ dashboard, loadingState: DashboardLoadingState.Done }); + wrapper.setProps({ dashboard, initPhase: DashboardInitPhase.Completed }); }); it('Should update title', () => { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 3e018e3a5f0..0773d85b368 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; // Services & Utils import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getMessageFromError } from 'app/core/utils/errors'; // Components import { DashboardGrid } from '../dashgrid/DashboardGrid'; @@ -14,15 +15,22 @@ import { DashNav } from '../components/DashNav'; import { SubMenu } from '../components/SubMenu'; import { DashboardSettings } from '../components/DashboardSettings'; import { CustomScrollbar } from '@grafana/ui'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; // Redux import { initDashboard } from '../state/initDashboard'; -import { setDashboardModel } from '../state/actions'; +import { cleanUpDashboard } from '../state/actions'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { StoreState, DashboardLoadingState, DashboardRouteInfo } from 'app/types'; +import { + StoreState, + DashboardInitPhase, + DashboardRouteInfo, + DashboardInitError, + AppNotificationSeverity, +} from 'app/types'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; export interface Props { @@ -37,11 +45,12 @@ export interface Props { routeInfo: DashboardRouteInfo; urlEdit: boolean; urlFullscreen: boolean; - loadingState: DashboardLoadingState; - isLoadingSlow: boolean; + initPhase: DashboardInitPhase; + isInitSlow: boolean; dashboard: DashboardModel | null; + initError?: DashboardInitError; initDashboard: typeof initDashboard; - setDashboardModel: typeof setDashboardModel; + cleanUpDashboard: typeof cleanUpDashboard; notifyApp: typeof notifyApp; updateLocation: typeof updateLocation; } @@ -83,7 +92,7 @@ export class DashboardPage extends PureComponent { componentWillUnmount() { if (this.props.dashboard) { this.props.dashboard.destroy(); - this.props.setDashboardModel(null); + this.props.cleanUpDashboard(); } } @@ -204,23 +213,37 @@ export class DashboardPage extends PureComponent { this.setState({ scrollTop: 0 }); }; - renderLoadingState() { + renderSlowInitState() { return (
- Dashboard {this.props.loadingState} + {this.props.initPhase}
); } + renderInitFailedState() { + const { initError } = this.props; + + return ( +
+ +
+ ); + } + render() { - const { dashboard, editview, $injector, isLoadingSlow } = this.props; + const { dashboard, editview, $injector, isInitSlow, initError } = this.props; const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state; if (!dashboard) { - if (isLoadingSlow) { - return this.renderLoadingState(); + if (isInitSlow) { + return this.renderSlowInitState(); } return null; } @@ -249,6 +272,8 @@ export class DashboardPage extends PureComponent { {editview && } + {initError && this.renderInitFailedState()} +
{dashboard.meta.submenuEnabled && } @@ -269,14 +294,15 @@ const mapStateToProps = (state: StoreState) => ({ urlFolderId: state.location.query.folderId, urlFullscreen: state.location.query.fullscreen === true, urlEdit: state.location.query.edit === true, - loadingState: state.dashboard.loadingState, - isLoadingSlow: state.dashboard.isLoadingSlow, + initPhase: state.dashboard.initPhase, + isInitSlow: state.dashboard.isInitSlow, + initError: state.dashboard.initError, dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { initDashboard, - setDashboardModel, + cleanUpDashboard, notifyApp, updateLocation, }; diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 915d2e03965..6dcf2775547 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -100,7 +100,6 @@ const mapStateToProps = (state: StoreState) => ({ urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, urlPanelId: state.location.query.panelId, - loadingState: state.dashboard.loadingState, dashboard: state.dashboard.model as DashboardModel, }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 50ff004ad48..f5911a233f7 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -8,20 +8,36 @@ import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { ThunkResult } from 'app/types'; import { + ThunkResult, DashboardAcl, DashboardAclDTO, PermissionLevel, DashboardAclUpdateDTO, NewDashboardAclItem, -} from 'app/types/acl'; -import { DashboardLoadingState, MutableDashboard } from 'app/types/dashboard'; + MutableDashboard, + DashboardInitError, +} from 'app/types'; export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); -export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_LOADING_STATE').create(); -export const setDashboardModel = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); -export const setDashboardLoadingSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_LOADING_SLOW').create(); + +export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create(); + +export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create(); + +export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create(); + +export const dashboardInitCompleted = actionCreatorFactory('DASHBOARD_INIT_COMLETED').create(); + +/* + * Unrecoverable init failure (fetch or model creation failed) + */ +export const dashboardInitFailed = actionCreatorFactory('DASHBOARD_INIT_FAILED').create(); + +/* + * When leaving dashboard, resets state + * */ +export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create(); export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index eebeb5010fb..86d75e883cc 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -1,7 +1,7 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { initDashboard, InitDashboardArgs } from './initDashboard'; -import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; +import { DashboardRouteInfo } from 'app/types'; const mockStore = configureMockStore([thunk]); @@ -98,13 +98,11 @@ describeInitScenario('Initializing new dashboard', ctx => { }); it('Should send action to set loading state to fetching', () => { - expect(ctx.actions[0].type).toBe('SET_DASHBOARD_LOADING_STATE'); - expect(ctx.actions[0].payload).toBe(DashboardLoadingState.Fetching); + expect(ctx.actions[0].type).toBe('DASHBOARD_INIT_FETCHING'); }); it('Should send action to set loading state to Initializing', () => { - expect(ctx.actions[1].type).toBe('SET_DASHBOARD_LOADING_STATE'); - expect(ctx.actions[1].payload).toBe(DashboardLoadingState.Initializing); + expect(ctx.actions[1].type).toBe('DASHBOARD_INIT_SERVICES'); }); it('Should update location with orgId query param', () => { @@ -113,7 +111,7 @@ describeInitScenario('Initializing new dashboard', ctx => { }); it('Should send action to set dashboard model', () => { - expect(ctx.actions[3].type).toBe('SET_DASHBOARD_MODEL'); + expect(ctx.actions[3].type).toBe('DASHBOARD_INIT_COMLETED'); expect(ctx.actions[3].payload.title).toBe('New dashboard'); }); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 941ac332f3c..e6f83780430 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -12,17 +12,16 @@ import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; -import { setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; +import { + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitSlow, + dashboardInitServices, +} from './actions'; // Types -import { - DashboardLoadingState, - DashboardRouteInfo, - StoreState, - ThunkDispatch, - ThunkResult, - DashboardDTO, -} from 'app/types'; +import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types'; import { DashboardModel } from './DashboardModel'; export interface InitDashboardArgs { @@ -106,8 +105,7 @@ async function fetchDashboard( throw { message: 'Unknown route ' + args.routeInfo }; } } catch (err) { - dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); - dispatch(notifyApp(createErrorNotification('Dashboard fetch failed', err))); + dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err })); console.log(err); return null; } @@ -125,13 +123,13 @@ async function fetchDashboard( export function initDashboard(args: InitDashboardArgs): ThunkResult { return async (dispatch, getState) => { // set fetching state - dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + dispatch(dashboardInitFetching()); // Detect slow loading / initializing and set state flag // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing setTimeout(() => { if (getState().dashboard.model === null) { - dispatch(setDashboardLoadingSlow()); + dispatch(dashboardInitSlow()); } }, 500); @@ -144,15 +142,14 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { } // set initializing state - dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing)); + dispatch(dashboardInitServices()); // create model let dashboard: DashboardModel; try { dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); } catch (err) { - dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); - dispatch(notifyApp(createErrorNotification('Dashboard model initializing failure', err))); + dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err })); console.log(err); return; } @@ -203,8 +200,8 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { // legacy srv state dashboardSrv.setCurrent(dashboard); - // set model in redux (even though it's mutable) - dispatch(setDashboardModel(dashboard)); + // yay we are done + dispatch(dashboardInitCompleted(dashboard)); }; } diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 5566363c996..ecf34f2f1a3 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,11 +1,20 @@ -import { DashboardState, DashboardLoadingState } from 'app/types/dashboard'; -import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; +import { DashboardState, DashboardInitPhase } from 'app/types'; +import { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitSlow, + dashboardInitServices, + dashboardInitFailed, + dashboardInitCompleted, + cleanUpDashboard, +} from './actions'; import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; +import { DashboardModel } from './DashboardModel'; export const initialState: DashboardState = { - loadingState: DashboardLoadingState.NotStarted, - isLoadingSlow: false, + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, model: null, permissions: [], }; @@ -19,26 +28,59 @@ export const dashboardReducer = reducerFactory(initialState) }), }) .addMapper({ - filter: setDashboardLoadingState, - mapper: (state, action) => ({ + filter: dashboardInitFetching, + mapper: state => ({ ...state, - loadingState: action.payload + initPhase: DashboardInitPhase.Fetching, }), }) .addMapper({ - filter: setDashboardModel, + filter: dashboardInitServices, + mapper: state => ({ + ...state, + initPhase: DashboardInitPhase.Services, + }), + }) + .addMapper({ + filter: dashboardInitSlow, + mapper: state => ({ + ...state, + isInitSlow: true, + }), + }) + .addMapper({ + filter: dashboardInitFailed, mapper: (state, action) => ({ ...state, + initPhase: DashboardInitPhase.Failed, + isInitSlow: false, + initError: action.payload, + model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }), + }), + }) + .addMapper({ + filter: dashboardInitCompleted, + mapper: (state, action) => ({ + ...state, + initPhase: DashboardInitPhase.Completed, model: action.payload, - isLoadingSlow: false, + isInitSlow: false, }), }) .addMapper({ - filter: setDashboardLoadingSlow, - mapper: (state, action) => ({ - ...state, - isLoadingSlow: true, - }), + filter: cleanUpDashboard, + mapper: (state, action) => { + // tear down current dashboard + state.model.destroy(); + + return { + ...state, + initPhase: DashboardInitPhase.NotStarted, + model: null, + isInitSlow: false, + initError: null, + }; + }, }) .create(); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index fac8761fa7a..9bcf258cf76 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -2,6 +2,7 @@ import { DashboardAcl } from './acl'; export interface MutableDashboard { meta: DashboardMeta; + destroy: () => void; } export interface DashboardDTO { @@ -44,12 +45,17 @@ export enum DashboardRouteInfo { Scripted = 'scripted-dashboard', } -export enum DashboardLoadingState { +export enum DashboardInitPhase { NotStarted = 'Not started', Fetching = 'Fetching', - Initializing = 'Initializing', - Error = 'Error', - Done = 'Done', + Services = 'Services', + Failed = 'Failed', + Completed = 'Completed', +} + +export interface DashboardInitError { + message: string; + error: any; } export const KIOSK_MODE_TV = 'tv'; @@ -57,7 +63,8 @@ export type KioskUrlValue = 'tv' | '1' | true; export interface DashboardState { model: MutableDashboard | null; - loadingState: DashboardLoadingState; - isLoadingSlow: boolean; + initPhase: DashboardInitPhase; + isInitSlow: boolean; + initError?: DashboardInitError; permissions: DashboardAcl[] | null; } diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 0f37ffc850e..c02c9227d29 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -282,6 +282,11 @@ div.flot-text { display: flex; align-items: center; justify-content: center; + + .alert { + max-width: 600px; + min-width: 600px; + } } .dashboard-loading__text { From a4841a72d9dc9e6f5e147cc3b5c9243c3d53b54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 21:04:18 +0100 Subject: [PATCH 106/144] Improved dashboard page test --- .../containers/DashboardPage.test.tsx | 256 +++++++++----- .../dashboard/containers/DashboardPage.tsx | 2 +- .../__snapshots__/DashboardPage.test.tsx.snap | 332 +++++++++++++++++- .../app/features/dashboard/state/reducers.ts | 4 +- 4 files changed, 505 insertions(+), 89 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 7ef4918aa90..41b09a7c361 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -10,117 +10,205 @@ jest.mock('sass/_variables.scss', () => ({ panelVerticalPadding: 10, })); -jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({ -})); +jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); -function setup(propOverrides?: Partial): ShallowWrapper { - const props: Props = { - urlUid: '11', - urlSlug: 'my-dash', - $scope: {}, - $injector: {}, - routeInfo: DashboardRouteInfo.Normal, - urlEdit: false, - urlFullscreen: false, - initPhase: DashboardInitPhase.Completed, - isInitSlow: false, - initDashboard: jest.fn(), - updateLocation: jest.fn(), - notifyApp: jest.fn(), - dashboard: null, - cleanUpDashboard: cleanUpDashboard, - }; +interface ScenarioContext { + dashboard?: DashboardModel; + setDashboardProp: (overrides?: any, metaOverrides?: any) => void; + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup?: (fn: () => void) => void; +} - Object.assign(props, propOverrides); - return shallow(); +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign({ + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, overrides); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + setup: fn => { + setupFn = fn; + }, + setDashboardProp: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + ctx.wrapper.setProps({ dashboard: ctx.dashboard }); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + urlSlug: 'my-dash', + $scope: {}, + urlUid: '11', + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + urlEdit: false, + urlFullscreen: false, + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + initDashboard: jest.fn(), + updateLocation: jest.fn(), + notifyApp: jest.fn(), + cleanUpDashboard: cleanUpDashboard, + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + ctx.wrapper = shallow(); + } + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); } describe('DashboardPage', () => { - let wrapper: ShallowWrapper; - beforeEach(() => { - wrapper = setup(); - }); + dashboardPageScenario("Given initial state", (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); - describe('Given dashboard has not loaded yet', () => { it('should render nothing', () => { - expect(wrapper).toMatchSnapshot(); + expect(ctx.wrapper).toMatchSnapshot(); }); }); - describe('Given dashboard model', () => { - let dashboard: DashboardModel; - - beforeEach(() => { - dashboard = new DashboardModel({ - title: 'My dashboard', - panels: [ - { - id: 1, - type: 'graph', - title: 'My graph', - gridPos: { x: 0, y: 0, w: 1, h: 1 } - } - ] - }, { - canEdit: true, - canSave: true, + dashboardPageScenario("Dashboard is fetching slowly", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.wrapper.setProps({ + isInitSlow: true, + initPhase: DashboardInitPhase.Fetching, }); - wrapper.setProps({ dashboard, initPhase: DashboardInitPhase.Completed }); + }); + + it('should render slow init state', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario("Dashboard init completed ", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); }); it('Should update title', () => { expect(document.title).toBe('My dashboard - Grafana'); }); - it('After render dashboard', () => { - expect(wrapper).toMatchSnapshot(); + it('Should render dashboard grid', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario("where user goes into panel edit", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setProps({ + urlFullscreen: true, + urlEdit: true, + urlPanelId: '1', + }); }); - describe('Given user has scrolled down and goes into fullscreen edit', () => { - beforeEach(() => { - wrapper.setState({ scrollTop: 100 }); - wrapper.setProps({ - urlFullscreen: true, - urlEdit: true, - urlPanelId: '1', - }); + it('Should update model state to fullscreen & edit', () => { + expect(ctx.dashboard.meta.fullscreen).toBe(true); + expect(ctx.dashboard.meta.isEditing).toBe(true); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper.state(); + expect(state.isEditing).toBe(true); + expect(state.isFullscreen).toBe(true); + expect(state.rememberScrollTop).toBe(100); + }); + }); + + dashboardPageScenario("where user goes back to dashboard from panel edit", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setState({ scrollTop: 100 }); + ctx.wrapper.setProps({ + urlFullscreen: true, + urlEdit: true, + urlPanelId: '1', }); - - it('Should update model state to fullscreen & edit', () => { - expect(dashboard.meta.fullscreen).toBe(true); - expect(dashboard.meta.isEditing).toBe(true); + ctx.wrapper.setProps({ + urlFullscreen: false, + urlEdit: false, + urlPanelId: null, }); + }); - it('Should update component state to fullscreen and edit', () => { - const state = wrapper.state(); - expect(state.isEditing).toBe(true); - expect(state.isFullscreen).toBe(true); - expect(state.rememberScrollTop).toBe(100); + it('Should update model state normal state', () => { + expect(ctx.dashboard.meta.fullscreen).toBe(false); + expect(ctx.dashboard.meta.isEditing).toBe(false); + }); + + it('Should update component state to normal and restore scrollTop', () => { + const state = ctx.wrapper.state(); + expect(state.isEditing).toBe(false); + expect(state.isFullscreen).toBe(false); + expect(state.scrollTop).toBe(100); + }); + }); + + dashboardPageScenario("When dashboard has editview url state", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setProps({ + editview: 'settings', }); + }); - describe('Given user goes back to dashboard', () => { - beforeEach(() => { - wrapper.setState({ scrollTop: 0 }); - wrapper.setProps({ - urlFullscreen: false, - urlEdit: false, - urlPanelId: null, - }); - }); + it('should render settings view', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); - it('Should update model state normal state', () => { - expect(dashboard.meta.fullscreen).toBe(false); - expect(dashboard.meta.isEditing).toBe(false); - }); + it('should set animation state', () => { + expect(ctx.wrapper.state().isSettingsOpening).toBe(true); + }); + }); - it('Should update component state to normal and restore scrollTop', () => { - const state = wrapper.state(); - expect(state.isEditing).toBe(false); - expect(state.isFullscreen).toBe(false); - expect(state.scrollTop).toBe(100); - }); - }); + dashboardPageScenario("When adding panel", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setState({ scrollTop: 100 }); + ctx.wrapper.instance().onAddPanel(); + }); + + it('should set scrollTop to 0', () => { + expect(ctx.wrapper.state().scrollTop).toBe(0); + }); + + it('should add panel widget to dashboard panels', () => { + expect(ctx.dashboard.panels[0].type).toBe('add-panel'); }); }); }); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0773d85b368..4e059e9ee6c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -90,8 +90,8 @@ export class DashboardPage extends PureComponent { } componentWillUnmount() { + console.log('unmount', this.props.cleanUpDashboard); if (this.props.dashboard) { - this.props.dashboard.destroy(); this.props.cleanUpDashboard(); } } diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index d3808513e7b..c1aa5972b6d 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -1,8 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DashboardPage Given dashboard has not loaded yet should render nothing 1`] = `""`; - -exports[`DashboardPage Given dashboard model After render dashboard 1`] = ` +exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = `
@@ -218,3 +216,331 @@ exports[`DashboardPage Given dashboard model After render dashboard 1`] = `
`; + +exports[`DashboardPage Dashboard is fetching slowly should render slow init state 1`] = ` +
+
+ + + Fetching +
+
+`; + +exports[`DashboardPage Given initial state should render nothing 1`] = `""`; + +exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = ` +
+ +
+ + +
+ +
+
+
+
+`; diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index ecf34f2f1a3..7a320beee93 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -70,7 +70,9 @@ export const dashboardReducer = reducerFactory(initialState) .addMapper({ filter: cleanUpDashboard, mapper: (state, action) => { - // tear down current dashboard + + // Destroy current DashboardModel + // Very important as this removes all dashboard event listeners state.model.destroy(); return { From 961695a61fd1b6bf631e399ea03acba6a92af7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 21:07:01 +0100 Subject: [PATCH 107/144] Improved dashboard page test --- .../features/dashboard/containers/DashboardPage.test.tsx | 9 ++++----- .../containers/__snapshots__/DashboardPage.test.tsx.snap | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 41b09a7c361..94aa0b32d46 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -89,7 +89,7 @@ describe('DashboardPage', () => { ctx.mount(); }); - it('should render nothing', () => { + it('Should render nothing', () => { expect(ctx.wrapper).toMatchSnapshot(); }); }); @@ -103,7 +103,7 @@ describe('DashboardPage', () => { }); }); - it('should render slow init state', () => { + it('Should render slow init state', () => { expect(ctx.wrapper).toMatchSnapshot(); }); }); @@ -123,7 +123,7 @@ describe('DashboardPage', () => { }); }); - dashboardPageScenario("where user goes into panel edit", (ctx) => { + dashboardPageScenario("When user goes into panel edit", (ctx) => { ctx.setup(() => { ctx.mount(); ctx.setDashboardProp(); @@ -143,11 +143,10 @@ describe('DashboardPage', () => { const state = ctx.wrapper.state(); expect(state.isEditing).toBe(true); expect(state.isFullscreen).toBe(true); - expect(state.rememberScrollTop).toBe(100); }); }); - dashboardPageScenario("where user goes back to dashboard from panel edit", (ctx) => { + dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => { ctx.setup(() => { ctx.mount(); ctx.setDashboardProp(); diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap index c1aa5972b6d..002cac2306e 100644 --- a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -217,7 +217,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
`; -exports[`DashboardPage Dashboard is fetching slowly should render slow init state 1`] = ` +exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
@@ -233,7 +233,7 @@ exports[`DashboardPage Dashboard is fetching slowly should render slow init stat
`; -exports[`DashboardPage Given initial state should render nothing 1`] = `""`; +exports[`DashboardPage Given initial state Should render nothing 1`] = `""`; exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
Date: Wed, 6 Feb 2019 21:32:48 +0100 Subject: [PATCH 108/144] Fixed some remaining issues --- .../app/core/components/AlertBox/AlertBox.tsx | 3 ++- .../containers/DashboardPage.test.tsx | 20 +++++++++++++++++++ .../dashboard/containers/DashboardPage.tsx | 3 +-- .../dashboard/services/DashboardSrv.ts | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx index 2de43bbd0b2..c99bf11ed7c 100644 --- a/public/app/core/components/AlertBox/AlertBox.tsx +++ b/public/app/core/components/AlertBox/AlertBox.tsx @@ -17,7 +17,8 @@ function getIconFromSeverity(severity: AppNotificationSeverity): string { case AppNotificationSeverity.Success: { return 'fa fa-check'; } - default: return null; + default: + return null; } } diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 94aa0b32d46..3c5ed8e9b11 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -210,4 +210,24 @@ describe('DashboardPage', () => { expect(ctx.dashboard.panels[0].type).toBe('add-panel'); }); }); + + dashboardPageScenario("Given panel with id 0", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph'}], + schemaVersion: 17, + }); + ctx.wrapper.setProps({ + urlEdit: true, + urlFullscreen: true, + urlPanelId: '0' + }); + }); + + it('Should go into edit mode' , () => { + expect(ctx.wrapper.state().isEditing).toBe(true); + expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0); + }); + }); }); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 4e059e9ee6c..27118e297b5 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -90,7 +90,6 @@ export class DashboardPage extends PureComponent { } componentWillUnmount() { - console.log('unmount', this.props.cleanUpDashboard); if (this.props.dashboard) { this.props.cleanUpDashboard(); } @@ -118,7 +117,7 @@ export class DashboardPage extends PureComponent { // Sync url state with model if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) { - if (urlPanelId) { + if (!isNaN(parseInt(urlPanelId, 10))) { this.onEnterFullscreen(); } else { this.onLeaveFullscreen(); diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 4030765a5a3..72e0a2e3c07 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -60,7 +60,7 @@ export class DashboardSrv { delete urlParams.edit; } - if (options.panelId) { + if (options.panelId !== undefined) { urlParams.panelId = options.panelId; } else { delete urlParams.panelId; From 7edc3fdd5c45b487a17a76ff9dda369aa930698b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 6 Feb 2019 21:35:01 +0100 Subject: [PATCH 109/144] Added another error object message detection --- public/app/core/utils/errors.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/app/core/utils/errors.ts b/public/app/core/utils/errors.ts index 52a7c39f713..3f6f1cfbc8d 100644 --- a/public/app/core/utils/errors.ts +++ b/public/app/core/utils/errors.ts @@ -6,6 +6,8 @@ export function getMessageFromError(err: any): string | null { return err.message; } else if (err.data && err.data.message) { return err.data.message; + } else if (err.statusText) { + return err.statusText; } else { return JSON.stringify(err); } From 1a140ee199cf7430b67743cc2399ec749a16d94d Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 22:27:08 +0100 Subject: [PATCH 110/144] run token cleanup job when grafana starts, then each hour --- conf/defaults.ini | 4 ++-- conf/sample.ini | 4 ++-- docs/sources/auth/overview.md | 4 ++-- pkg/services/auth/auth_token_test.go | 8 ++++---- pkg/services/auth/token_cleanup.go | 22 +++++++++++++--------- pkg/setting/setting.go | 12 ++++++------ 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 41b948e53af..d0fc1133fac 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -256,8 +256,8 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 -# How often should expired auth tokens be deleted from the database. The default is 7 days. -expired_tokens_cleanup_interval_days = 7 +# How often should expired auth tokens be deleted from the database. The default is each hour. +expired_tokens_cleanup_interval_hours = 1 # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false diff --git a/conf/sample.ini b/conf/sample.ini index 831fa31253e..2ff37239abf 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -236,8 +236,8 @@ log_queries = # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. ;token_rotation_interval_minutes = 10 -# How often should expired auth tokens be deleted from the database. The default is 7 days. -;expired_tokens_cleanup_interval_days = 7 +# How often should expired auth tokens be deleted from the database. The default is each hour. +;expired_tokens_cleanup_interval_hours = 1 # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index fba8da00a5e..0f563fbe8d5 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -64,8 +64,8 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 -# How often should expired auth tokens be deleted from the database. The default is 7 days. -expired_tokens_cleanup_interval_days = 7 +# How often should expired auth tokens be deleted from the database. The default is each hour. +expired_tokens_cleanup_interval_hours = 1 ``` ### Anonymous authentication diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 3313af9f87f..964bd499a01 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -423,10 +423,10 @@ func createTestContext(t *testing.T) *testContext { tokenService := &UserAuthTokenService{ SQLStore: sqlstore, Cfg: &setting.Cfg{ - LoginMaxInactiveLifetimeDays: 7, - LoginMaxLifetimeDays: 30, - TokenRotationIntervalMinutes: 10, - ExpiredTokensCleanupIntervalDays: 1, + LoginMaxInactiveLifetimeDays: 7, + LoginMaxLifetimeDays: 30, + TokenRotationIntervalMinutes: 10, + ExpiredTokensCleanupIntervalHours: 1, }, log: log.New("test-logger"), } diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/token_cleanup.go index d0e12c9c0e1..0d5cbdaca10 100644 --- a/pkg/services/auth/token_cleanup.go +++ b/pkg/services/auth/token_cleanup.go @@ -6,25 +6,29 @@ import ( ) func (srv *UserAuthTokenService) Run(ctx context.Context) error { - if srv.Cfg.ExpiredTokensCleanupIntervalDays <= 0 { - srv.log.Debug("cleanup of expired auth tokens are disabled") - return nil - } - - jobInterval := time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalDays) * 24 * time.Hour - srv.log.Debug("cleanup of expired auth tokens are enabled", "intervalDays", srv.Cfg.ExpiredTokensCleanupIntervalDays) - + jobInterval := time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalHours) * time.Hour ticker := time.NewTicker(jobInterval) maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour + err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { + srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime) + }) + if err != nil { + srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err) + } + for { select { case <-ticker.C: - srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { + err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime) }) + if err != nil { + srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err) + } + case <-ctx.Done(): return ctx.Err() } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9f7d03bb472..43ac46e5ab8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -233,11 +233,11 @@ type Cfg struct { EnterpriseLicensePath string // Auth - LoginCookieName string - LoginMaxInactiveLifetimeDays int - LoginMaxLifetimeDays int - TokenRotationIntervalMinutes int - ExpiredTokensCleanupIntervalDays int + LoginCookieName string + LoginMaxInactiveLifetimeDays int + LoginMaxLifetimeDays int + TokenRotationIntervalMinutes int + ExpiredTokensCleanupIntervalHours int } type CommandLineArgs struct { @@ -673,7 +673,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { if cfg.TokenRotationIntervalMinutes < 2 { cfg.TokenRotationIntervalMinutes = 2 } - cfg.ExpiredTokensCleanupIntervalDays = auth.Key("expired_tokens_cleanup_interval_days").MustInt(7) + cfg.ExpiredTokensCleanupIntervalHours = auth.Key("expired_tokens_cleanup_interval_hours").MustInt(1) DisableLoginForm = auth.Key("disable_login_form").MustBool(false) DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) From 3555997f98cb82c53032aa9d286ab76dd748a65b Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 6 Feb 2019 22:33:48 +0100 Subject: [PATCH 111/144] devenv: update ha test and load test better ha setup for many mysql connections prometheus now scrapes mysql metrics in ha setup ha setup provisions mysql dashboard adds configurable virtual users for load test run script --- devenv/docker/ha_test/docker-compose.yaml | 21 +- .../{alerts.yaml => dashboards.yaml} | 6 + .../dashboards/mysql/overview.json | 5397 +++++++++++++++++ .../docker/ha_test/prometheus/prometheus.yml | 14 +- devenv/docker/loadtest/README.md | 14 +- devenv/docker/loadtest/auth_token_test.js | 2 +- devenv/docker/loadtest/run.sh | 8 +- 7 files changed, 5443 insertions(+), 19 deletions(-) rename devenv/docker/ha_test/grafana/provisioning/dashboards/{alerts.yaml => dashboards.yaml} (55%) create mode 100644 devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index 504ee86404d..8087894c56e 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -15,6 +15,7 @@ services: MYSQL_DATABASE: grafana MYSQL_USER: grafana MYSQL_PASSWORD: password + command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --max-connections=1001] ports: - 3306 healthcheck: @@ -22,6 +23,16 @@ services: timeout: 10s retries: 10 + mysqld-exporter: + image: prom/mysqld-exporter + environment: + - DATA_SOURCE_NAME=root:rootpass@(db:3306)/ + ports: + - 9104 + depends_on: + db: + condition: service_healthy + # db: # image: postgres:9.3 # environment: @@ -47,6 +58,7 @@ services: - GF_DATABASE_PASSWORD=password - GF_DATABASE_TYPE=mysql - GF_DATABASE_HOST=db:3306 + - GF_DATABASE_MAX_OPEN_CONN=300 - GF_SESSION_PROVIDER=mysql - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true # - GF_DATABASE_TYPE=postgres @@ -55,7 +67,7 @@ services: # - 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,auth:debug - - GF_LOGIN_ROTATE_TOKEN_MINUTES=2 + - GF_AUTH_TOKEN_ROTATION_INTERVAL_MINUTES=2 ports: - 3000 depends_on: @@ -70,10 +82,3 @@ services: - VIRTUAL_HOST=prometheus.loc ports: - 9090 - - # mysqld-exporter: - # image: prom/mysqld-exporter - # environment: - # - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/ - # ports: - # - 9104 diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml b/devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml similarity index 55% rename from devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml rename to devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml index 60b6cd4bb04..ad85bb7036f 100644 --- a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml +++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml @@ -6,3 +6,9 @@ providers: type: file options: path: /etc/grafana/provisioning/dashboards/alerts + + - name: 'MySQL' + folder: 'MySQL' + type: file + options: + path: /etc/grafana/provisioning/dashboards/mysql diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json new file mode 100644 index 00000000000..d072e4c1d28 --- /dev/null +++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json @@ -0,0 +1,5397 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": false, + "hide": true, + "iconColor": "#e0752d", + "limit": 100, + "name": "PMM Annotations", + "showIn": 0, + "tags": [ + "pmm_annotation" + ], + "type": "tags" + }, + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": false, + "hide": true, + "iconColor": "#6ed0e0", + "limit": 100, + "name": "Annotations & Alerts", + "showIn": 0, + "tags": [ + + ], + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "iteration": 1540971751770, + "links": [ + { + "icon": "dashboard", + "includeVars": true, + "keepTime": true, + "tags": [ + "QAN" + ], + "targetBlank": false, + "title": "Query Analytics", + "type": "link", + "url": "/graph/dashboard/db/_pmm-query-analytics" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "OS" + ], + "targetBlank": false, + "title": "OS", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "MySQL" + ], + "targetBlank": false, + "title": "MySQL", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "MongoDB" + ], + "targetBlank": false, + "title": "MongoDB", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "PostgreSQL" + ], + "targetBlank": false, + "title": "PostgreSQL", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "HA" + ], + "targetBlank": false, + "title": "HA", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "Cloud" + ], + "targetBlank": false, + "title": "Cloud", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "Insight" + ], + "targetBlank": false, + "title": "Insight", + "type": "dashboards" + }, + { + "asDropdown": true, + "includeVars": true, + "keepTime": true, + "tags": [ + "PMM" + ], + "targetBlank": false, + "title": "PMM", + "type": "dashboards" + } + ], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 382, + "panels": [ + + ], + "repeat": null, + "title": "", + "type": "row" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "decimals": 1, + "description": "**MySQL Uptime**\n\nThe amount of time since the last restart of the MySQL server process.", + "editable": true, + "error": false, + "format": "s", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 2, + "w": 6, + "x": 0, + "y": 1 + }, + "height": "125px", + "id": 12, + "interval": "$interval", + "links": [ + + ], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "s", + "postfixFontSize": "80%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "calculatedInterval": "10m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_uptime{instance=\"$host\"}", + "format": "time_series", + "interval": "5m", + "intervalFactor": 1, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 300 + } + ], + "thresholds": "300,3600", + "title": "MySQL Uptime", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "decimals": 2, + "description": "**Current QPS**\n\nBased on the queries reported by MySQL's ``SHOW STATUS`` command, it is the number of statements executed by the server within the last second. This variable includes statements executed within stored programs, unlike the Questions variable. It does not count \n``COM_PING`` or ``COM_STATISTICS`` commands.", + "editable": true, + "error": false, + "format": "short", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 2, + "w": 6, + "x": 6, + "y": 1 + }, + "height": "125px", + "id": 13, + "interval": "$interval", + "links": [ + { + "targetBlank": true, + "title": "MySQL Server Status Variables", + "type": "absolute", + "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Queries" + } + ], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "tableColumn": "", + "targets": [ + { + "calculatedInterval": "10m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_queries{instance=\"$host\"}[$interval]) or irate(mysql_global_status_queries{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": "35,75", + "title": "Current QPS", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "Prometheus", + "decimals": 0, + "description": "**InnoDB Buffer Pool Size**\n\nInnoDB maintains a storage area called the buffer pool for caching data and indexes in memory. Knowing how the InnoDB buffer pool works, and taking advantage of it to keep frequently accessed data in memory, is one of the most important aspects of MySQL tuning. The goal is to keep the working set in memory. In most cases, this should be between 60%-90% of available memory on a dedicated database host, but depends on many factors.", + "editable": true, + "error": false, + "format": "bytes", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 2, + "w": 6, + "x": 12, + "y": 1 + }, + "height": "125px", + "id": 51, + "interval": "$interval", + "links": [ + { + "targetBlank": true, + "title": "Tuning the InnoDB Buffer Pool Size", + "type": "absolute", + "url": "https://www.percona.com/blog/2015/06/02/80-ram-tune-innodb_buffer_pool_size/" + } + ], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "calculatedInterval": "10m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_innodb_buffer_pool_size{instance=\"$host\"}", + "format": "time_series", + "interval": "5m", + "intervalFactor": 1, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 300 + } + ], + "thresholds": "90,95", + "title": "InnoDB Buffer Pool Size", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "Prometheus", + "decimals": 0, + "description": "**InnoDB Buffer Pool Size % of Total RAM**\n\nInnoDB maintains a storage area called the buffer pool for caching data and indexes in memory. Knowing how the InnoDB buffer pool works, and taking advantage of it to keep frequently accessed data in memory, is one of the most important aspects of MySQL tuning. The goal is to keep the working set in memory. In most cases, this should be between 60%-90% of available memory on a dedicated database host, but depends on many factors.", + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 2, + "w": 6, + "x": 18, + "y": 1 + }, + "height": "125px", + "id": 52, + "interval": "$interval", + "links": [ + { + "targetBlank": true, + "title": "Tuning the InnoDB Buffer Pool Size", + "type": "absolute", + "url": "https://www.percona.com/blog/2015/06/02/80-ram-tune-innodb_buffer_pool_size/" + } + ], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "80%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "repeat": null, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "maxValue": 100, + "minValue": 0, + "show": true + }, + "tableColumn": "", + "targets": [ + { + "calculatedInterval": "10m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "(mysql_global_variables_innodb_buffer_pool_size{instance=\"$host\"} * 100) / on (instance) node_memory_MemTotal{instance=\"$host\"}", + "format": "time_series", + "interval": "5m", + "intervalFactor": 1, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 300 + } + ], + "thresholds": "40,80", + "title": "Buffer Pool Size of Total RAM", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + + ], + "valueName": "current" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 383, + "panels": [ + + ], + "repeat": null, + "title": "Connections", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 0, + "description": "**Max Connections** \n\nMax Connections is the maximum permitted number of simultaneous client connections. By default, this is 151. Increasing this value increases the number of file descriptors that mysqld requires. If the required number of descriptors are not available, the server reduces the value of Max Connections.\n\nmysqld actually permits Max Connections + 1 clients to connect. The extra connection is reserved for use by accounts that have the SUPER privilege, such as root.\n\nMax Used Connections is the maximum number of connections that have been in use simultaneously since the server started.\n\nConnections is the number of connection attempts (successful or not) to the MySQL server.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 4 + }, + "height": "250px", + "id": 92, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "targetBlank": true, + "title": "MySQL Server System Variables", + "type": "absolute", + "url": "https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_max_connections" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Max Connections", + "fill": 0 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "max(max_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[$interval]) or mysql_global_status_threads_connected{instance=\"$host\"} )", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Connections", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_max_used_connections{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Max Used Connections", + "metric": "", + "refId": "C", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_max_connections{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Max Connections", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Connections", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Active Threads**\n\nThreads Connected is the number of open connections, while Threads Running is the number of threads not sleeping.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Peak Threads Running", + "color": "#E24D42", + "lines": false, + "pointradius": 1, + "points": true + }, + { + "alias": "Peak Threads Connected", + "color": "#1F78C1" + }, + { + "alias": "Avg Threads Running", + "color": "#EAB839" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "max_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[$interval]) or\nmax_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[5m])", + "format": "time_series", + "hide": false, + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Peak Threads Connected", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "max_over_time(mysql_global_status_threads_running{instance=\"$host\"}[$interval]) or\nmax_over_time(mysql_global_status_threads_running{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Peak Threads Running", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "expr": "avg_over_time(mysql_global_status_threads_running{instance=\"$host\"}[$interval]) or \navg_over_time(mysql_global_status_threads_running{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Avg Threads Running", + "refId": "C", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Client Thread Activity", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "format": "short", + "label": "Threads", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 384, + "panels": [ + + ], + "repeat": null, + "title": "Table Locks", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Questions**\n\nThe number of statements executed by the server. This includes only statements sent to the server by clients and not statements executed within stored programs, unlike the Queries used in the QPS calculation. \n\nThis variable does not count the following commands:\n* ``COM_PING``\n* ``COM_STATISTICS``\n* ``COM_STMT_PREPARE``\n* ``COM_STMT_CLOSE``\n* ``COM_STMT_RESET``", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 53, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "targetBlank": true, + "title": "MySQL Queries and Questions", + "type": "absolute", + "url": "https://www.percona.com/blog/2014/05/29/how-mysql-queries-and-questions-are-measured/" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_questions{instance=\"$host\"}[$interval]) or irate(mysql_global_status_questions{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Questions", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Questions", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Thread Cache**\n\nThe thread_cache_size variable sets how many threads the server should cache to reuse. When a client disconnects, the client's threads are put in the cache if the cache is not full. It is autosized in MySQL 5.6.8 and above (capped to 100). Requests for threads are satisfied by reusing threads taken from the cache if possible, and only when the cache is empty is a new thread created.\n\n* *Threads_created*: The number of threads created to handle connections.\n* *Threads_cached*: The number of threads in the thread cache.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Tuning information", + "type": "absolute", + "url": "https://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_thread_cache_size" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Threads Created", + "fill": 0 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_thread_cache_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Thread Cache Size", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_threads_cached{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Threads Cached", + "metric": "", + "refId": "C", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_threads_created{instance=\"$host\"}[$interval]) or irate(mysql_global_status_threads_created{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Threads Created", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Thread Cache", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 385, + "panels": [ + + ], + "repeat": null, + "title": "Temporary Objects", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_created_tmp_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_tables{instance=\"$host\"}[5m])", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Created Tmp Tables", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_created_tmp_disk_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_disk_tables{instance=\"$host\"}[5m])", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Created Tmp Disk Tables", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_created_tmp_files{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_files{instance=\"$host\"}[5m])", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Created Tmp Files", + "metric": "", + "refId": "C", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Temporary Objects", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Select Types**\n\nAs with most relational databases, selecting based on indexes is more efficient than scanning an entire table's data. Here we see the counters for selects not done with indexes.\n\n* ***Select Scan*** is how many queries caused full table scans, in which all the data in the table had to be read and either discarded or returned.\n* ***Select Range*** is how many queries used a range scan, which means MySQL scanned all rows in a given range.\n* ***Select Full Join*** is the number of joins that are not joined on an index, this is usually a huge performance hit.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 20 + }, + "height": "250px", + "id": 311, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_select_full_join{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_full_join{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Select Full Join", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_select_full_range_join{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_full_range_join{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Select Full Range Join", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_select_range{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_range{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Select Range", + "metric": "", + "refId": "C", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_select_range_check{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_range_check{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Select Range Check", + "metric": "", + "refId": "D", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_select_scan{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_scan{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Select Scan", + "metric": "", + "refId": "E", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Select Types", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 27 + }, + "id": 386, + "panels": [ + + ], + "repeat": null, + "title": "Sorts", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Sorts**\n\nDue to a query's structure, order, or other requirements, MySQL sorts the rows before returning them. For example, if a table is ordered 1 to 10 but you want the results reversed, MySQL then has to sort the rows to return 10 to 1.\n\nThis graph also shows when sorts had to scan a whole table or a given range of a table in order to return the results and which could not have been sorted via an index.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 30, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_sort_rows{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_rows{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Sort Rows", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_sort_range{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_range{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Sort Range", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_sort_merge_passes{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_merge_passes{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Sort Merge Passes", + "metric": "", + "refId": "C", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_sort_scan{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_scan{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Sort Scan", + "metric": "", + "refId": "D", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Sorts", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Slow Queries**\n\nSlow queries are defined as queries being slower than the long_query_time setting. For example, if you have long_query_time set to 3, all queries that take longer than 3 seconds to complete will show on this graph.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 48, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_slow_queries{instance=\"$host\"}[$interval]) or irate(mysql_global_status_slow_queries{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Slow Queries", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Slow Queries", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 35 + }, + "id": 387, + "panels": [ + + ], + "repeat": null, + "title": "Aborted", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**Aborted Connections**\n\nWhen a given host connects to MySQL and the connection is interrupted in the middle (for example due to bad credentials), MySQL keeps that info in a system table (since 5.6 this table is exposed in performance_schema).\n\nIf the amount of failed requests without a successful connection reaches the value of max_connect_errors, mysqld assumes that something is wrong and blocks the host from further connection.\n\nTo allow connections from that host again, you need to issue the ``FLUSH HOSTS`` statement.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 36 + }, + "id": 47, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_aborted_connects{instance=\"$host\"}[$interval]) or irate(mysql_global_status_aborted_connects{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Aborted Connects (attempts)", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_aborted_clients{instance=\"$host\"}[$interval]) or irate(mysql_global_status_aborted_clients{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Aborted Clients (timeout)", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Aborted Connections", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**Table Locks**\n\nMySQL takes a number of different locks for varying reasons. In this graph we see how many Table level locks MySQL has requested from the storage engine. In the case of InnoDB, many times the locks could actually be row locks as it only takes table level locks in a few specific cases.\n\nIt is most useful to compare Locks Immediate and Locks Waited. If Locks waited is rising, it means you have lock contention. Otherwise, Locks Immediate rising and falling is normal activity.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 36 + }, + "id": 32, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_table_locks_immediate{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_locks_immediate{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Table Locks Immediate", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_table_locks_waited{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_locks_waited{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Table Locks Waited", + "metric": "", + "refId": "B", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Table Locks", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 388, + "panels": [ + + ], + "repeat": null, + "title": "Network", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Network Traffic**\n\nHere we can see how much network traffic is generated by MySQL. Outbound is network traffic sent from MySQL and Inbound is network traffic MySQL has received.", + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 44 + }, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_bytes_received{instance=\"$host\"}[$interval]) or irate(mysql_global_status_bytes_received{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Inbound", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_bytes_sent{instance=\"$host\"}[$interval]) or irate(mysql_global_status_bytes_sent{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Outbound", + "metric": "", + "refId": "B", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Network Traffic", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "Bps", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Network Usage Hourly**\n\nHere we can see how much network traffic is generated by MySQL per hour. You can use the bar graph to compare data sent by MySQL and data received by MySQL.", + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 44 + }, + "height": "250px", + "id": 381, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "increase(mysql_global_status_bytes_received{instance=\"$host\"}[1h])", + "format": "time_series", + "interval": "1h", + "intervalFactor": 1, + "legendFormat": "Received", + "metric": "", + "refId": "A", + "step": 3600 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "increase(mysql_global_status_bytes_sent{instance=\"$host\"}[1h])", + "format": "time_series", + "interval": "1h", + "intervalFactor": 1, + "legendFormat": "Sent", + "metric": "", + "refId": "B", + "step": 3600 + } + ], + "thresholds": [ + + ], + "timeFrom": "24h", + "timeShift": null, + "title": "MySQL Network Usage Hourly", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 51 + }, + "id": 389, + "panels": [ + + ], + "repeat": null, + "title": "Memory", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 0, + "description": "***System Memory***: Total Memory for the system.\\\n***InnoDB Buffer Pool Data***: InnoDB maintains a storage area called the buffer pool for caching data and indexes in memory.\\\n***TokuDB Cache Size***: Similar in function to the InnoDB Buffer Pool, TokuDB will allocate 50% of the installed RAM for its own cache.\\\n***Key Buffer Size***: Index blocks for MYISAM tables are buffered and are shared by all threads. key_buffer_size is the size of the buffer used for index blocks.\\\n***Adaptive Hash Index Size***: When InnoDB notices that some index values are being accessed very frequently, it builds a hash index for them in memory on top of B-Tree indexes.\\\n ***Query Cache Size***: The query cache stores the text of a SELECT statement together with the corresponding result that was sent to the client. The query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time.\\\n***InnoDB Dictionary Size***: The data dictionary is InnoDB ‘s internal catalog of tables. InnoDB stores the data dictionary on disk, and loads entries into memory while the server is running.\\\n***InnoDB Log Buffer Size***: The MySQL InnoDB log buffer allows transactions to run without having to write the log to disk before the transactions commit.", + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 52 + }, + "id": 50, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": true, + "hideZero": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Detailed descriptions about metrics", + "type": "absolute", + "url": "https://www.percona.com/doc/percona-monitoring-and-management/dashboard.mysql-overview.html#mysql-internal-memory-overview" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "System Memory", + "fill": 0, + "stack": false + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "node_memory_MemTotal{instance=\"$host\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "System Memory", + "refId": "G", + "step": 4 + }, + { + "expr": "mysql_global_status_innodb_page_size{instance=\"$host\"} * on (instance) mysql_global_status_buffer_pool_pages{instance=\"$host\",state=\"data\"}", + "format": "time_series", + "hide": false, + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "InnoDB Buffer Pool Data", + "refId": "A", + "step": 20 + }, + { + "expr": "mysql_global_variables_innodb_log_buffer_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "InnoDB Log Buffer Size", + "refId": "D", + "step": 20 + }, + { + "expr": "mysql_global_variables_innodb_additional_mem_pool_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 2, + "legendFormat": "InnoDB Additional Memory Pool Size", + "refId": "H", + "step": 40 + }, + { + "expr": "mysql_global_status_innodb_mem_dictionary{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "InnoDB Dictionary Size", + "refId": "F", + "step": 20 + }, + { + "expr": "mysql_global_variables_key_buffer_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Key Buffer Size", + "refId": "B", + "step": 20 + }, + { + "expr": "mysql_global_variables_query_cache_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Query Cache Size", + "refId": "C", + "step": 20 + }, + { + "expr": "mysql_global_status_innodb_mem_adaptive_hash{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Adaptive Hash Index Size", + "refId": "E", + "step": 20 + }, + { + "expr": "mysql_global_variables_tokudb_cache_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "TokuDB Cache Size", + "refId": "I", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Internal Memory Overview", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 59 + }, + "id": 390, + "panels": [ + + ], + "repeat": null, + "title": "Command, Handlers, Processes", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**Top Command Counters**\n\nThe Com_{{xxx}} statement counter variables indicate the number of times each xxx statement has been executed. There is one status variable for each type of statement. For example, Com_delete and Com_update count [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements, respectively. Com_delete_multi and Com_update_multi are similar but apply to [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements that use multiple-table syntax.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 60 + }, + "id": 14, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Server Status Variables (Com_xxx)", + "type": "absolute", + "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "topk(5, rate(mysql_global_status_commands_total{instance=\"$host\"}[$interval])>0) or topk(5, irate(mysql_global_status_commands_total{instance=\"$host\"}[5m])>0)", + "format": "time_series", + "hide": false, + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Com_{{ command }}", + "metric": "", + "refId": "B", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Top Command Counters", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**Top Command Counters Hourly**\n\nThe Com_{{xxx}} statement counter variables indicate the number of times each xxx statement has been executed. There is one status variable for each type of statement. For example, Com_delete and Com_update count [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements, respectively. Com_delete_multi and Com_update_multi are similar but apply to [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements that use multiple-table syntax.", + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 67 + }, + "id": 39, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 2, + "links": [ + { + "dashboard": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx", + "title": "Server Status Variables (Com_xxx)", + "type": "absolute", + "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "topk(5, increase(mysql_global_status_commands_total{instance=\"$host\"}[1h])>0)", + "format": "time_series", + "interval": "1h", + "intervalFactor": 1, + "legendFormat": "Com_{{ command }}", + "metric": "", + "refId": "A", + "step": 3600 + } + ], + "thresholds": [ + + ], + "timeFrom": "24h", + "timeShift": null, + "title": "Top Command Counters Hourly", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Handlers**\n\nHandler statistics are internal statistics on how MySQL is selecting, updating, inserting, and modifying rows, tables, and indexes.\n\nThis is in fact the layer between the Storage Engine and MySQL.\n\n* `read_rnd_next` is incremented when the server performs a full table scan and this is a counter you don't really want to see with a high value.\n* `read_key` is incremented when a read is done with an index.\n* `read_next` is incremented when the storage engine is asked to 'read the next index entry'. A high value means a lot of index scans are being done.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 74 + }, + "id": 8, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_handlers_total{instance=\"$host\", handler!~\"commit|rollback|savepoint.*|prepare\"}[$interval]) or irate(mysql_global_status_handlers_total{instance=\"$host\", handler!~\"commit|rollback|savepoint.*|prepare\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "{{ handler }}", + "metric": "", + "refId": "J", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Handlers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 81 + }, + "id": 28, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_handlers_total{instance=\"$host\", handler=~\"commit|rollback|savepoint.*|prepare\"}[$interval]) or irate(mysql_global_status_handlers_total{instance=\"$host\", handler=~\"commit|rollback|savepoint.*|prepare\"}[5m])", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "{{ handler }}", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Transaction Handlers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 0, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 88 + }, + "id": 40, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_info_schema_threads{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "{{ state }}", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Process States", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 95 + }, + "id": 49, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideZero": true, + "max": true, + "min": false, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "topk(5, avg_over_time(mysql_info_schema_threads{instance=\"$host\"}[1h]))", + "interval": "1h", + "intervalFactor": 1, + "legendFormat": "{{ state }}", + "metric": "", + "refId": "A", + "step": 3600 + } + ], + "thresholds": [ + + ], + "timeFrom": "24h", + "timeShift": null, + "title": "Top Process States Hourly", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 102 + }, + "id": 391, + "panels": [ + + ], + "repeat": null, + "title": "Query Cache", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Query Cache Memory**\n\nThe query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time. This serialization is true not only for SELECTs, but also for INSERT/UPDATE/DELETE.\n\nThis also means that the larger the `query_cache_size` is set to, the slower those operations become. In concurrent environments, the MySQL Query Cache quickly becomes a contention point, decreasing performance. MariaDB and AWS Aurora have done work to try and eliminate the query cache contention in their flavors of MySQL, while MySQL 8.0 has eliminated the query cache feature.\n\nThe recommended settings for most environments is to set:\n ``query_cache_type=0``\n ``query_cache_size=0``\n\nNote that while you can dynamically change these values, to completely remove the contention point you have to restart the database.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 103 + }, + "id": 46, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_qcache_free_memory{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Free Memory", + "metric": "", + "refId": "F", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_query_cache_size{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Query Cache Size", + "metric": "", + "refId": "E", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Query Cache Memory", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Query Cache Activity**\n\nThe query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time. This serialization is true not only for SELECTs, but also for INSERT/UPDATE/DELETE.\n\nThis also means that the larger the `query_cache_size` is set to, the slower those operations become. In concurrent environments, the MySQL Query Cache quickly becomes a contention point, decreasing performance. MariaDB and AWS Aurora have done work to try and eliminate the query cache contention in their flavors of MySQL, while MySQL 8.0 has eliminated the query cache feature.\n\nThe recommended settings for most environments is to set:\n``query_cache_type=0``\n``query_cache_size=0``\n\nNote that while you can dynamically change these values, to completely remove the contention point you have to restart the database.", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 103 + }, + "height": "", + "id": 45, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_qcache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_hits{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Hits", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_qcache_inserts{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_inserts{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Inserts", + "metric": "", + "refId": "C", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_qcache_not_cached{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_not_cached{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Not Cached", + "metric": "", + "refId": "D", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_qcache_lowmem_prunes{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_lowmem_prunes{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Prunes", + "metric": "", + "refId": "F", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_qcache_queries_in_cache{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Queries in Cache", + "metric": "", + "refId": "E", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Query Cache Activity", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 110 + }, + "id": 392, + "panels": [ + + ], + "repeat": null, + "title": "Files and Tables", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 111 + }, + "id": 43, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_opened_files{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_files{instance=\"$host\"}[5m])", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Openings", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL File Openings", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 111 + }, + "id": 41, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_open_files{instance=\"$host\"}", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Open Files", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_open_files_limit{instance=\"$host\"}", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Open Files Limit", + "metric": "", + "refId": "D", + "step": 20 + }, + { + "expr": "mysql_global_status_innodb_num_open_files{instance=\"$host\"}", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "InnoDB Open Files", + "refId": "B", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Open Files", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 118 + }, + "id": 393, + "panels": [ + + ], + "repeat": null, + "title": "Table Openings", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Table Open Cache Status**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 119 + }, + "id": 44, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Server Status Variables (table_open_cache)", + "type": "absolute", + "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Table Open Cache Hit Ratio", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(mysql_global_status_opened_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_tables{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Openings", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "expr": "rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Hits", + "refId": "B", + "step": 20 + }, + { + "expr": "rate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Misses", + "refId": "C", + "step": 20 + }, + { + "expr": "rate(mysql_global_status_table_open_cache_overflows{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_overflows{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Misses due to Overflows", + "refId": "D", + "step": 20 + }, + { + "expr": "(rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m]))/((rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m]))+(rate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[5m])))", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Table Open Cache Hit Ratio", + "refId": "E", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Table Open Cache Status", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "percentunit", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Open Tables**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 119 + }, + "id": 42, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Server Status Variables (table_open_cache)", + "type": "absolute", + "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_open_tables{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Open Tables", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_table_open_cache{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Table Open Cache", + "metric": "", + "refId": "C", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Open Tables", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 126 + }, + "id": 394, + "panels": [ + + ], + "repeat": null, + "title": "MySQL Table Definition Cache", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "description": "**MySQL Table Definition Cache**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).", + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 127 + }, + "id": 54, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + { + "title": "Server Status Variables (table_open_cache)", + "type": "absolute", + "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache" + } + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Opened Table Definitions", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_status_open_table_definitions{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Open Table Definitions", + "metric": "", + "refId": "B", + "step": 20 + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "mysql_global_variables_table_definition_cache{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Table Definitions Cache Size", + "metric": "", + "refId": "C", + "step": 20 + }, + { + "expr": "rate(mysql_global_status_opened_table_definitions{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_table_definitions{instance=\"$host\"}[5m])", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Opened Table Definitions", + "refId": "A", + "step": 20 + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "MySQL Table Definition Cache", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 134 + }, + "id": 395, + "panels": [ + + ], + "repeat": null, + "title": "System Charts", + "type": "row" + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 135 + }, + "id": 31, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(node_vmstat_pgpgin{instance=\"$host\"}[$interval]) * 1024 or irate(node_vmstat_pgpgin{instance=\"$host\"}[5m]) * 1024", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Page In", + "metric": "", + "refId": "A", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(node_vmstat_pgpgout{instance=\"$host\"}[$interval]) * 1024 or irate(node_vmstat_pgpgout{instance=\"$host\"}[5m]) * 1024", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Page Out", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "I/O Activity", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "Bps", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": null, + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 135 + }, + "height": "250px", + "id": 37, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "max(node_memory_MemTotal{instance=\"$host\"}) without(job) - \n(max(node_memory_MemFree{instance=\"$host\"}) without(job) + \nmax(node_memory_Buffers{instance=\"$host\"}) without(job) + \n(max(node_memory_Cached{instance=\"$host\",job=~\"rds-enhanced|linux\"}) without (job) or \nmax(node_memory_Cached{instance=\"$host\",job=\"rds-basic\"}) without (job)))", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Used", + "metric": "", + "refId": "A", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "node_memory_MemFree{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Free", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "node_memory_Buffers{instance=\"$host\"}", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Buffers", + "metric": "", + "refId": "D", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "max(node_memory_Cached{instance=~\"$host\",job=~\"rds-enhanced|linux\"}) without (job) or \nmax(node_memory_Cached{instance=~\"$host\",job=~\"rds-basic\"}) without (job)", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Cached", + "metric": "", + "refId": "E", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Memory Distribution", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "Load 1m": "#58140C", + "Max Core Utilization": "#bf1b00", + "iowait": "#e24d42", + "nice": "#1f78c1", + "softirq": "#806eb7", + "system": "#eab839", + "user": "#508642" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": null, + "editable": true, + "error": false, + "fill": 6, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 142 + }, + "height": "", + "id": 2, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": true, + "hideZero": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Max Core Utilization", + "lines": false, + "pointradius": 1, + "points": true, + "stack": false + }, + { + "alias": "Load 1m", + "color": "#58140C", + "fill": 2, + "legend": false, + "stack": false, + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": true, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "clamp_max(((avg by (mode) ( (clamp_max(rate(node_cpu{instance=\"$host\",mode!=\"idle\"}[$interval]),1)) or (clamp_max(irate(node_cpu{instance=\"$host\",mode!=\"idle\"}[5m]),1)) ))*100 or (avg_over_time(node_cpu_average{instance=~\"$host\", mode!=\"total\", mode!=\"idle\"}[$interval]) or avg_over_time(node_cpu_average{instance=~\"$host\", mode!=\"total\", mode!=\"idle\"}[5m]))),100)", + "format": "time_series", + "hide": false, + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "{{ mode }}", + "metric": "", + "refId": "A", + "step": 20 + }, + { + "expr": "clamp_max(max by () (sum by (cpu) ( (clamp_max(rate(node_cpu{instance=\"$host\",mode!=\"idle\",mode!=\"iowait\"}[$interval]),1)) or (clamp_max(irate(node_cpu{instance=\"$host\",mode!=\"idle\",mode!=\"iowait\"}[5m]),1)) ))*100,100)", + "format": "time_series", + "hide": true, + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Max Core Utilization", + "refId": "B", + "step": 20 + }, + { + "expr": "node_load1{instance=\"$host\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "Load 1m", + "refId": "C" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "CPU Usage / Load", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "decimals": 1, + "format": "percent", + "label": "", + "logBase": 1, + "max": 100, + "min": 0, + "show": true + }, + { + "format": "none", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 142 + }, + "height": "250px", + "id": 36, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": true, + "hideZero": true, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 1, + "points": true, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "sum((rate(node_disk_read_time_ms{device!~\"dm-.+\", instance=\"$host\"}[$interval]) / rate(node_disk_reads_completed{device!~\"dm-.+\", instance=\"$host\"}[$interval])) or (irate(node_disk_read_time_ms{device!~\"dm-.+\", instance=\"$host\"}[5m]) / irate(node_disk_reads_completed{device!~\"dm-.+\", instance=\"$host\"}[5m]))\nor avg_over_time(aws_rds_read_latency_average{instance=\"$host\"}[$interval]) or avg_over_time(aws_rds_read_latency_average{instance=\"$host\"}[5m]))", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Read", + "metric": "", + "refId": "A", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2m", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "sum((rate(node_disk_write_time_ms{device!~\"dm-.+\", instance=\"$host\"}[$interval]) / rate(node_disk_writes_completed{device!~\"dm-.+\", instance=\"$host\"}[$interval])) or (irate(node_disk_write_time_ms{device!~\"dm-.+\", instance=\"$host\"}[5m]) / irate(node_disk_writes_completed{device!~\"dm-.+\", instance=\"$host\"}[5m])) or \navg_over_time(aws_rds_write_latency_average{instance=\"$host\"}[$interval]) or avg_over_time(aws_rds_write_latency_average{instance=\"$host\"}[5m]))", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Write", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Disk Latency", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "ms", + "label": "", + "logBase": 2, + "max": null, + "min": null, + "show": true + }, + { + "format": "ms", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": null, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 149 + }, + "height": "250px", + "id": 21, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "Outbound", + "transform": "negative-Y" + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "sum(rate(node_network_receive_bytes{instance=\"$host\", device!=\"lo\"}[$interval])) or sum(irate(node_network_receive_bytes{instance=\"$host\", device!=\"lo\"}[5m])) or sum(max_over_time(rdsosmetrics_network_rx{instance=\"$host\"}[$interval])) or sum(max_over_time(rdsosmetrics_network_rx{instance=\"$host\"}[5m])) ", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Inbound", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "sum(rate(node_network_transmit_bytes{instance=\"$host\", device!=\"lo\"}[$interval])) or sum(irate(node_network_transmit_bytes{instance=\"$host\", device!=\"lo\"}[5m])) or\nsum(max_over_time(rdsosmetrics_network_tx{instance=\"$host\"}[$interval])) or sum(max_over_time(rdsosmetrics_network_tx{instance=\"$host\"}[5m]))", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Outbound", + "metric": "", + "refId": "A", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Network Traffic", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "Bps", + "label": "Outbound (-) / Inbound (+)", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "decimals": null, + "editable": true, + "error": false, + "fill": 2, + "grid": { + + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 149 + }, + "id": 38, + "legend": { + "alignAsTable": true, + "avg": true, + "current": false, + "hideEmpty": false, + "max": true, + "min": true, + "rightSide": false, + "show": true, + "sort": "avg", + "sortDesc": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 2, + "links": [ + + ], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(node_vmstat_pswpin{instance=\"$host\"}[$interval]) * 4096 or irate(node_vmstat_pswpin{instance=\"$host\"}[5m]) * 4096", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Swap In (Reads)", + "metric": "", + "refId": "A", + "step": 20, + "target": "" + }, + { + "calculatedInterval": "2s", + "datasourceErrors": { + + }, + "errors": { + + }, + "expr": "rate(node_vmstat_pswpout{instance=\"$host\"}[$interval]) * 4096 or irate(node_vmstat_pswpout{instance=\"$host\"}[5m]) * 4096", + "format": "time_series", + "interval": "$interval", + "intervalFactor": 1, + "legendFormat": "Swap Out (Writes)", + "metric": "", + "refId": "B", + "step": 20, + "target": "" + } + ], + "thresholds": [ + + ], + "timeFrom": null, + "timeShift": null, + "title": "Swap Activity", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [ + + ] + }, + "yaxes": [ + { + "format": "Bps", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "bytes", + "logBase": 1, + "max": null, + "min": 0, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "1m", + "schemaVersion": 16, + "style": "dark", + "tags": [ + "Percona", + "MySQL" + ], + "templating": { + "list": [ + { + "allFormat": "glob", + "auto": true, + "auto_count": 200, + "auto_min": "1s", + "current": { + "text": "auto", + "value": "$__auto_interval_interval" + }, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Interval", + "multi": false, + "multiFormat": "glob", + "name": "interval", + "options": [ + { + "selected": true, + "text": "auto", + "value": "$__auto_interval_interval" + }, + { + "selected": false, + "text": "1s", + "value": "1s" + }, + { + "selected": false, + "text": "5s", + "value": "5s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + } + ], + "query": "1s,5s,1m,5m,1h,6h,1d", + "refresh": 2, + "type": "interval" + }, + { + "allFormat": "glob", + "allValue": null, + "current": { + + }, + "datasource": "Prometheus", + "hide": 0, + "includeAll": false, + "label": "Host", + "multi": false, + "multiFormat": "regex values", + "name": "host", + "options": [ + + ], + "query": "label_values(mysql_up, instance)", + "refresh": 1, + "refresh_on_load": false, + "regex": "", + "sort": 1, + "tagValuesQuery": null, + "tags": [ + + ], + "tagsQuery": null, + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "hidden": false, + "notice": false, + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "type": "timepicker" + }, + "timezone": "browser", + "title": "MySQL Overview", + "uid": "MQWgroiiz", + "version": 1 +} \ No newline at end of file diff --git a/devenv/docker/ha_test/prometheus/prometheus.yml b/devenv/docker/ha_test/prometheus/prometheus.yml index ea97ba8ba05..0950bad6f7d 100644 --- a/devenv/docker/ha_test/prometheus/prometheus.yml +++ b/devenv/docker/ha_test/prometheus/prometheus.yml @@ -30,10 +30,10 @@ scrape_configs: port: 3000 refresh_interval: 10s - # - job_name: 'mysql' - # dns_sd_configs: - # - names: - # - 'mysqld-exporter' - # type: 'A' - # port: 9104 - # refresh_interval: 10s \ No newline at end of file + - job_name: 'mysql' + dns_sd_configs: + - names: + - 'mysqld-exporter' + type: 'A' + port: 9104 + refresh_interval: 10s \ No newline at end of file diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md index 8e724637acb..ca70a77dc74 100644 --- a/devenv/docker/loadtest/README.md +++ b/devenv/docker/loadtest/README.md @@ -8,7 +8,7 @@ Docker ## Run -Run load test for 15 minutes: +Run load test for 15 minutes using 2 virtual users and targeting http://localhost:3000. ```bash $ ./run.sh @@ -20,6 +20,18 @@ Run load test for custom duration: $ ./run.sh -d 10s ``` +Run load test for custom target url: + +```bash +$ ./run.sh -u http://grafana.loc +``` + +Run load test for 10 virtual users: + +```bash +$ ./run.sh -v 10 +``` + Example output: ```bash diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js index e1356fb6f9a..2742f24e0e1 100644 --- a/devenv/docker/loadtest/auth_token_test.js +++ b/devenv/docker/loadtest/auth_token_test.js @@ -65,7 +65,7 @@ export default (data) => { } }); - sleep(1) + sleep(5) } export const teardown = (data) => {} diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh index 474d75383b6..9517edf5d74 100755 --- a/devenv/docker/loadtest/run.sh +++ b/devenv/docker/loadtest/run.sh @@ -5,8 +5,9 @@ PWD=$(pwd) run() { duration='15m' url='http://localhost:3000' + vus='2' - while getopts ":d:u:" o; do + while getopts ":d:u:v:" o; do case "${o}" in d) duration=${OPTARG} @@ -14,11 +15,14 @@ run() { u) url=${OPTARG} ;; + v) + vus=${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 + docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus $vus --duration $duration src/auth_token_test.js } run "$@" From 4bd94b8aba0dafc8f4b264b486ad9fc35aeb5a24 Mon Sep 17 00:00:00 2001 From: Mitsuhiro Tanda Date: Thu, 7 Feb 2019 14:07:41 +0900 Subject: [PATCH 112/144] support json format templating --- public/app/features/templating/template_srv.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index de5efabcf23..11e90cbb5f7 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -156,6 +156,9 @@ export class TemplateSrv { } return value; } + case 'json': { + return JSON.stringify(value); + } case 'percentencode': { // like glob, but url escaped if (_.isArray(value)) { From 8b080f051127fe5ffe0a88f129ede102b7ccc043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 7 Feb 2019 09:09:59 +0100 Subject: [PATCH 113/144] Simplified condition --- public/app/features/dashboard/services/DashboardSrv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 72e0a2e3c07..eccb1df9e43 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -60,7 +60,7 @@ export class DashboardSrv { delete urlParams.edit; } - if (options.panelId !== undefined) { + if (options.panelId || options.panelId === 0) { urlParams.panelId = options.panelId; } else { delete urlParams.panelId; From 170783c292cafd53730f8f365f661f7fd83d124c Mon Sep 17 00:00:00 2001 From: bergquist Date: Thu, 7 Feb 2019 10:51:35 +0100 Subject: [PATCH 114/144] make hourly cleanup the default behavior --- conf/defaults.ini | 3 --- conf/sample.ini | 3 --- pkg/services/auth/auth_token_test.go | 7 +++---- pkg/services/auth/token_cleanup.go | 3 +-- pkg/setting/setting.go | 10 ++++------ 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index d0fc1133fac..a87aba10adb 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -256,9 +256,6 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 -# How often should expired auth tokens be deleted from the database. The default is each hour. -expired_tokens_cleanup_interval_hours = 1 - # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false diff --git a/conf/sample.ini b/conf/sample.ini index 2ff37239abf..dbbb3593f0f 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -236,9 +236,6 @@ log_queries = # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. ;token_rotation_interval_minutes = 10 -# How often should expired auth tokens be deleted from the database. The default is each hour. -;expired_tokens_cleanup_interval_hours = 1 - # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 964bd499a01..26dcbc5c868 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -423,10 +423,9 @@ func createTestContext(t *testing.T) *testContext { tokenService := &UserAuthTokenService{ SQLStore: sqlstore, Cfg: &setting.Cfg{ - LoginMaxInactiveLifetimeDays: 7, - LoginMaxLifetimeDays: 30, - TokenRotationIntervalMinutes: 10, - ExpiredTokensCleanupIntervalHours: 1, + LoginMaxInactiveLifetimeDays: 7, + LoginMaxLifetimeDays: 30, + TokenRotationIntervalMinutes: 10, }, log: log.New("test-logger"), } diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/token_cleanup.go index 0d5cbdaca10..aa5bc4856ab 100644 --- a/pkg/services/auth/token_cleanup.go +++ b/pkg/services/auth/token_cleanup.go @@ -6,8 +6,7 @@ import ( ) func (srv *UserAuthTokenService) Run(ctx context.Context) error { - jobInterval := time.Duration(srv.Cfg.ExpiredTokensCleanupIntervalHours) * time.Hour - ticker := time.NewTicker(jobInterval) + ticker := time.NewTicker(time.Hour) maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 43ac46e5ab8..21899482529 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -233,11 +233,10 @@ type Cfg struct { EnterpriseLicensePath string // Auth - LoginCookieName string - LoginMaxInactiveLifetimeDays int - LoginMaxLifetimeDays int - TokenRotationIntervalMinutes int - ExpiredTokensCleanupIntervalHours int + LoginCookieName string + LoginMaxInactiveLifetimeDays int + LoginMaxLifetimeDays int + TokenRotationIntervalMinutes int } type CommandLineArgs struct { @@ -673,7 +672,6 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { if cfg.TokenRotationIntervalMinutes < 2 { cfg.TokenRotationIntervalMinutes = 2 } - cfg.ExpiredTokensCleanupIntervalHours = auth.Key("expired_tokens_cleanup_interval_hours").MustInt(1) DisableLoginForm = auth.Key("disable_login_form").MustBool(false) DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) From 487e7b5ea699e37408890793c1a9975f77d7a4b4 Mon Sep 17 00:00:00 2001 From: bergquist Date: Thu, 7 Feb 2019 11:07:55 +0100 Subject: [PATCH 115/144] removes cleanup setting from docs --- docs/sources/auth/overview.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index 0f563fbe8d5..1d0b0d89b3f 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -63,9 +63,6 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 - -# How often should expired auth tokens be deleted from the database. The default is each hour. -expired_tokens_cleanup_interval_hours = 1 ``` ### Anonymous authentication From 5f808ddf220f2f2e691f1e58b05b64aa3fe77a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 7 Feb 2019 11:17:31 +0100 Subject: [PATCH 116/144] Added annother initDashboard test --- .../core/services/__mocks__/backend_srv.ts | 14 ++++++++ .../dashboard/state/initDashboard.test.ts | 36 +++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 public/app/core/services/__mocks__/backend_srv.ts diff --git a/public/app/core/services/__mocks__/backend_srv.ts b/public/app/core/services/__mocks__/backend_srv.ts new file mode 100644 index 00000000000..cbb04ebf9df --- /dev/null +++ b/public/app/core/services/__mocks__/backend_srv.ts @@ -0,0 +1,14 @@ + +const backendSrv = { + get: jest.fn(), + getDashboard: jest.fn(), + getDashboardByUid: jest.fn(), + getFolderByUid: jest.fn(), + post: jest.fn(), +}; + +export function getBackendSrv() { + return backendSrv; +} + + diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index 86d75e883cc..ebef6fd8494 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -2,6 +2,14 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { initDashboard, InitDashboardArgs } from './initDashboard'; import { DashboardRouteInfo } from 'app/types'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitServices, +} from './actions'; + +jest.mock('app/core/services/backend_srv'); const mockStore = configureMockStore([thunk]); @@ -13,6 +21,7 @@ interface ScenarioContext { variableSrv: any; dashboardSrv: any; keybindingSrv: any; + backendSrv: any; setup: (fn: () => void) => void; actions: any[]; storeState: any; @@ -59,6 +68,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { fixUrl: false, routeInfo: DashboardRouteInfo.Normal, }, + backendSrv: getBackendSrv(), timeSrv, annotationsSrv, unsavedChangesSrv, @@ -97,12 +107,12 @@ describeInitScenario('Initializing new dashboard', ctx => { ctx.args.routeInfo = DashboardRouteInfo.New; }); - it('Should send action to set loading state to fetching', () => { - expect(ctx.actions[0].type).toBe('DASHBOARD_INIT_FETCHING'); + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); }); - it('Should send action to set loading state to Initializing', () => { - expect(ctx.actions[1].type).toBe('DASHBOARD_INIT_SERVICES'); + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); }); it('Should update location with orgId query param', () => { @@ -110,8 +120,8 @@ describeInitScenario('Initializing new dashboard', ctx => { expect(ctx.actions[2].payload.query.orgId).toBe(12); }); - it('Should send action to set dashboard model', () => { - expect(ctx.actions[3].type).toBe('DASHBOARD_INIT_COMLETED'); + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type); expect(ctx.actions[3].payload.title).toBe('New dashboard'); }); @@ -125,4 +135,18 @@ describeInitScenario('Initializing new dashboard', ctx => { }); }); +describeInitScenario('Initializing home dashboard', ctx => { + ctx.setup(() => { + ctx.args.routeInfo = DashboardRouteInfo.Home; + ctx.backendSrv.get.mockReturnValue(Promise.resolve({ + redirectUri: '/u/123/my-home' + })); + }); + + it('Should redirect to custom home dashboard', () => { + expect(ctx.actions[1].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[1].payload.path).toBe('/u/123/my-home'); + }); +}); + From 61e9148eed12ad7898eea49017fe58375f6eb927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 7 Feb 2019 12:02:01 +0100 Subject: [PATCH 117/144] added way to test action called from react component --- public/app/core/redux/actionCreatorFactory.ts | 15 ++++++++++++++ public/app/core/redux/index.ts | 4 ++-- .../containers/DashboardPage.test.tsx | 20 ++++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts index d6477144df4..df0f02f5c99 100644 --- a/public/app/core/redux/actionCreatorFactory.ts +++ b/public/app/core/redux/actionCreatorFactory.ts @@ -53,5 +53,20 @@ export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCrea return { create }; }; +export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator { + calls: number; +} + +export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => { + const mock: NoPayloadActionCreatorMock = Object.assign( + (): ActionOf => { + mock.calls++; + return { type: creator.type, payload: undefined }; + }, + { type: creator.type, calls: 0 } + ); + return mock; +}; + // Should only be used by tests export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0); diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts index 1ed23a8d744..e5087123c1e 100644 --- a/public/app/core/redux/index.ts +++ b/public/app/core/redux/index.ts @@ -1,2 +1,2 @@ -export { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf, ActionCreator } from './actionCreatorFactory'; -export { reducerFactory } from './reducerFactory'; +export * from './actionCreatorFactory'; +export * from './reducerFactory'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 3c5ed8e9b11..1e6f8bd888e 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -3,6 +3,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { DashboardPage, Props, State } from './DashboardPage'; import { DashboardModel } from '../state'; import { cleanUpDashboard } from '../state/actions'; +import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux'; import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; jest.mock('sass/_variables.scss', () => ({ @@ -13,6 +14,7 @@ jest.mock('sass/_variables.scss', () => ({ jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); interface ScenarioContext { + cleanUpDashboardMock: NoPayloadActionCreatorMock; dashboard?: DashboardModel; setDashboardProp: (overrides?: any, metaOverrides?: any) => void; wrapper?: ShallowWrapper; @@ -42,6 +44,7 @@ function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) = let setupFn: () => void; const ctx: ScenarioContext = { + cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard), setup: fn => { setupFn = fn; }, @@ -63,7 +66,7 @@ function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) = initDashboard: jest.fn(), updateLocation: jest.fn(), notifyApp: jest.fn(), - cleanUpDashboard: cleanUpDashboard, + cleanUpDashboard: ctx.cleanUpDashboardMock, dashboard: null, }; @@ -230,4 +233,19 @@ describe('DashboardPage', () => { expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0); }); }); + + dashboardPageScenario("When dashboard unmounts", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph'}], + schemaVersion: 17, + }); + ctx.wrapper.unmount(); + }); + + it('Should call clean up action' , () => { + expect(ctx.cleanUpDashboardMock.calls).toBe(1); + }); + }); }); From 89d69a6f21e06e68fb35659446f4b6c149f11f49 Mon Sep 17 00:00:00 2001 From: Mitsuhiro Tanda Date: Thu, 7 Feb 2019 20:16:20 +0900 Subject: [PATCH 118/144] update docs --- docs/sources/features/datasources/cloudwatch.md | 10 ++++++++++ docs/sources/reference/templating.md | 1 + 2 files changed, 11 insertions(+) diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 783b17874e0..6ca10b2f8e8 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -214,6 +214,16 @@ Example `ec2_instance_attribute()` query ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] }) ``` +## Using json format template variables + +Some of query takes JSON format filter. Grafana support to interpolate template variable to JSON format string, it can use as filter string. + +If `env = 'production', 'staging'`, following query will return ARNs of EC2 instances which `Environment` tag is `production` or `staging`. + +``` +resource_arns(us-east-1, ec2:instance, {"Environment":${env:json}}) +``` + ## Cost Amazon provides 1 million CloudWatch API requests each month at no additional charge. Past this, diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 7426877654b..bf3fbd6a229 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -50,6 +50,7 @@ Filter Option | Example | Raw | Interpolated | Description `regex` | ${servers:regex} | `'test.', 'test2'` | (test\.|test2) | Formats multi-value variable into a regex string `pipe` | ${servers:pipe} | `'test.', 'test2'` | test.|test2 | Formats multi-value variable into a pipe-separated string `csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string +`json`| ${servers:json} | `'test1', 'test2'` | `["test1","test2"]` | Formats multi-value variable as a JSON string `distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB. `lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression. `percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded. From 2eca4caa5dc39235ca6f943857903b5d90d19362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 7 Feb 2019 13:58:24 +0100 Subject: [PATCH 119/144] added reducers tests --- .../features/dashboard/state/reducers.test.ts | 54 ++++++++++++++++++- public/app/types/dashboard.ts | 3 +- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index b96666019ba..800ba27e800 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -1,6 +1,13 @@ -import { loadDashboardPermissions } from './actions'; -import { OrgRole, PermissionLevel, DashboardState } from 'app/types'; +import { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitSlow, +} from './actions'; +import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types'; import { initialState, dashboardReducer } from './reducers'; +import { DashboardModel } from './DashboardModel'; describe('dashboard reducer', () => { describe('loadDashboardPermissions', () => { @@ -18,4 +25,47 @@ describe('dashboard reducer', () => { expect(state.permissions.length).toBe(2); }); }); + + describe('dashboardInitCompleted', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitSlow()); + state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' }))); + }); + + it('should set model', async () => { + expect(state.model.title).toBe('My dashboard'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + }); + + describe('dashboardInitFailed', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'})); + }); + + it('should set model', async () => { + expect(state.model.title).toBe('Dashboard init failed'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should set initError', async () => { + expect(state.initError.message).toBe('Oh no'); + }); + + it('should set phase failed', async () => { + expect(state.initPhase).toBe(DashboardInitPhase.Failed); + }); + }); }); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 9bcf258cf76..d81f9e2bcba 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,6 +1,7 @@ import { DashboardAcl } from './acl'; export interface MutableDashboard { + title: string; meta: DashboardMeta; destroy: () => void; } @@ -27,7 +28,7 @@ export interface DashboardMeta { focusPanelId?: boolean; isStarred?: boolean; showSettings?: boolean; - expires: string; + expires?: string; isSnapshot?: boolean; folderTitle?: string; folderUrl?: string; From 5ba3b0aa2ce8aa79b76a8f3f9de6197be5a8d198 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 7 Feb 2019 14:10:20 +0100 Subject: [PATCH 120/144] Selecting theme variable variant helper function --- .../ColorPicker/NamedColorsGroup.tsx | 12 ++++- .../ColorPicker/SpectrumPalettePointer.tsx | 17 ++++--- packages/grafana-ui/src/themes/index.d.ts | 2 +- packages/grafana-ui/src/themes/index.js | 2 +- .../src/themes/selectThemeVariant.test.ts | 51 +++++++++++++++++++ .../src/themes/selectThemeVariant.ts | 9 ++++ scripts/webpack/getThemeVariable.test.js | 2 +- 7 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 packages/grafana-ui/src/themes/selectThemeVariant.test.ts create mode 100644 packages/grafana-ui/src/themes/selectThemeVariant.ts diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx index 91407bc6cc6..2b5f7bb9b57 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx @@ -1,8 +1,9 @@ import React, { FunctionComponent } from 'react'; -import { Themeable, GrafanaThemeType } from '../../types'; +import { Themeable } from '../../types'; import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette'; import { Color } from 'csstype'; import { find, upperFirst } from 'lodash'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; type ColorChangeHandler = (color: ColorDefinition) => void; @@ -28,7 +29,14 @@ export const ColorSwatch: FunctionComponent = ({ }) => { const isSmall = variant === ColorSwatchVariant.Small; const swatchSize = isSmall ? '16px' : '32px'; - const selectedSwatchBorder = theme.type === GrafanaThemeType.Light ? '#ffffff' : '#1A1B1F'; + + const selectedSwatchBorder = selectThemeVariant( + { + light: theme.colors.white, + dark: theme.colors.black, + }, + theme.type + ); const swatchStyles = { width: swatchSize, diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx index 18327e96769..7e3b2cf06a3 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { GrafanaThemeType, Themeable } from '../../types'; +import { Themeable } from '../../types'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; export interface SpectrumPalettePointerProps extends Themeable { direction?: string; } -const SpectrumPalettePointer: React.FunctionComponent = ({ - theme, - direction, -}) => { +const SpectrumPalettePointer: React.FunctionComponent = ({ theme, direction }) => { const styles = { picker: { width: '16px', @@ -17,7 +15,14 @@ const SpectrumPalettePointer: React.FunctionComponent): () => void +export function mockTheme(themeMock: (name: string) => object): () => void diff --git a/packages/grafana-ui/src/themes/index.js b/packages/grafana-ui/src/themes/index.js index 91ba25349fd..21dd19cf119 100644 --- a/packages/grafana-ui/src/themes/index.js +++ b/packages/grafana-ui/src/themes/index.js @@ -3,7 +3,7 @@ const lightTheme = require('./light'); let mockedTheme; -let getTheme = name => mockedTheme || (name === 'light' ? lightTheme : darkTheme); +let getTheme = name => (mockedTheme && mockedTheme(name)) || (name === 'light' ? lightTheme : darkTheme); const mockTheme = mock => { mockedTheme = mock; diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.test.ts b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts new file mode 100644 index 00000000000..86e35f515c2 --- /dev/null +++ b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts @@ -0,0 +1,51 @@ +import { GrafanaThemeType } from '../types/theme'; +import { selectThemeVariant } from './selectThemeVariant'; +import { mockTheme } from './index'; + +const lightThemeMock = { + color: { + red: '#ff0000', + green: '#00ff00', + }, +}; + +const darkThemeMock = { + color: { + red: '#ff0000', + green: '#00ff00', + }, +}; + +describe('Theme variable variant selector', () => { + const restoreTheme = mockTheme(name => (name === GrafanaThemeType.Light ? lightThemeMock : darkThemeMock)); + + afterAll(() => { + restoreTheme(); + }); + it('return correct variable value for given theme', () => { + const theme = lightThemeMock; + + const selectedValue = selectThemeVariant( + { + dark: theme.color.red, + light: theme.color.green, + }, + GrafanaThemeType.Light + ); + + expect(selectedValue).toBe(lightThemeMock.color.green); + }); + + it('return dark theme variant if no theme given', () => { + const theme = lightThemeMock; + + const selectedValue = selectThemeVariant( + { + dark: theme.color.red, + light: theme.color.green, + } + ); + + expect(selectedValue).toBe(lightThemeMock.color.red); + }); +}); diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.ts b/packages/grafana-ui/src/themes/selectThemeVariant.ts new file mode 100644 index 00000000000..e7e8e780222 --- /dev/null +++ b/packages/grafana-ui/src/themes/selectThemeVariant.ts @@ -0,0 +1,9 @@ +import { GrafanaThemeType } from '../types/theme'; + +type VariantDescriptor = { + [key in GrafanaThemeType]: string | number; +}; + +export const selectThemeVariant = (variants: VariantDescriptor, currentTheme?: GrafanaThemeType) => { + return variants[currentTheme || GrafanaThemeType.Dark]; +}; diff --git a/scripts/webpack/getThemeVariable.test.js b/scripts/webpack/getThemeVariable.test.js index 78083330890..57a6fb5236c 100644 --- a/scripts/webpack/getThemeVariable.test.js +++ b/scripts/webpack/getThemeVariable.test.js @@ -15,7 +15,7 @@ const themeMock = { }; describe('Variables retrieval', () => { - const restoreTheme = mockTheme(themeMock); + const restoreTheme = mockTheme(() => themeMock); afterAll(() => { restoreTheme(); From e7917ce4e047729cd7c92c9030bf70c3c7c235e5 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 7 Feb 2019 14:10:56 +0100 Subject: [PATCH 121/144] Removed unnecessary code from ColorPicker and extended theme type --- .../components/ColorPicker/ColorPicker.tsx | 19 +++---------------- packages/grafana-ui/src/themes/dark.js | 11 ++++++++--- packages/grafana-ui/src/themes/default.js | 15 +++++++++++++++ packages/grafana-ui/src/themes/light.js | 5 +++++ packages/grafana-ui/src/types/theme.ts | 7 ++++++- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index 67201727a34..a48ecc44c45 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -1,8 +1,8 @@ import React, { Component, createRef } from 'react'; import PopperController from '../Tooltip/PopperController'; -import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper'; +import Popper from '../Tooltip/Popper'; import { ColorPickerPopover } from './ColorPickerPopover'; -import { GrafanaThemeType, Themeable } from '../../types'; +import { Themeable } from '../../types'; import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; import propDeprecationWarning from '../../utils/propDeprecationWarning'; @@ -18,7 +18,6 @@ export interface ColorPickerProps extends Themeable { */ onColorChange?: ColorPickerChangeHandler; enableNamedColors?: boolean; - withArrow?: boolean; children?: JSX.Element; } @@ -32,7 +31,6 @@ export const warnAboutColorPickerPropsDeprecation = (componentName: string, prop export const colorPickerFactory = ( popover: React.ComponentType, displayName = 'ColorPicker', - renderPopoverArrowFunction?: RenderPopperArrowFn ) => { return class ColorPicker extends Component { static displayName = displayName; @@ -50,17 +48,7 @@ export const colorPickerFactory = ( ...this.props, onChange: this.handleColorChange, }); - const { theme, withArrow, children } = this.props; - - const renderArrow: RenderPopperArrowFn = ({ arrowProps, placement }) => { - return ( -
- ); - }; + const { theme, children } = this.props; return ( @@ -72,7 +60,6 @@ export const colorPickerFactory = ( {...popperProps} referenceElement={this.pickerTriggerRef.current} wrapperClassName="ColorPicker" - renderArrow={withArrow && (renderPopoverArrowFunction || renderArrow)} onMouseLeave={hidePopper} onMouseEnter={showPopper} /> diff --git a/packages/grafana-ui/src/themes/dark.js b/packages/grafana-ui/src/themes/dark.js index 553eb537093..d5e7e5aacba 100644 --- a/packages/grafana-ui/src/themes/dark.js +++ b/packages/grafana-ui/src/themes/dark.js @@ -33,7 +33,7 @@ const darkTheme = { ...defaultTheme, type: 'dark', name: 'Grafana Dark', - colors: { + colors: { ...basicColors, inputBlack: '#09090b', queryRed: '#e24d42', @@ -57,7 +57,12 @@ const darkTheme = { linkColorHover: basicColors.white, linkColorExternal: basicColors.blue, headingColor: new tinycolor(basicColors.white).darken(11).toString(), - } -} + }, + background: { + dropdown: basicColors.dark3, + scrollbar: '#aeb5df', + scrollbar2: '#3a3a3a', + }, +}; module.exports = darkTheme; diff --git a/packages/grafana-ui/src/themes/default.js b/packages/grafana-ui/src/themes/default.js index 59ed050e360..d95c6ad5689 100644 --- a/packages/grafana-ui/src/themes/default.js +++ b/packages/grafana-ui/src/themes/default.js @@ -41,6 +41,21 @@ const theme = { m: '768px', l: '992px', xl: '1200px' + }, + spacing: { + xs: '0', + s: '0.2rem', + m: '1rem', + l: '1.5rem', + xl: '3rem', + gutter: '30px', + }, + border: { + radius: { + xs: '2px', + s: '3px', + m: '5px', + } } }; diff --git a/packages/grafana-ui/src/themes/light.js b/packages/grafana-ui/src/themes/light.js index de5c79e8319..8da6190caba 100644 --- a/packages/grafana-ui/src/themes/light.js +++ b/packages/grafana-ui/src/themes/light.js @@ -60,6 +60,11 @@ const lightTheme/*: GrafanaThemeType*/ = { linkColorHover: new tinycolor(basicColors.gray1).darken(20).toString(), linkColorExternal: basicColors.blueLight, headingColor: basicColors.gray1, + }, + background: { + dropdown: basicColors.white, + scrollbar: basicColors.gray5, + scrollbar2: basicColors.gray5, } } diff --git a/packages/grafana-ui/src/types/theme.ts b/packages/grafana-ui/src/types/theme.ts index 0fc81fa24e1..8a79658b423 100644 --- a/packages/grafana-ui/src/types/theme.ts +++ b/packages/grafana-ui/src/types/theme.ts @@ -62,6 +62,11 @@ export interface GrafanaTheme { m: string; }; }; + background: { + dropdown: string; + scrollbar: string; + scrollbar2: string; + }; colors: { black: string; white: string; @@ -102,7 +107,7 @@ export interface GrafanaTheme { warn: string; critical: string; - // TODO: should this be a part of theme? + // TODO: move to background section bodyBg: string; pageBg: string; bodyColor: string; From b545f518209badbc307cec4efdacd40935dbfbaf Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Thu, 7 Feb 2019 14:17:05 +0100 Subject: [PATCH 122/144] changelog: add notes about closing #15291 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bddf5e0f32..a603fbb1a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) +* **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) # 6.0.0-beta1 (2019-01-30) From df17f7dc459b156651d5bc27ee5aca27241aa242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 7 Feb 2019 18:20:16 +0100 Subject: [PATCH 123/144] fixed explore width-0 issue, fixes #15304 --- public/app/features/explore/Explore.tsx | 50 ++++++++++++++----------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 437b50db63c..a28776d813a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -205,28 +205,34 @@ export class Explore extends React.PureComponent {
- {({ width }) => ( -
- - {showingStartPage && } - {!showingStartPage && ( - <> - {supportsGraph && !supportsLogs && } - {supportsTable && } - {supportsLogs && ( - - )} - - )} - -
- )} + {({ width }) => { + if (width === 0) { + return null; + } + + return ( +
+ + {showingStartPage && } + {!showingStartPage && ( + <> + {supportsGraph && !supportsLogs && } + {supportsTable && } + {supportsLogs && ( + + )} + + )} + +
+ ); + }}
)} From 13d9acb1ef8278e5ad704b785705da9ee51b3f3b Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Sun, 13 Jan 2019 20:04:11 +0100 Subject: [PATCH 124/144] ldap: adds docker block for freeipa --- .gitignore | 1 + .../docker/blocks/freeipa/docker-compose.yaml | 54 ++++++++++++++ .../docker/blocks/freeipa/ldap_freeipa.toml | 74 +++++++++++++++++++ devenv/docker/blocks/freeipa/notes.md | 32 ++++++++ 4 files changed, 161 insertions(+) create mode 100644 devenv/docker/blocks/freeipa/docker-compose.yaml create mode 100644 devenv/docker/blocks/freeipa/ldap_freeipa.toml create mode 100644 devenv/docker/blocks/freeipa/notes.md diff --git a/.gitignore b/.gitignore index d599f762840..2945746832a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ devenv/docker-compose.yaml /conf/provisioning/**/custom.yaml /conf/provisioning/**/dev.yaml /conf/ldap_dev.toml +/conf/ldap_freeipa.toml profile.cov /grafana /local diff --git a/devenv/docker/blocks/freeipa/docker-compose.yaml b/devenv/docker/blocks/freeipa/docker-compose.yaml new file mode 100644 index 00000000000..8a9a5705f9d --- /dev/null +++ b/devenv/docker/blocks/freeipa/docker-compose.yaml @@ -0,0 +1,54 @@ +version: '3' + +volumes: + freeipa_data: {} + +services: + freeipa: + image: freeipa/freeipa-server:fedora-29 + container_name: freeipa + stdin_open: true + tty: true + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + hostname: ipa.example.test + environment: + # - DEBUG_TRACE=1 + - IPA_SERVER_IP=172.17.0.2 + - DEBUG_NO_EXIT=1 + - IPA_SERVER_HOSTNAME=ipa.example.test + - PASSWORD=Secret123 + - HOSTNAME=ipa.example.test + command: + - --admin-password=Secret123 + - --ds-password=Secret123 + - -U + - --realm=EXAMPLE.TEST + ports: + # FreeIPA WebUI + - "80:80" + - "443:443" + # Kerberos + - "88:88/udp" + - "88:88" + - "464:464/udp" + - "464:464" + # LDAP + - "389:389" + - "636:636" + # DNS + # - "53:53/udp" + # - "53:53" + # NTP + - "123:123/udp" + # other + - "7389:7389" + - "9443:9443" + - "9444:9444" + - "9445:9445" + tmpfs: + - /run + - /tmp + volumes: + - freeipa_data:/data:Z + - /sys/fs/cgroup:/sys/fs/cgroup:ro diff --git a/devenv/docker/blocks/freeipa/ldap_freeipa.toml b/devenv/docker/blocks/freeipa/ldap_freeipa.toml new file mode 100644 index 00000000000..358b7cdebf9 --- /dev/null +++ b/devenv/docker/blocks/freeipa/ldap_freeipa.toml @@ -0,0 +1,74 @@ +# To troubleshoot and get more log info enable ldap debug logging in grafana.ini +# [log] +# filters = ldap:debug + +[[servers]] +# Ldap server host (specify multiple hosts space separated) +host = "172.17.0.1" +# Default port is 389 or 636 if use_ssl = true +port = 389 +# Set to true if ldap server supports TLS +use_ssl = false +# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS) +start_tls = false +# set to true if you want to skip ssl cert validation +ssl_skip_verify = false +# set to the path to your root CA certificate or leave unset to use system defaults +# root_ca_cert = "/path/to/certificate.crt" + +# Search user bind dn +bind_dn = "uid=admin,cn=users,cn=accounts,dc=example,dc=test" +# Search user bind password +# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" +bind_password = 'Secret123' + +# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" +search_filter = "(uid=%s)" + +# An array of base dns to search through +search_base_dns = ["cn=users,cn=accounts,dc=example,dc=test"] + +# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups. +# This is done by enabling group_search_filter below. You must also set member_of= "cn" +# in [servers.attributes] below. + +# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN +# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of +# below in such a way that the user's recursive group membership is considered. +# +# Nested Groups + Active Directory (AD) Example: +# +# AD groups store the Distinguished Names (DNs) of members, so your filter must +# recursively search your groups for the authenticating user's DN. For example: +# +# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)" +# group_search_filter_user_attribute = "distinguishedName" +# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] +# +# [servers.attributes] +# ... +# member_of = "distinguishedName" + +## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available) +# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))" +## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter. +## Defaults to the value of username in [server.attributes] +## Valid options are any of your values in [servers.attributes] +## If you are using nested groups you probably want to set this and member_of in +## [servers.attributes] to "distinguishedName" +# group_search_filter_user_attribute = "distinguishedName" +## An array of the base DNs to search through for groups. Typically uses ou=groups +# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] + +# Specify names of the ldap attributes your ldap uses +[servers.attributes] +name = "givenName" +username = "uid" +member_of = "memberOf" +# surname = "sn" +# email = "mail" + +[[servers.group_mappings]] +# If you want to match all (or no ldap groups) then you can use wildcard +group_dn = "*" +org_role = "Viewer" diff --git a/devenv/docker/blocks/freeipa/notes.md b/devenv/docker/blocks/freeipa/notes.md new file mode 100644 index 00000000000..76afdf913c8 --- /dev/null +++ b/devenv/docker/blocks/freeipa/notes.md @@ -0,0 +1,32 @@ +# Notes on FreeIPA LDAP Docker Block + +Users have to be created manually. The docker-compose up command takes a few minutes to run. + +## Create a user + +`docker exec -it freeipa /bin/bash` + +To create a user with username: `ldap-viewer` and password: `grafana123` + +```bash +kinit admin +``` + +Log in with password `Secret123` + +```bash +ipa user-add ldap-viewer --first ldap --last viewer +ipa passwd ldap-viewer +ldappasswd -D uid=ldap-viewer,cn=users,cn=accounts,dc=example,dc=org -w test -a test -s grafana123 +``` + +## Enabling FreeIPA LDAP in Grafana + +Copy the ldap_freeipa.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block: + +```ini +[auth.ldap] +enabled = true +config_file = conf/ldap_freeipa.toml +; allow_sign_up = true +``` From 21a1507c7754c1028b3516831ac6989b9c99a485 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Sun, 13 Jan 2019 21:22:01 +0100 Subject: [PATCH 125/144] ldap: fixes #14432. Fix for IPA v4.6.4 IPA v4.6.4 introduced a fix that does not allow empty attributes to be sent in a search request. This fix only adds attributes to the request if they are mapped in the ldap toml file. --- pkg/login/ldap.go | 25 +++++++++++++++------- pkg/login/ldap_test.go | 48 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 402160ef5e3..7c45db95649 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -278,18 +278,27 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { var err error for _, searchBase := range a.server.SearchBaseDNs { + attributes := make([]string, 0) + + appendIfNotEmpty := func(slice []string, attr string) []string { + if attr == "" { + return slice + } + return append(slice, attr) + } + + attributes = appendIfNotEmpty(attributes, a.server.Attr.Username) + attributes = appendIfNotEmpty(attributes, a.server.Attr.Surname) + attributes = appendIfNotEmpty(attributes, a.server.Attr.Email) + attributes = appendIfNotEmpty(attributes, a.server.Attr.Name) + attributes = appendIfNotEmpty(attributes, a.server.Attr.MemberOf) + searchReq := ldap.SearchRequest{ BaseDN: searchBase, Scope: ldap.ScopeWholeSubtree, DerefAliases: ldap.NeverDerefAliases, - Attributes: []string{ - a.server.Attr.Username, - a.server.Attr.Surname, - a.server.Attr.Email, - a.server.Attr.Name, - a.server.Attr.MemberOf, - }, - Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1), + Attributes: attributes, + Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1), } a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq)) diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go index f1cf44dc554..c02fa02e030 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/login/ldap_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" "gopkg.in/ldap.v3" @@ -322,11 +323,51 @@ func TestLdapAuther(t *testing.T) { So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin") }) }) + + Convey("When searching for a user and not all five attributes are mapped", t, func() { + mockLdapConnection := &mockLdapConn{} + entry := ldap.Entry{ + DN: "dn", Attributes: []*ldap.EntryAttribute{ + {Name: "username", Values: []string{"roelgerrits"}}, + {Name: "surname", Values: []string{"Gerrits"}}, + {Name: "email", Values: []string{"roel@test.com"}}, + {Name: "name", Values: []string{"Roel"}}, + {Name: "memberof", Values: []string{"admins"}}, + }} + result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}} + mockLdapConnection.setSearchResult(&result) + + // Set up attribute map without surname and email + ldapAuther := &ldapAuther{ + server: &LdapServerConf{ + Attr: LdapAttributeMap{ + Username: "username", + Name: "name", + MemberOf: "memberof", + }, + SearchBaseDNs: []string{"BaseDNHere"}, + }, + conn: mockLdapConnection, + log: log.New("test-logger"), + } + + searchResult, err := ldapAuther.searchForUser("roelgerrits") + + So(err, ShouldBeNil) + So(searchResult, ShouldNotBeNil) + + // User should be searched in ldap + So(mockLdapConnection.searchCalled, ShouldBeTrue) + + // No empty attributes should be added to the search request + So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3) + }) } type mockLdapConn struct { - result *ldap.SearchResult - searchCalled bool + result *ldap.SearchResult + searchCalled bool + searchAttributes []string } func (c *mockLdapConn) Bind(username, password string) error { @@ -339,8 +380,9 @@ func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) { c.result = result } -func (c *mockLdapConn) Search(*ldap.SearchRequest) (*ldap.SearchResult, error) { +func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) { c.searchCalled = true + c.searchAttributes = sr.Attributes return c.result, nil } From b32d420a753a57c3e2c90e471655f81396dc0fc5 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Fri, 25 Jan 2019 16:43:54 +0100 Subject: [PATCH 126/144] ldap: refactoring. --- pkg/login/ldap.go | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 7c45db95649..c15cb865bd3 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -273,25 +273,28 @@ func (a *ldapAuther) initialBind(username, userPassword string) error { return nil } +func appendIfNotEmpty(slice []string, values ...string) []string { + for _, v := range values { + if v != "" { + slice = append(slice, v) + } + } + return slice +} + func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { var searchResult *ldap.SearchResult var err error for _, searchBase := range a.server.SearchBaseDNs { attributes := make([]string, 0) - - appendIfNotEmpty := func(slice []string, attr string) []string { - if attr == "" { - return slice - } - return append(slice, attr) - } - - attributes = appendIfNotEmpty(attributes, a.server.Attr.Username) - attributes = appendIfNotEmpty(attributes, a.server.Attr.Surname) - attributes = appendIfNotEmpty(attributes, a.server.Attr.Email) - attributes = appendIfNotEmpty(attributes, a.server.Attr.Name) - attributes = appendIfNotEmpty(attributes, a.server.Attr.MemberOf) + inputs := a.server.Attr + attributes = appendIfNotEmpty(attributes, + inputs.Username, + inputs.Surname, + inputs.Email, + inputs.Name, + inputs.MemberOf) searchReq := ldap.SearchRequest{ BaseDN: searchBase, From 71576a634e6057955087a862185f859ba04f705d Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Fri, 8 Feb 2019 14:06:06 +0100 Subject: [PATCH 127/144] Do not use js theme variables in sass (poor dev experience for now) --- package.json | 1 - .../grafana-ui/.storybook/webpack.config.js | 6 +- public/sass/_variables.dark.scss | 95 +++++++++---------- public/sass/_variables.light.scss | 90 ++++++++---------- public/sass/_variables.scss | 44 ++++----- scripts/webpack/getThemeVariable.js | 50 ---------- scripts/webpack/getThemeVariable.test.js | 40 -------- scripts/webpack/sass.rule.js | 6 +- scripts/webpack/webpack.hot.js | 8 +- yarn.lock | 5 - 10 files changed, 112 insertions(+), 233 deletions(-) delete mode 100644 scripts/webpack/getThemeVariable.js delete mode 100644 scripts/webpack/getThemeVariable.test.js diff --git a/package.json b/package.json index 41c319c7f82..5ac751ced3f 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "ng-annotate-webpack-plugin": "^0.3.0", "ngtemplate-loader": "^2.0.1", "node-sass": "^4.11.0", - "node-sass-utils": "^1.1.2", "npm": "^5.4.2", "optimize-css-assets-webpack-plugin": "^4.0.2", "phantomjs-prebuilt": "^2.1.15", diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js index 4f27b71bb60..307a1142a7d 100644 --- a/packages/grafana-ui/.storybook/webpack.config.js +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -1,5 +1,4 @@ const path = require('path'); -const getThemeVariable = require('../../../scripts/webpack/getThemeVariable'); module.exports = (baseConfig, env, config) => { config.module.rules.push({ @@ -36,10 +35,7 @@ module.exports = (baseConfig, env, config) => { { loader: 'sass-loader', options: { - sourceMap: false, - functions: { - 'getThemeVariable($themeVar, $themeName: dark)': getThemeVariable, - }, + sourceMap: false }, }, ], diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 16d29ce41f7..149a1247b8e 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -3,69 +3,67 @@ $theme-name: dark; +// Grays // ------------------------- -$black: getThemeVariable('colors.black', $theme-name); -$dark-1: getThemeVariable('colors.dark1', $theme-name); -$dark-2: getThemeVariable('colors.dark2', $theme-name); -$dark-3: getThemeVariable('colors.dark3', $theme-name); -$dark-4: getThemeVariable('colors.dark4', $theme-name); -$dark-5: getThemeVariable('colors.dark5', $theme-name); -$gray-1: getThemeVariable('colors.gray1', $theme-name); -$gray-2: getThemeVariable('colors.gray2', $theme-name); -$gray-3: getThemeVariable('colors.gray3', $theme-name); -$gray-4: getThemeVariable('colors.gray4', $theme-name); -$gray-5: getThemeVariable('colors.gray5', $theme-name); -$gray-6: getThemeVariable('colors.gray6', $theme-name); -$gray-7: getThemeVariable('colors.gray7', $theme-name); +$black: #000; +$dark-1: #141414; +$dark-2: #1f1f20; +$dark-3: #262628; +$dark-4: #333333; +$dark-5: #444444; +$gray-1: #555555; +$gray-2: #8e8e8e; +$gray-3: #b3b3b3; +$gray-4: #d8d9da; +$gray-5: #ececec; +$gray-7: #fbfbfb; -$gray-blue: getThemeVariable('colors.grayBlue', $theme-name); -$input-black: getThemeVariable('colors.inputBlack', $theme-name); +$gray-blue: #212327; +$input-black: #09090b; -$white: getThemeVariable('colors.white', $theme-name); +$white: #fff; // Accent colors // ------------------------- -$blue: getThemeVariable('colors.blue', $theme-name); -$blue-dark: getThemeVariable('colors.blueDark', $theme-name); -$green: getThemeVariable('colors.green', $theme-name); -$red: getThemeVariable('colors.red', $theme-name); -$yellow: getThemeVariable('colors.yellow', $theme-name); -$pink: getThemeVariable('colors.pink', $theme-name); -$purple: getThemeVariable('colors.purple', $theme-name); -$variable: getThemeVariable('colors.variable', $theme-name); -$orange: getThemeVariable('colors.orange', $theme-name); +$blue: #33b5e5; +$blue-dark: #005f81; +$green: #299c46; +$red: #d44a3a; +$yellow: #ecbb13; +$purple: #9933cc; +$variable: #32d1df; +$orange: #eb7b18; $brand-primary: $orange; $brand-success: $green; $brand-warning: $brand-primary; $brand-danger: $red; -$query-red: getThemeVariable('colors.queryRed', $theme-name); -$query-green: getThemeVariable('colors.queryGreen', $theme-name); -$query-purple: getThemeVariable('colors.queryPurple', $theme-name); -$query-keyword: getThemeVariable('colors.queryKeyword', $theme-name); -$query-orange: getThemeVariable('colors.queryOrange', $theme-name); +$query-red: #e24d42; +$query-green: #74e680; +$query-purple: #fe85fc; +$query-keyword: #66d9ef; +$query-orange: $orange; // Status colors // ------------------------- -$online: getThemeVariable('colors.online', $theme-name); -$warn: getThemeVariable('colors.warn', $theme-name); -$critical: getThemeVariable('colors.critical', $theme-name); +$online: #10a345; +$warn: #f79520; +$critical: #ed2e18; // Scaffolding // ------------------------- -$body-bg: getThemeVariable('colors.bodyBg', $theme-name); -$page-bg: getThemeVariable('colors.pageBg', $theme-name); +$body-bg: rgb(23, 24, 25); +$page-bg: rgb(22, 23, 25); -$body-color: getThemeVariable('colors.bodyColor', $theme-name); -$text-color: getThemeVariable('colors.textColor', $theme-name); -$text-color-strong: getThemeVariable('colors.textColorStrong', $theme-name); -$text-color-weak: getThemeVariable('colors.textColorWeak', $theme-name); -$text-color-faint: getThemeVariable('colors.textColorFaint', $theme-name); -$text-color-emphasis: getThemeVariable('colors.textColorEmphasis', $theme-name); +$body-color: $gray-4; +$text-color: $gray-4; +$text-color-strong: $white; +$text-color-weak: $gray-2; +$text-color-faint: $dark-5; +$text-color-emphasis: $gray-5; -$text-shadow-strong: 1px 1px 4px getThemeVariable('colors.black', $theme-name); -$text-shadow-faint: 1px 1px 4px #2d2d2d; +$text-shadow-faint: 1px 1px 4px rgb(45, 45, 45); $textShadow: none; // gradients @@ -81,11 +79,10 @@ $edit-gradient: linear-gradient(180deg, rgb(22, 23, 25) 50%, #090909); // Links // ------------------------- -$link-color: getThemeVariable('colors.linkColor', $theme-name); -$link-color-disabled: getThemeVariable('colors.linkColorDisabled', $theme-name); -$link-hover-color: getThemeVariable('colors.linkColorHover', $theme-name); - -$external-link-color: getThemeVariable('colors.linkColorExternal', $theme-name); +$link-color: darken($white, 11%); +$link-color-disabled: darken($link-color, 30%); +$link-hover-color: $white; +$external-link-color: $blue; // Typography // ------------------------- @@ -132,7 +129,7 @@ $list-item-shadow: $card-shadow; $empty-list-cta-bg: $gray-blue; // Scrollbars -$scrollbarBackground: #aeb5df; +$scrollbarBackground: #404357; $scrollbarBackground2: #3a3a3a; $scrollbarBorder: black; diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index a4e4e806a68..97d7a374765 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -6,66 +6,59 @@ $theme-name: light; // Grays // ------------------------- $black: #000; +$dark-2: #1e2028; +$dark-3: #303133; +$dark-4: #35373f; +$dark-5: #41444b; +$gray-1: #52545c; +$gray-2: #767980; +$gray-3: #acb6bf; +$gray-4: #c7d0d9; +$gray-5: #dde4ed; +$gray-6: #e9edf2; +$gray-7: #f7f8fa; -// ------------------------- -$black: getThemeVariable('colors.black', $theme-name); -$dark-1: getThemeVariable('colors.dark1', $theme-name); -$dark-2: getThemeVariable('colors.dark2', $theme-name); -$dark-3: getThemeVariable('colors.dark3', $theme-name); -$dark-4: getThemeVariable('colors.dark4', $theme-name); -$dark-5: getThemeVariable('colors.dark5', $theme-name); -$gray-1: getThemeVariable('colors.gray1', $theme-name); -$gray-2: getThemeVariable('colors.gray2', $theme-name); -$gray-3: getThemeVariable('colors.gray3', $theme-name); -$gray-4: getThemeVariable('colors.gray4', $theme-name); -$gray-5: getThemeVariable('colors.gray5', $theme-name); -$gray-6: getThemeVariable('colors.gray6', $theme-name); -$gray-7: getThemeVariable('colors.gray7', $theme-name); - -$white: getThemeVariable('colors.white', $theme-name); +$white: #fff; // Accent colors // ------------------------- -$blue: getThemeVariable('colors.blue', $theme-name); -$blue-dark: getThemeVariable('colors.blueDark', $theme-name); -$blue-light: getThemeVariable('colors.blueLight', $theme-name); -$green: getThemeVariable('colors.green', $theme-name); -$red: getThemeVariable('colors.red', $theme-name); -$yellow: getThemeVariable('colors.yellow', $theme-name); -$orange: getThemeVariable('colors.orange', $theme-name); -$pink: getThemeVariable('colors.pink', $theme-name); -$purple: getThemeVariable('colors.purple', $theme-name); -$variable: getThemeVariable('colors.variable', $theme-name); +$blue: #0083b3; +$blue-light: #00a8e6; +$green: #3aa655; +$red: #d44939; +$yellow: #ff851b; +$orange: #ff7941; +$purple: #9954bb; +$variable: $blue; $brand-primary: $orange; $brand-success: $green; $brand-warning: $orange; $brand-danger: $red; -$query-red: getThemeVariable('colors.queryRed', $theme-name); -$query-green: getThemeVariable('colors.queryGreen', $theme-name); -$query-purple: getThemeVariable('colors.queryPurple', $theme-name); -$query-keyword: getThemeVariable('colors.queryKeyword', $theme-name); -$query-orange: getThemeVariable('colors.queryOrange', $theme-name); +$query-red: $red; +$query-green: $green; +$query-purple: $purple; +$query-orange: $orange; +$query-keyword: $blue; // Status colors // ------------------------- -$online: getThemeVariable('colors.online', $theme-name); -$warn: getThemeVariable('colors.warn', $theme-name); -$critical: getThemeVariable('colors.critical', $theme-name); +$online: #01a64f; +$warn: #f79520; +$critical: #ec2128; // Scaffolding // ------------------------- +$body-bg: $gray-7; +$page-bg: $gray-7; -$body-bg: getThemeVariable('colors.bodyBg', $theme-name); -$page-bg: getThemeVariable('colors.pageBg', $theme-name); - -$body-color: getThemeVariable('colors.bodyColor', $theme-name); -$text-color: getThemeVariable('colors.textColor', $theme-name); -$text-color-strong: getThemeVariable('colors.textColorStrong', $theme-name); -$text-color-weak: getThemeVariable('colors.textColorWeak', $theme-name); -$text-color-faint: getThemeVariable('colors.textColorFaint', $theme-name); -$text-color-emphasis: getThemeVariable('colors.textColorEmphasis', $theme-name); +$body-color: $gray-1; +$text-color: $gray-1; +$text-color-strong: $dark-2; +$text-color-weak: $gray-2; +$text-color-faint: $gray-4; +$text-color-emphasis: $dark-5; $text-shadow-faint: none; $textShadow: none; @@ -83,15 +76,14 @@ $edit-gradient: linear-gradient(-60deg, $gray-7, #f5f6f9 70%, $gray-7 98%); // Links // ------------------------- -$link-color: getThemeVariable('colors.linkColor', $theme-name); -$link-color-disabled: getThemeVariable('colors.linkColorDisabled', $theme-name); -$link-hover-color: getThemeVariable('colors.linkColorHover', $theme-name); - -$external-link-color: getThemeVariable('colors.linkColorExternal', $theme-name); +$link-color: $gray-1; +$link-color-disabled: lighten($link-color, 30%); +$link-hover-color: darken($link-color, 20%); +$external-link-color: $blue-light; // Typography // ------------------------- -$headings-color: getThemeVariable('colors.headingColor', $theme-name); +$headings-color: $text-color; $abbr-border-color: $gray-2 !default; $text-muted: $text-color-weak; diff --git a/public/sass/_variables.scss b/public/sass/_variables.scss index eab0e8c7f5a..4e9e69c4d2f 100644 --- a/public/sass/_variables.scss +++ b/public/sass/_variables.scss @@ -47,45 +47,45 @@ $enable-flex: true; // Typography // ------------------------- -$font-family-sans-serif: getThemeVariable('typography.fontFamily.sansSerif'); -$font-family-serif: getThemeVariable('typography.fontFamily.serif'); -$font-family-monospace: getThemeVariable('typography.fontFamily.monospace'); +$font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif; +$font-family-serif: Georgia, 'Times New Roman', Times, serif; +$font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace; $font-family-base: $font-family-sans-serif !default; -$font-size-root: getThemeVariable('typography.size.m') !default; -$font-size-base: getThemeVariable('typography.size.base') !default; +$font-size-root: 14px !default; +$font-size-base: 13px !default; -$font-size-lg: getThemeVariable('typography.size.l') !default; -$font-size-md: getThemeVariable('typography.size.m') !default; -$font-size-sm: getThemeVariable('typography.size.s') !default; -$font-size-xs: getThemeVariable('typography.size.xs') !default; +$font-size-lg: 18px !default; +$font-size-md: 14px !default; +$font-size-sm: 12px !default; +$font-size-xs: 10px !default; -$line-height-base: getThemeVariable('typography.lineHeight.l') !default; -$font-weight-semi-bold: getThemeVariable('typography.weight.semibold'); +$line-height-base: 1.5 !default; +$font-weight-semi-bold: 500; -$font-size-h1: getThemeVariable('typography.heading.h1') !default; -$font-size-h2: getThemeVariable('typography.heading.h2') !default; -$font-size-h3: getThemeVariable('typography.heading.h3') !default; -$font-size-h4: getThemeVariable('typography.heading.h4') !default; -$font-size-h5: getThemeVariable('typography.heading.h5') !default; -$font-size-h6: getThemeVariable('typography.heading.h6') !default; +$font-size-h1: 2rem !default; +$font-size-h2: 1.75rem !default; +$font-size-h3: 1.5rem !default; +$font-size-h4: 1.3rem !default; +$font-size-h5: 1.2rem !default; +$font-size-h6: 1rem !default; $display1-size: 6rem !default; $display2-size: 5.5rem !default; $display3-size: 4.5rem !default; $display4-size: 3.5rem !default; -$display1-weight: getThemeVariable('typography.weight.normal') !default; -$display2-weight: getThemeVariable('typography.weight.normal') !default; -$display3-weight: getThemeVariable('typography.weight.normal') !default; -$display4-weight: getThe1meVariable('typography.weight.normal') !default; +$display1-weight: 400 !default; +$display2-weight: 400 !default; +$display3-weight: 400 !default; +$display4-weight: 400 !default; $lead-font-size: 1.25rem !default; $lead-font-weight: 300 !default; $headings-margin-bottom: ($spacer / 2) !default; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$headings-font-weight: getThemeVariable('typography.weight.normal') !default; +$headings-font-weight: 400 !default; $headings-line-height: 1.1 !default; $hr-border-width: $border-width !default; diff --git a/scripts/webpack/getThemeVariable.js b/scripts/webpack/getThemeVariable.js deleted file mode 100644 index 3bd9b2a53d0..00000000000 --- a/scripts/webpack/getThemeVariable.js +++ /dev/null @@ -1,50 +0,0 @@ -const sass = require('node-sass'); -const sassUtils = require('node-sass-utils')(sass); -const { get } = require('lodash'); -const tinycolor = require('tinycolor2'); -const { getTheme } = require('@grafana/ui/src/themes'); - -const units = ['rem', 'em', 'vh', 'vw', 'vmin', 'vmax', 'ex', '%', 'px', 'cm', 'mm', 'in', 'pt', 'pc', 'ch']; -const matchDimension = value => value.match(/[a-zA-Z]+|[0-9]+/g); - -const isHex = value => { - const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi; - return hexRegex.test(value); -}; - -const isDimension = value => { - if (typeof value !== 'string') { - return false; - } - const [val, unit] = matchDimension(value); - return units.indexOf(unit) > -1; -}; - -/** - * @param {SassString} variablePath - * @param {"dark"|"light"} themeName - */ -function getThemeVariable(variablePath, themeName) { - const theme = getTheme(themeName.getValue()); - const variable = get(theme, variablePath.getValue()); - - if (!variable) { - throw new Error(`${variablePath.getValue()} is not defined for ${themeName.getValue()}`); - } - - if (isHex(variable)) { - const rgb = new tinycolor(variable).toRgb(); - const color = new sass.types.Color(rgb.r, rgb.g, rgb.b); - return color; - } - - if (isDimension(variable)) { - const [value, unit] = matchDimension(variable); - const dimension = new sassUtils.SassDimension(parseInt(value, 10), unit); - return sassUtils.castToSass(dimension); - } - - return sassUtils.castToSass(variable); -} - -module.exports = getThemeVariable; diff --git a/scripts/webpack/getThemeVariable.test.js b/scripts/webpack/getThemeVariable.test.js deleted file mode 100644 index 57a6fb5236c..00000000000 --- a/scripts/webpack/getThemeVariable.test.js +++ /dev/null @@ -1,40 +0,0 @@ -const sass = require('node-sass'); -const getThemeVariable = require('./getThemeVariable'); -const { mockTheme } = require('@grafana/ui'); - -const themeMock = { - color: { - background: '#ff0000', - }, - spacing: { - padding: '2em', - }, - typography: { - fontFamily: 'Arial, sans-serif', - }, -}; - -describe('Variables retrieval', () => { - const restoreTheme = mockTheme(() => themeMock); - - afterAll(() => { - restoreTheme(); - }); - - it('returns sass Color for color values', () => { - const result = getThemeVariable({ getValue: () => 'color.background' }, { getValue: () => {} }); - expect(result).toBeInstanceOf(sass.types.Color); - }); - it('returns sass Number for dimension values', () => { - const result = getThemeVariable({ getValue: () => 'spacing.padding' }, { getValue: () => {} }); - expect(result).toBeInstanceOf(sass.types.Number); - }); - it('returns sass String for string values', () => { - const result = getThemeVariable({ getValue: () => 'typography.fontFamily' }, { getValue: () => {} }); - expect(result).toBeInstanceOf(sass.types.String); - }); - - it('throws for unknown theme paths', () => { - expect(() => getThemeVariable({ getValue: () => 'what.ever' }, { getValue: () => {} })).toThrow(); - }); -}); diff --git a/scripts/webpack/sass.rule.js b/scripts/webpack/sass.rule.js index 78f6b60d33f..66a48a12b32 100644 --- a/scripts/webpack/sass.rule.js +++ b/scripts/webpack/sass.rule.js @@ -1,7 +1,6 @@ 'use strict'; const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const getThemeVariable = require('./getThemeVariable'); module.exports = function(options) { return { @@ -27,10 +26,7 @@ module.exports = function(options) { { loader: 'sass-loader', options: { - sourceMap: options.sourceMap, - functions: { - 'getThemeVariable($themeVar, $themeName: dark)': getThemeVariable, - }, + sourceMap: options.sourceMap }, }, ], diff --git a/scripts/webpack/webpack.hot.js b/scripts/webpack/webpack.hot.js index 4519e292c6b..c1053f1f7da 100644 --- a/scripts/webpack/webpack.hot.js +++ b/scripts/webpack/webpack.hot.js @@ -8,7 +8,6 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin'); const IgnoreNotFoundExportPlugin = require("./IgnoreNotFoundExportPlugin.js"); -const getThemeVariable = require("./getThemeVariable"); module.exports = merge(common, { entry: { @@ -87,12 +86,7 @@ module.exports = merge(common, { }, }, { - loader: 'sass-loader', - options: { - functions: { - "getThemeVariable($themeVar, $themeName: dark)": getThemeVariable - } - } + loader: 'sass-loader' } ], }, diff --git a/yarn.lock b/yarn.lock index cd3dfbbebd8..df2e1cea37e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11910,11 +11910,6 @@ node-releases@^1.0.0-alpha.11, node-releases@^1.1.3: dependencies: semver "^5.3.0" -node-sass-utils@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/node-sass-utils/-/node-sass-utils-1.1.2.tgz#d03639cfa4fc962398ba3648ab466f0db7cc2131" - integrity sha1-0DY5z6T8liOYujZIq0ZvDbfMITE= - node-sass@^4.11.0: version "4.11.0" resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.11.0.tgz#183faec398e9cbe93ba43362e2768ca988a6369a" From 7e03913d0d3c95cf2fa441d2f21648b4841e9f1a Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Fri, 8 Feb 2019 14:06:53 +0100 Subject: [PATCH 128/144] Use TS instead of JS to store theme variables@next --- .../src/themes/{dark.js => dark.ts} | 11 ++++++----- .../src/themes/{default.js => default.ts} | 2 +- packages/grafana-ui/src/themes/index.d.ts | 4 ---- packages/grafana-ui/src/themes/index.js | 16 ---------------- packages/grafana-ui/src/themes/index.ts | 14 ++++++++++++++ .../src/themes/{light.js => light.ts} | 19 +++++++++---------- .../src/themes/selectThemeVariant.test.ts | 1 + 7 files changed, 31 insertions(+), 36 deletions(-) rename packages/grafana-ui/src/themes/{dark.js => dark.ts} (87%) rename packages/grafana-ui/src/themes/{default.js => default.ts} (97%) delete mode 100644 packages/grafana-ui/src/themes/index.d.ts delete mode 100644 packages/grafana-ui/src/themes/index.js create mode 100644 packages/grafana-ui/src/themes/index.ts rename packages/grafana-ui/src/themes/{light.js => light.ts} (86%) diff --git a/packages/grafana-ui/src/themes/dark.js b/packages/grafana-ui/src/themes/dark.ts similarity index 87% rename from packages/grafana-ui/src/themes/dark.js rename to packages/grafana-ui/src/themes/dark.ts index d5e7e5aacba..deae022f63a 100644 --- a/packages/grafana-ui/src/themes/dark.js +++ b/packages/grafana-ui/src/themes/dark.ts @@ -1,5 +1,6 @@ -const defaultTheme = require('./default'); -const tinycolor = require('tinycolor2'); +import tinycolor from 'tinycolor2'; +import defaultTheme from './default'; +import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; const basicColors = { black: '#000000', @@ -29,9 +30,9 @@ const basicColors = { orange: '#eb7b18', }; -const darkTheme = { +const darkTheme: GrafanaTheme = { ...defaultTheme, - type: 'dark', + type: GrafanaThemeType.Dark, name: 'Grafana Dark', colors: { ...basicColors, @@ -65,4 +66,4 @@ const darkTheme = { }, }; -module.exports = darkTheme; +export default darkTheme; diff --git a/packages/grafana-ui/src/themes/default.js b/packages/grafana-ui/src/themes/default.ts similarity index 97% rename from packages/grafana-ui/src/themes/default.js rename to packages/grafana-ui/src/themes/default.ts index d95c6ad5689..bf318f526e7 100644 --- a/packages/grafana-ui/src/themes/default.js +++ b/packages/grafana-ui/src/themes/default.ts @@ -59,4 +59,4 @@ const theme = { } }; -module.exports = theme; +export default theme; diff --git a/packages/grafana-ui/src/themes/index.d.ts b/packages/grafana-ui/src/themes/index.d.ts deleted file mode 100644 index c16d489d9e0..00000000000 --- a/packages/grafana-ui/src/themes/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { GrafanaTheme } from "../types"; - -export function getTheme(themeName?: string): GrafanaTheme -export function mockTheme(themeMock: (name: string) => object): () => void diff --git a/packages/grafana-ui/src/themes/index.js b/packages/grafana-ui/src/themes/index.js deleted file mode 100644 index 21dd19cf119..00000000000 --- a/packages/grafana-ui/src/themes/index.js +++ /dev/null @@ -1,16 +0,0 @@ -const darkTheme = require('./dark'); -const lightTheme = require('./light'); - -let mockedTheme; - -let getTheme = name => (mockedTheme && mockedTheme(name)) || (name === 'light' ? lightTheme : darkTheme); - -const mockTheme = mock => { - mockedTheme = mock; - return () => (mockedTheme = null); -}; - -module.exports = { - getTheme, - mockTheme, -}; diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts new file mode 100644 index 00000000000..c0d9a4f2d32 --- /dev/null +++ b/packages/grafana-ui/src/themes/index.ts @@ -0,0 +1,14 @@ +import darkTheme from './dark'; +import lightTheme from './light'; +import { GrafanaTheme } from '../types/theme'; + +let themeMock: ((name?: string) => GrafanaTheme) | null; + +export let getTheme = (name?: string) => (themeMock && themeMock(name)) || (name === 'light' ? lightTheme : darkTheme); + +export const mockTheme = (mock: (name: string) => GrafanaTheme) => { + themeMock = mock; + return () => { + themeMock = null; + }; +}; diff --git a/packages/grafana-ui/src/themes/light.js b/packages/grafana-ui/src/themes/light.ts similarity index 86% rename from packages/grafana-ui/src/themes/light.js rename to packages/grafana-ui/src/themes/light.ts index 8da6190caba..fd1f1d05b95 100644 --- a/packages/grafana-ui/src/themes/light.js +++ b/packages/grafana-ui/src/themes/light.ts @@ -1,7 +1,6 @@ -// import { GrafanaThemeType } from "../theme"; - -const defaultTheme = require('./default'); -const tinycolor = require('tinycolor2'); +import tinycolor from 'tinycolor2'; +import defaultTheme from './default'; +import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; const basicColors = { black: '#000000', @@ -31,11 +30,11 @@ const basicColors = { orange: '#ff7941', }; -const lightTheme/*: GrafanaThemeType*/ = { +const lightTheme: GrafanaTheme = { ...defaultTheme, - type: 'light', + type: GrafanaThemeType.Light, name: 'Grafana Light', - colors: { + colors: { ...basicColors, variable: basicColors.blue, inputBlack: '#09090b', @@ -65,7 +64,7 @@ const lightTheme/*: GrafanaThemeType*/ = { dropdown: basicColors.white, scrollbar: basicColors.gray5, scrollbar2: basicColors.gray5, - } -} + }, +}; -module.exports = lightTheme; +export default lightTheme; diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.test.ts b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts index 86e35f515c2..66cb02a2372 100644 --- a/packages/grafana-ui/src/themes/selectThemeVariant.test.ts +++ b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts @@ -17,6 +17,7 @@ const darkThemeMock = { }; describe('Theme variable variant selector', () => { + // @ts-ignore const restoreTheme = mockTheme(name => (name === GrafanaThemeType.Light ? lightThemeMock : darkThemeMock)); afterAll(() => { From 5436c284481bd98ae63e24ddd82c7e1d428dc878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 8 Feb 2019 15:38:45 +0100 Subject: [PATCH 129/144] Minor refactoring around theme access --- jest.config.js | 2 - public/app/core/config.ts | 4 ++ .../panel/graph/Legend/LegendSeriesItem.tsx | 1 - .../app/plugins/panel/graph/data_processor.ts | 4 +- public/app/plugins/panel/graph/graph.ts | 7 +--- public/app/plugins/panel/graph/module.ts | 4 +- public/app/plugins/panel/singlestat/module.ts | 10 +---- public/app/plugins/panel/table/module.ts | 3 +- scripts/webpack/webpack.test.js | 38 ------------------- 9 files changed, 13 insertions(+), 60 deletions(-) delete mode 100644 scripts/webpack/webpack.test.js diff --git a/jest.config.js b/jest.config.js index 248435c43f2..c5c6bcb9f5f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,13 +6,11 @@ module.exports = { }, "moduleDirectories": ["node_modules", "public"], "roots": [ - "/scripts", "/public/app", "/public/test", "/packages" ], "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$", - "testPathIgnorePatterns": ["webpack.test.js"], "moduleFileExtensions": [ "ts", "tsx", diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 368b3798117..f4254ac251a 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { PanelPlugin } from 'app/types/plugins'; +import { GrafanaTheme, getTheme, GrafanaThemeType } from '@grafana/ui'; export interface BuildInfo { version: string; @@ -36,8 +37,11 @@ export class Settings { loginError: any; viewersCanEdit: boolean; disableSanitizeHtml: boolean; + theme: GrafanaTheme; constructor(options: Settings) { + this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); + const defaults = { datasources: {}, windowTitlePrefix: 'Grafana - ', diff --git a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx index e3de5b067ba..2cf45727c4a 100644 --- a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx +++ b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx @@ -2,7 +2,6 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { TimeSeries } from 'app/core/core'; import { SeriesColorPicker } from '@grafana/ui'; -// import { ThemeProvider } from 'app/core/utils/ConfigProvider'; export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total']; diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 0d4445e1981..2966bb33eb4 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { colors, GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui'; +import { colors, getColorFromHexRgbOrName } from '@grafana/ui'; import TimeSeries from 'app/core/time_series2'; import config from 'app/core/config'; @@ -113,7 +113,7 @@ export class DataProcessor { const series = new TimeSeries({ datapoints: datapoints, alias: alias, - color: getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark), + color: getColorFromHexRgbOrName(color, config.theme.type), unit: seriesData.unit, }); diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 846d11ea475..54ba4ed1e6f 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -25,7 +25,7 @@ import ReactDOM from 'react-dom'; import { Legend, GraphLegendProps } from './Legend/Legend'; import { GraphCtrl } from './module'; -import { GrafanaThemeType, getValueFormat } from '@grafana/ui'; +import { getValueFormat } from '@grafana/ui'; import { provideTheme } from 'app/core/utils/ConfigProvider'; const LegendWithThemeProvider = provideTheme(Legend); @@ -55,10 +55,7 @@ class GraphElement { this.panelWidth = 0; this.eventManager = new EventManager(this.ctrl); this.thresholdManager = new ThresholdManager(this.ctrl); - this.timeRegionManager = new TimeRegionManager( - this.ctrl, - config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark - ); + this.timeRegionManager = new TimeRegionManager(this.ctrl, config.theme.type); this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => { return this.sortedSeries; }); diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index cb1c0d98269..3919c4f69a9 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -10,7 +10,7 @@ import { MetricsPanelCtrl } from 'app/plugins/sdk'; import { DataProcessor } from './data_processor'; import { axesEditorComponent } from './axes_editor'; import config from 'app/core/config'; -import { GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui'; +import { getColorFromHexRgbOrName } from '@grafana/ui'; class GraphCtrl extends MetricsPanelCtrl { static template = template; @@ -244,7 +244,7 @@ class GraphCtrl extends MetricsPanelCtrl { } onColorChange = (series, color) => { - series.setColor(getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark)); + series.setColor(getColorFromHexRgbOrName(color, config.theme.type)); this.panel.aliasColors[series.alias] = color; this.render(); }; diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 4ea81ff8630..21ab32278f8 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -588,10 +588,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { fill: 1, zero: false, lineWidth: 1, - fillColor: getColorFromHexRgbOrName( - panel.sparkline.fillColor, - config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark - ), + fillColor: getColorFromHexRgbOrName(panel.sparkline.fillColor, config.theme.type), }, }, yaxes: { show: false }, @@ -608,10 +605,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { const plotSeries = { data: data.flotpairs, - color: getColorFromHexRgbOrName( - panel.sparkline.lineColor, - config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark - ), + color: getColorFromHexRgbOrName(panel.sparkline.lineColor, config.theme.type), }; $.plot(plotCanvas, [plotSeries], options); diff --git a/public/app/plugins/panel/table/module.ts b/public/app/plugins/panel/table/module.ts index 3d82dd4df68..268f5aa7ac4 100644 --- a/public/app/plugins/panel/table/module.ts +++ b/public/app/plugins/panel/table/module.ts @@ -6,7 +6,6 @@ import { transformDataToTable } from './transformers'; import { tablePanelEditor } from './editor'; import { columnOptionsTab } from './column_options'; import { TableRenderer } from './renderer'; -import { GrafanaThemeType } from '@grafana/ui'; class TablePanelCtrl extends MetricsPanelCtrl { static templateUrl = 'module.html'; @@ -131,7 +130,7 @@ class TablePanelCtrl extends MetricsPanelCtrl { this.dashboard.isTimezoneUtc(), this.$sanitize, this.templateSrv, - config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark, + config.theme.type ); return super.render(this.table); diff --git a/scripts/webpack/webpack.test.js b/scripts/webpack/webpack.test.js deleted file mode 100644 index ec9ee5e26df..00000000000 --- a/scripts/webpack/webpack.test.js +++ /dev/null @@ -1,38 +0,0 @@ -const webpack = require('webpack'); -const merge = require('webpack-merge'); -const common = require('./webpack.common.js'); - -config = merge(common, { - mode: 'development', - devtool: 'cheap-module-source-map', - - externals: { - 'react/addons': true, - 'react/lib/ExecutionEnvironment': true, - 'react/lib/ReactContext': true, - }, - - module: { - rules: [ - { - test: /\.tsx?$/, - exclude: /node_modules/, - use: { - loader: 'ts-loader', - options: { - transpileOnly: true, - }, - }, - }, - ], - }, - - plugins: [ - new webpack.SourceMapDevToolPlugin({ - filename: null, // if no value is provided the sourcemap is inlined - test: /\.(ts|js)($|\?)/i, // process .js and .ts files only - }), - ], -}); - -module.exports = config; From 3d5ae3dca34b1c1fa3281293ee4348454a07c99d Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 8 Feb 2019 08:10:02 -0800 Subject: [PATCH 130/144] mark packages as Apache license --- packages/grafana-build/package.json | 4 ++-- packages/grafana-ui/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/grafana-build/package.json b/packages/grafana-build/package.json index 24fb648c8d4..056e5d2c7ea 100644 --- a/packages/grafana-build/package.json +++ b/packages/grafana-build/package.json @@ -8,6 +8,6 @@ "tslint": "echo \"Nothing to do\"", "typecheck": "echo \"Nothing to do\"" }, - "author": "", - "license": "ISC" + "author": "Grafana Labs", + "license": "Apache-2.0" } diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 0d1b14a7150..a0c76f711af 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -8,8 +8,8 @@ "typecheck": "tsc --noEmit", "storybook": "start-storybook -p 9001 -c .storybook -s ../../public" }, - "author": "", - "license": "ISC", + "author": "Grafana Labs", + "license": "Apache-2.0", "dependencies": { "@torkelo/react-select": "2.1.1", "@types/react-color": "^2.14.0", From 1bc2a0af70304bee4b4a18beb5865c604cf2a942 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 8 Feb 2019 18:08:07 +0100 Subject: [PATCH 131/144] use unique datasource id when registering mysql tls config --- pkg/tsdb/mysql/mysql.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index d451150f1de..d307e12166c 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -39,8 +39,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin } if tlsConfig.RootCAs != nil || len(tlsConfig.Certificates) > 0 { - mysql.RegisterTLSConfig(datasource.Name, tlsConfig) - cnnstr += "&tls=" + datasource.Name + tlsConfigString := fmt.Sprintf("ds%d", datasource.Id) + mysql.RegisterTLSConfig(tlsConfigString, tlsConfig) + cnnstr += "&tls=" + tlsConfigString } logger.Debug("getEngine", "connection", cnnstr) From 169732997ddc38ea9b885bdbbb654ffbda8391b6 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 8 Feb 2019 12:46:30 -0800 Subject: [PATCH 132/144] support three letter hex color strings --- packages/grafana-ui/src/utils/namedColorsPalette.test.ts | 2 ++ packages/grafana-ui/src/utils/namedColorsPalette.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts index f875b9966f1..aa57b46636c 100644 --- a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts +++ b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts @@ -59,6 +59,8 @@ describe('colors', () => { it('returns color if specified as hex or rgb/a', () => { expect(getColorFromHexRgbOrName('ff0000')).toBe('ff0000'); expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000'); + expect(getColorFromHexRgbOrName('#FF0000')).toBe('#FF0000'); + expect(getColorFromHexRgbOrName('#CCC')).toBe('#CCC'); expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)'); expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)'); }); diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.ts b/packages/grafana-ui/src/utils/namedColorsPalette.ts index a99a93f4207..ee5741e794e 100644 --- a/packages/grafana-ui/src/utils/namedColorsPalette.ts +++ b/packages/grafana-ui/src/utils/namedColorsPalette.ts @@ -73,7 +73,7 @@ export const getColorDefinition = (hex: string, theme: GrafanaThemeType): ColorD }; const isHex = (color: string) => { - const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi; + const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3})$/gi; return hexRegex.test(color); }; From 748cb449117ac417b977a7ad1a4df2b08e205c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 8 Feb 2019 21:53:51 +0100 Subject: [PATCH 133/144] Fixed issues with plus button in threshold and panel option header, and current state in viz picker, fixes #15329 --- .../PanelOptionsGroup/_PanelOptionsGroup.scss | 4 ++-- .../ThresholdsEditor/_ThresholdsEditor.scss | 4 ++-- .../panel_editor/VisualizationTab.tsx | 20 ++++++++++++++----- public/sass/components/_panel_editor.scss | 12 ++++++----- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss index b5b815cf57c..993bf086c95 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss @@ -30,13 +30,13 @@ &:hover { .panel-options-group__add-circle { background-color: $btn-success-bg; - color: $text-color-strong; + color: $white; } } } .panel-options-group__add-circle { - @include gradientBar($btn-success-bg, $btn-success-bg-hl, $text-color); + @include gradientBar($btn-success-bg, $btn-success-bg-hl); border-radius: 50px; width: 20px; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss index 200adfbfd75..490b452234f 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -21,7 +21,7 @@ } .thresholds-row-add-button { - @include buttonBackground($btn-success-bg, $btn-success-bg-hl, $text-color); + @include buttonBackground($btn-success-bg, $btn-success-bg-hl); align-self: center; margin-right: 5px; @@ -34,7 +34,7 @@ cursor: pointer; &:hover { - color: $text-color-strong; + color: $white; } } diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index 94a403c11bf..0aeb8af41d9 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -119,7 +119,12 @@ export class VisualizationTab extends PureComponent { template += `
` + - (i > 0 ? `
{{ctrl.editorTabs[${i}].title}}
` : '') + + (i > 0 + ? `
+ {{ctrl.editorTabs[${i}].title}} + +
` + : '') + `
@@ -228,8 +233,13 @@ export class VisualizationTab extends PureComponent { }; return ( - + <> { } const mapStateToProps = (state: StoreState) => ({ - urlOpenVizPicker: !!state.location.query.openVizPicker + urlOpenVizPicker: !!state.location.query.openVizPicker, }); const mapDispatchToProps = { - updateLocation + updateLocation, }; export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps); diff --git a/public/sass/components/_panel_editor.scss b/public/sass/components/_panel_editor.scss index b791231a242..1de136c09f1 100644 --- a/public/sass/components/_panel_editor.scss +++ b/public/sass/components/_panel_editor.scss @@ -146,15 +146,17 @@ padding-bottom: 6px; transition: transform 1 ease; - &--current { - box-shadow: 0 0 6px $orange; - border: 1px solid $orange; - } - &:hover { box-shadow: $panel-editor-viz-item-shadow-hover; background: $panel-editor-viz-item-bg-hover; border: $panel-editor-viz-item-border-hover; + + } + + &--current { + box-shadow: 0 0 6px $orange !important; + border: 1px solid $orange !important; + background: $panel-editor-viz-item-bg !important; } } From bd6cefa53fc43e1a841ec13d448eefec77accb2a Mon Sep 17 00:00:00 2001 From: Nick Richards Date: Fri, 8 Feb 2019 14:51:50 -0800 Subject: [PATCH 134/144] Improve usability showing disabled lines in forms * Use gray-3 instead of gray-2 for text-color-weak in "light" theme --- public/sass/_variables.light.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 97d7a374765..0f4e15c91ec 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -56,7 +56,7 @@ $page-bg: $gray-7; $body-color: $gray-1; $text-color: $gray-1; $text-color-strong: $dark-2; -$text-color-weak: $gray-2; +$text-color-weak: $gray-3; $text-color-faint: $gray-4; $text-color-emphasis: $dark-5; From 2987a47a9b147bef3c8ebdc98ca962eac2a9cb4e Mon Sep 17 00:00:00 2001 From: Connor Patterson Date: Sat, 9 Feb 2019 13:47:08 -0500 Subject: [PATCH 135/144] Add aws ec2 api metrics for cloudwatch --- pkg/tsdb/cloudwatch/metric_find_query.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 34181d19673..8d186ba7a12 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -55,6 +55,7 @@ func init() { "AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"}, "AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"}, "AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, + "AWS/EC2/API": {"ClientErrors","RequestLimitExceeded","ServerErrors","SuccessfulCalls"}, "AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"}, "AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"}, "AWS/EFS": {"BurstCreditBalance", "ClientConnections", "DataReadIOBytes", "DataWriteIOBytes", "MetadataIOBytes", "TotalIOBytes", "PermittedThroughput", "PercentIOLimit"}, @@ -133,6 +134,7 @@ func init() { "AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"}, "AWS/EBS": {"VolumeId"}, "AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"}, + "AWS/EC2/API": {}, "AWS/EC2Spot": {"AvailabilityZone", "FleetRequestId", "InstanceType"}, "AWS/ECS": {"ClusterName", "ServiceName"}, "AWS/EFS": {"FileSystemId"}, From d2aed7e075207fe5da0fa1ffbf7fa151ae1c029a Mon Sep 17 00:00:00 2001 From: Connor Patterson Date: Sat, 9 Feb 2019 14:13:15 -0500 Subject: [PATCH 136/144] Fix formatting --- pkg/tsdb/cloudwatch/metric_find_query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 8d186ba7a12..ddda26dfd24 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -55,7 +55,7 @@ func init() { "AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"}, "AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"}, "AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"}, - "AWS/EC2/API": {"ClientErrors","RequestLimitExceeded","ServerErrors","SuccessfulCalls"}, + "AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"}, "AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"}, "AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"}, "AWS/EFS": {"BurstCreditBalance", "ClientConnections", "DataReadIOBytes", "DataWriteIOBytes", "MetadataIOBytes", "TotalIOBytes", "PermittedThroughput", "PercentIOLimit"}, From 716db35faeb0942568968e879bd20d994a9454d3 Mon Sep 17 00:00:00 2001 From: thatsparesh <45209+thatsparesh@users.noreply.github.com> Date: Sat, 9 Feb 2019 14:56:43 -0600 Subject: [PATCH 137/144] remove unnecessary spy --- public/test/specs/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index 1570c7dd9b7..b8307186540 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -143,7 +143,7 @@ export function DashboardViewStateStub(this: any) { } export function TimeSrvStub(this: any) { - this.init = sinon.spy(); + this.init = () => {}; this.time = { from: 'now-1h', to: 'now' }; this.timeRange = function(parse) { if (parse === false) { From 105879ab5dc59da377f794b4be582a4b90a7cd61 Mon Sep 17 00:00:00 2001 From: thatsparesh <45209+thatsparesh@users.noreply.github.com> Date: Sat, 9 Feb 2019 14:57:20 -0600 Subject: [PATCH 138/144] use timeSrv in metricFindQuery as timeRange --- .../plugins/datasource/mssql/datasource.ts | 14 ++---- .../datasource/mssql/specs/datasource.test.ts | 48 ++++++++++++++++++- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts index 1ede9cc3d1e..303cd0471d7 100644 --- a/public/app/plugins/datasource/mssql/datasource.ts +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -8,7 +8,7 @@ export class MssqlDatasource { interval: string; /** @ngInject */ - constructor(instanceSettings, private backendSrv, private $q, private templateSrv) { + constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) { this.name = instanceSettings.name; this.id = instanceSettings.id; this.responseParser = new ResponseParser(this.$q); @@ -107,19 +107,13 @@ export class MssqlDatasource { format: 'table', }; + const range = this.timeSrv.timeRange(); const data = { queries: [interpolatedQuery], + from: range.from.valueOf().toString(), + to: range.to.valueOf().toString(), }; - if (optionalOptions && optionalOptions.range) { - if (optionalOptions.range.from) { - data['from'] = optionalOptions.range.from.valueOf().toString(); - } - if (optionalOptions.range.to) { - data['to'] = optionalOptions.range.to.valueOf().toString(); - } - } - return this.backendSrv .datasourceRequest({ url: '/api/tsdb/query', diff --git a/public/app/plugins/datasource/mssql/specs/datasource.test.ts b/public/app/plugins/datasource/mssql/specs/datasource.test.ts index 0dd496bfe59..a05848b3da8 100644 --- a/public/app/plugins/datasource/mssql/specs/datasource.test.ts +++ b/public/app/plugins/datasource/mssql/specs/datasource.test.ts @@ -1,6 +1,6 @@ import moment from 'moment'; import { MssqlDatasource } from '../datasource'; -import { TemplateSrvStub } from 'test/specs/helpers'; +import { TemplateSrvStub, TimeSrvStub } from 'test/specs/helpers'; import { CustomVariable } from 'app/features/templating/custom_variable'; import q from 'q'; @@ -8,13 +8,14 @@ describe('MSSQLDatasource', () => { const ctx: any = { backendSrv: {}, templateSrv: new TemplateSrvStub(), + timeSrv: new TimeSrvStub(), }; beforeEach(() => { ctx.$q = q; ctx.instanceSettings = { name: 'mssql' }; - ctx.ds = new MssqlDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.$q, ctx.templateSrv); + ctx.ds = new MssqlDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.$q, ctx.templateSrv, ctx.timeSrv); }); describe('When performing annotationQuery', () => { @@ -188,6 +189,49 @@ describe('MSSQLDatasource', () => { }); }); + describe('When performing metricFindQuery', () => { + let results; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 1, + }, + refId: 'tempvar', + tables: [ + { + columns: [{ text: 'title' }], + rows: [['aTitle']], + }, + ], + }, + }, + }; + const time = { + from: moment(1521545610656), + to: moment(1521546251185) + }; + + beforeEach(() => { + ctx.timeSrv.setTime(time); + + ctx.backendSrv.datasourceRequest = options => { + results = options.data; + return ctx.$q.when({ data: response, status: 200 }); + }; + + return ctx.ds.metricFindQuery(query); + }); + + it('should pass timerange to datasourceRequest', () => { + expect(results.from).toBe(time.from.valueOf().toString()); + expect(results.to).toBe(time.to.valueOf().toString()); + expect(results.queries.length).toBe(1); + expect(results.queries[0].rawSql).toBe(query); + }); + }); + describe('When interpolating variables', () => { beforeEach(() => { ctx.variable = new CustomVariable({}, {}); From a0729b9b50e4c7ad761fa2884c2a3b608c1c8b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sun, 10 Feb 2019 17:05:58 +0100 Subject: [PATCH 139/144] provide time range to angular query controllers --- .../dashboard/panel_editor/QueryEditorRow.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx index eda10087d41..83ef70f62e7 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -7,10 +7,11 @@ import _ from 'lodash'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import { Emitter } from 'app/core/utils/emitter'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; // Types import { PanelModel } from '../state/PanelModel'; -import { DataQuery, DataSourceApi } from '@grafana/ui'; +import { DataQuery, DataSourceApi, TimeRange } from '@grafana/ui'; interface Props { panel: PanelModel; @@ -43,8 +44,15 @@ export class QueryEditorRow extends PureComponent { componentDidMount() { this.loadDatasource(); + this.props.panel.events.on('refresh', this.onPanelRefresh); } + onPanelRefresh = () => { + if (this.state.angularScope) { + this.state.angularScope.range = getTimeSrv().timeRange(); + } + }; + getAngularQueryComponentScope(): AngularQueryComponentScope { const { panel, query } = this.props; const { datasource } = this.state; @@ -56,6 +64,7 @@ export class QueryEditorRow extends PureComponent { refresh: () => panel.refresh(), render: () => panel.render(), events: panel.events, + range: getTimeSrv().timeRange(), }; } @@ -97,6 +106,8 @@ export class QueryEditorRow extends PureComponent { } componentWillUnmount() { + this.props.panel.events.off('refresh', this.onPanelRefresh); + if (this.angularQueryEditor) { this.angularQueryEditor.destroy(); } @@ -250,4 +261,5 @@ export interface AngularQueryComponentScope { datasource: DataSourceApi; toggleEditorMode?: () => void; getCollapsedText?: () => string; + range: TimeRange; } From 41217ea110875c703a50e047041464f0bb9bebbc Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 11 Feb 2019 08:39:33 +0100 Subject: [PATCH 140/144] changelog: add notes about closing #13324 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a603fbb1a78..38d76762d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) +* **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh) * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) * **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) From 7b761f0a2864d0619817ca90942b91294f613354 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 11 Feb 2019 08:44:04 +0100 Subject: [PATCH 141/144] changelog: add notes about closing #15189 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d76762d4b..79c52786898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) * **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) +### 6.6.0-beta1 fixes + +* **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189) + # 6.0.0-beta1 (2019-01-30) ### New Features From 06972144d20b2125034e51db7c58573231d17e8a Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 11 Feb 2019 08:48:03 +0100 Subject: [PATCH 142/144] changelog: add notes about closing #14233 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c52786898..a5664a4205a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) * **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) +* **Cloudwatch**: Add AWS/EC2/API metrics [#14233](https://github.com/grafana/grafana/issues/14233), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) * **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh) * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) From be11da5b31892127d817cbc982a470f54602e813 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 11 Feb 2019 08:49:10 +0100 Subject: [PATCH 143/144] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5664a4205a..b48766e97a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) * **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda) -### 6.6.0-beta1 fixes +### 6.0.0-beta1 fixes * **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189) From 5eea85a3a3287b7916c7fb5e97db9d86bdba5add Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 11 Feb 2019 09:06:20 +0100 Subject: [PATCH 144/144] changelog: add notes about closing #8570 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48766e97a3..30f4961343b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS/EC2/API metrics [#14233](https://github.com/grafana/grafana/issues/14233), thx [@tcpatterson](https://github.com/tcpatterson) * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) +* **MySQL**: Adds datasource SSL CA/client certificates support [#8570](https://github.com/grafana/grafana/issues/8570), thx [@bugficks](https://github.com/bugficks) * **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh) * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh) * **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda)