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 01/75] 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 02/75] 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 | 15 +- 6 files changed, 170 insertions(+), 118 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 Dashboard { +} + +export enum DashboardLoadingState { + NotStarted = 'Not started', + Fetching = 'Fetching', + Initializing = 'Initializing', + Error = 'Error', + Done = 'Done', +} + export interface DashboardState { - permissions: DashboardAcl[]; + 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 03/75] 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 04/75] 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 05/75] 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 06/75] 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 07/75] 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 09/75] 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 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 10/75] 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 11/75] 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 12/75] 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 13/75] 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 14/75] 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 15/75] 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 16/75] 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 17/75] 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 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 18/75] 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 19/75] 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 20/75] 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 21/75] 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 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 22/75] 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 23/75] 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 24/75] 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 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 25/75] 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 26/75] 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 27/75] 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 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 28/75] 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 08925ffad89a7a99f42a2ae604251e49a3409e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 13:49:35 +0100 Subject: [PATCH 29/75] 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 30/75] 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 31/75] 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 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 32/75] 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 33/75] 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 @@
-
+