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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] 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/89] move auth token middleware/hooks to middleware package fix/adds auth token middleware tests --- pkg/api/common_test.go | 63 +++++++++-- pkg/api/http_server.go | 18 +-- pkg/api/login.go | 17 ++- pkg/middleware/middleware.go | 65 ++++++++++- pkg/middleware/middleware_test.go | 165 +++++++++++++++++++++++++--- pkg/middleware/org_redirect_test.go | 31 ++++-- pkg/middleware/quota_test.go | 16 ++- pkg/models/context.go | 2 + 8 files changed, 324 insertions(+), 53 deletions(-) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index fe02c94e277..853a04b5c11 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -129,24 +130,70 @@ func setupScenarioContext(url string) *scenarioContext { return sc } +type fakeUserToken interface { + auth.UserToken + SetToken(token string) +} + +type userTokenImpl struct { + userId int64 + token string +} + +func (ut *userTokenImpl) GetUserId() int64 { + return ut.userId +} + +func (ut *userTokenImpl) GetToken() string { + return ut.token +} + +func (ut *userTokenImpl) SetToken(token string) { + ut.token = token +} + type fakeUserAuthTokenService struct { - initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool + createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) + tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) + revokeTokenProvider func(token auth.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { - return false + createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + revokeTokenProvider: func(token auth.UserToken) error { + return nil }, } } -func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { - return s.initContextWithTokenProvider(ctx, orgID) +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { - return nil +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { + return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil } +func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return s.tryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { + return s.revokeTokenProvider(token) +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 7b7c1478a4c..a0a65d73244 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -21,7 +21,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtoken" "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" @@ -48,14 +48,14 @@ type HTTPServer struct { streamManager *live.StreamManager httpSrv *http.Server - RouteRegister routing.RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` - HooksService *hooks.HooksService `inject:""` - CacheService *cache.CacheService `inject:""` - DatasourceCache datasources.CacheService `inject:""` - AuthTokenService auth.UserAuthTokenService `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Bus bus.Bus `inject:""` + RenderService rendering.Service `inject:""` + Cfg *setting.Cfg `inject:""` + HooksService *hooks.HooksService `inject:""` + CacheService *cache.CacheService `inject:""` + DatasourceCache datasources.CacheService `inject:""` + AuthTokenService authtoken.UserAuthTokenService `inject:""` } func (hs *HTTPServer) Init() error { diff --git a/pkg/api/login.go b/pkg/api/login.go index 49da147724e..d25e83d34e8 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -5,11 +5,14 @@ import ( "net/http" "net/url" + "github.com/grafana/grafana/pkg/services/auth/authtoken" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -126,17 +129,23 @@ func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) { if user == nil { - hs.log.Error("User login with nil user") + hs.log.Error("user login with nil user") } - err := hs.AuthTokenService.UserAuthenticatedHook(user, c) + userToken, err := hs.AuthTokenService.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent()) if err != nil { - hs.log.Error("User auth hook failed", "error", err) + hs.log.Error("failed to create auth token", "error", err) } + + middleware.WriteSessionCookie(c, userToken.GetToken(), middleware.OneYearInSeconds) } func (hs *HTTPServer) Logout(c *m.ReqContext) { - hs.AuthTokenService.SignOutUser(c) + if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != authtoken.ErrAuthTokenNotFound { + hs.log.Error("failed to revoke auth token", "error", err) + } + + middleware.WriteSessionCookie(c, "", -1) if setting.SignoutRedirectUrl != "" { c.Redirect(setting.SignoutRedirectUrl) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 3722ac3058f..6cf29340b82 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -1,13 +1,15 @@ package middleware import ( + "net/http" + "net/url" "strconv" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -21,7 +23,7 @@ var ( ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ) -func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { +func GetContextHandler(ats authtoken.UserAuthTokenService) macaron.Handler { return func(c *macaron.Context) { ctx := &m.ReqContext{ Context: c, @@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler { case initContextWithApiKey(ctx): case initContextWithBasicAuth(ctx, orgId): case initContextWithAuthProxy(ctx, orgId): - case ats.InitContextWithToken(ctx, orgId): + case initContextWithToken(ats, ctx, orgId): case initContextWithAnonymousUser(ctx): } @@ -166,6 +168,63 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } +const cookieName = "grafana_session" +const OneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often. + +func initContextWithToken(authTokenService authtoken.UserAuthTokenService, ctx *m.ReqContext, orgID int64) bool { + rawToken := ctx.GetCookie(cookieName) + if rawToken == "" { + return false + } + + token, err := authTokenService.LookupToken(rawToken) + if err != nil { + ctx.Logger.Error("failed to look up user based on cookie", "error", err) + return false + } + + query := m.GetSignedInUserQuery{UserId: token.GetUserId(), OrgId: orgID} + if err := bus.Dispatch(&query); err != nil { + ctx.Logger.Error("failed to get user with id", "userId", token.GetUserId(), "error", err) + return false + } + + ctx.SignedInUser = query.Result + ctx.IsSignedIn = true + ctx.UserToken = token + + rotated, err := authTokenService.TryRotateToken(token, ctx.RemoteAddr(), ctx.Req.UserAgent()) + if err != nil { + ctx.Logger.Error("failed to rotate token", "error", err) + return true + } + + if rotated { + WriteSessionCookie(ctx, token.GetToken(), OneYearInSeconds) + } + + return true +} + +func WriteSessionCookie(ctx *m.ReqContext, value string, maxAge int) { + if setting.Env == setting.DEV { + ctx.Logger.Info("new token", "unhashed token", value) + } + + ctx.Resp.Header().Del("Set-Cookie") + cookie := http.Cookie{ + Name: cookieName, + Value: url.QueryEscape(value), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: false, // TODO: use setting SecurityHTTPSCookies + MaxAge: maxAge, + SameSite: http.SameSiteLaxMode, // TODO: use setting LoginCookieSameSite + } + + http.SetCookie(ctx.Resp, &cookie) +} + func AddDefaultResponseHeaders() macaron.Handler { return func(ctx *m.ReqContext) { if ctx.IsApiRequest() && ctx.Req.Method == "GET" { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 4679c449853..4e10ee39201 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -10,6 +10,8 @@ import ( msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtoken" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -146,17 +148,91 @@ func TestMiddlewareContext(t *testing.T) { }) }) - middlewareScenario("Auth token service", func(sc *scenarioContext) { - var wasCalled bool - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - wasCalled = true - return false + middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: unhashedToken, + }, nil } sc.fakeReq("GET", "/").exec() - Convey("should call middleware", func() { - So(wasCalled, ShouldBeTrue) + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) + So(sc.context.UserToken.GetToken(), ShouldEqual, "token") + }) + + Convey("should not set cookie", func() { + So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "") + }) + }) + + middlewareScenario("Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: unhashedToken, + }, nil + } + + sc.userAuthTokenService.tryRotateTokenProvider = func(userToken auth.UserToken, clientIP, userAgent string) (bool, error) { + userToken.(fakeUserToken).SetToken("rotated") + return true, nil + } + + expectedCookie := &http.Cookie{ + Name: cookieName, + Value: "rotated", + Path: setting.AppSubUrl + "/", + HttpOnly: true, + MaxAge: OneYearInSeconds, + SameSite: http.SameSiteLaxMode, + } + + sc.fakeReq("GET", "/").exec() + + Convey("should init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeTrue) + So(sc.context.UserId, ShouldEqual, 12) + So(sc.context.UserToken.GetUserId(), ShouldEqual, 12) + So(sc.context.UserToken.GetToken(), ShouldEqual, "rotated") + }) + + Convey("should set cookie", func() { + So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String()) + }) + }) + + middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return nil, authtoken.ErrAuthTokenNotFound + } + + sc.fakeReq("GET", "/").exec() + + Convey("should not init context with user info", func() { + So(sc.context.IsSignedIn, ShouldBeFalse) + So(sc.context.UserId, ShouldEqual, 0) + So(sc.context.UserToken, ShouldBeNil) }) }) @@ -508,6 +584,7 @@ type scenarioContext struct { resp *httptest.ResponseRecorder apiKey string authHeader string + tokenSessionCookie string respJson map[string]interface{} handlerFunc handlerFunc defaultHandler macaron.Handler @@ -522,6 +599,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext { return sc } +func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext { + sc.tokenSessionCookie = unhashedToken + return sc +} + func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext { sc.authHeader = authHeader return sc @@ -571,6 +653,13 @@ func (sc *scenarioContext) exec() { sc.req.Header.Add("Authorization", sc.authHeader) } + if sc.tokenSessionCookie != "" { + sc.req.AddCookie(&http.Cookie{ + Name: cookieName, + Value: sc.tokenSessionCookie, + }) + } + sc.m.ServeHTTP(sc.resp, sc.req) if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" { @@ -582,24 +671,70 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) +type fakeUserToken interface { + auth.UserToken + SetToken(token string) +} + +type userTokenImpl struct { + userId int64 + token string +} + +func (ut *userTokenImpl) GetUserId() int64 { + return ut.userId +} + +func (ut *userTokenImpl) GetToken() string { + return ut.token +} + +func (ut *userTokenImpl) SetToken(token string) { + ut.token = token +} + type fakeUserAuthTokenService struct { - initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool + createTokenProvider func(userId int64, clientIP, userAgent string) (auth.UserToken, error) + tryRotateTokenProvider func(token auth.UserToken, clientIP, userAgent string) (bool, error) + lookupTokenProvider func(unhashedToken string) (auth.UserToken, error) + revokeTokenProvider func(token auth.UserToken) error } func newFakeUserAuthTokenService() *fakeUserAuthTokenService { return &fakeUserAuthTokenService{ - initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool { - return false + createTokenProvider: func(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + tryRotateTokenProvider: func(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + lookupTokenProvider: func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 0, + token: "", + }, nil + }, + revokeTokenProvider: func(token auth.UserToken) error { + return nil }, } } -func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool { - return s.initContextWithTokenProvider(ctx, orgID) +func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (auth.UserToken, error) { + return s.createTokenProvider(userId, clientIP, userAgent) } -func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error { - return nil +func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (auth.UserToken, error) { + return s.lookupTokenProvider(unhashedToken) } -func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil } +func (s *fakeUserAuthTokenService) TryRotateToken(token auth.UserToken, clientIP, userAgent string) (bool, error) { + return s.tryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *fakeUserAuthTokenService) RevokeToken(token auth.UserToken) error { + return s.revokeTokenProvider(token) +} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 46b8776fdcc..c7479b3e9bc 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -3,6 +3,8 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/auth" + "fmt" "github.com/grafana/grafana/pkg/bus" @@ -14,14 +16,21 @@ func TestOrgRedirectMiddleware(t *testing.T) { Convey("Can redirect to correct org", t, func() { middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return nil }) - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} - ctx.IsSignedIn = true - return true + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil } sc.m.Get("/", sc.defaultHandler) @@ -33,21 +42,23 @@ func TestOrgRedirectMiddleware(t *testing.T) { }) middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { + sc.withTokenSessionCookie("token") bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { return fmt.Errorf("") }) - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12} - ctx.IsSignedIn = true - return true - } - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} return nil }) + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil + } + sc.m.Get("/", sc.defaultHandler) sc.fakeReq("GET", "/?orgId=3").exec() diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 4f2203a5d3d..af22f41deba 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" @@ -74,10 +75,17 @@ func TestMiddlewareQuota(t *testing.T) { }) middlewareScenario("with user logged in", func(sc *scenarioContext) { - sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool { - ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12} - ctx.IsSignedIn = true - return true + sc.withTokenSessionCookie("token") + bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + return nil + }) + + sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (auth.UserToken, error) { + return &userTokenImpl{ + userId: 12, + token: "", + }, nil } bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { diff --git a/pkg/models/context.go b/pkg/models/context.go index df970451304..da63db63f45 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -13,6 +14,7 @@ import ( type ReqContext struct { *macaron.Context *SignedInUser + UserToken auth.UserToken // This should only be used by the auth_proxy Session session.SessionStore From a7a964ec19cec8f0848d14eee6109d3656f61701 Mon Sep 17 00:00:00 2001 From: SamuelToh Date: Sun, 27 Jan 2019 21:49:22 +1000 Subject: [PATCH 25/89] Added PATCH verb end point for annotation op Added new PATCH verb annotation endpoint Removed unwanted fmt Added test cases for PATCH verb annotation endpoint Fixed formatting issue Check arr len before proceeding Updated doc to include PATCH verb annotation endpt --- docs/sources/http_api/annotations.md | 27 +++++++++++- pkg/api/annotations.go | 59 ++++++++++++++++++++++++++ pkg/api/annotations_test.go | 62 ++++++++++++++++++++++++++++ pkg/api/api.go | 1 + pkg/api/dtos/annotations.go | 8 ++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index 6633714d77b..dee4ede0777 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -160,15 +160,18 @@ Content-Type: application/json } ``` -## Update Annotation +## Replace Annotation `PUT /api/annotations/:id` +Replaces the annotation that matches the specified id. + **Example Request**: ```json PUT /api/annotations/1141 HTTP/1.1 Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk Content-Type: application/json { @@ -180,6 +183,28 @@ Content-Type: application/json } ``` +## Update Annotation + +`PATCH /api/annotations/:id` + +Updates one or more properties of an annotation that matches the specified id. + +**Example Request**: + +```json +PATCH /api/annotations/1145 HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +Content-Type: application/json + +{ + "time":1507037197000, + "timeEnd":1507180807095, + "text":"New Annotation Description", + "tags":["tag6","tag7","tag8"] +} +``` + ## Delete Annotation By Id `DELETE /api/annotations/:id` diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 242b5531f51..da9b55a1c16 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -210,6 +210,65 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response { return Success("Annotation updated") } +func PatchAnnotation(c *m.ReqContext, cmd dtos.PatchAnnotationsCmd) Response { + annotationID := c.ParamsInt64(":annotationId") + + repo := annotations.GetRepository() + + if resp := canSave(c, repo, annotationID); resp != nil { + return resp + } + + items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId}) + + if err != nil || len(items) == 0 { + return Error(500, "Could not find annotation to update", err) + } + + existing := annotations.Item{ + OrgId: c.OrgId, + UserId: c.UserId, + Id: annotationID, + Epoch: items[0].Time, + Text: items[0].Text, + Tags: items[0].Tags, + RegionId: items[0].RegionId, + } + + if cmd.Tags != nil { + existing.Tags = cmd.Tags + } + + if cmd.Text != "" && cmd.Text != existing.Text { + existing.Text = cmd.Text + } + + if cmd.Time > 0 && cmd.Time != existing.Epoch { + existing.Epoch = cmd.Time + } + + if err := repo.Update(&existing); err != nil { + return Error(500, "Failed to update annotation", err) + } + + // Update region end time if provided + if existing.RegionId != 0 && cmd.TimeEnd > 0 { + itemRight := existing + itemRight.RegionId = existing.Id + itemRight.Epoch = cmd.TimeEnd + + // We don't know id of region right event, so set it to 0 and find then using query like + // ... WHERE region_id = AND id != ... + itemRight.Id = 0 + + if err := repo.Update(&itemRight); err != nil { + return Error(500, "Failed to update annotation for region end time", err) + } + } + + return Success("Annotation patched") +} + func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response { repo := annotations.GetRepository() diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 08f3018c694..ebdd867a031 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -27,6 +27,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) { IsRegion: false, } + patchCmd := dtos.PatchAnnotationsCmd{ + Time: 1000, + Text: "annotation text", + Tags: []string{"tag1", "tag2"}, + } + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER Convey("Should not be allowed to save an annotation", func() { @@ -40,6 +46,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -67,6 +78,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 200) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -100,6 +116,13 @@ func TestAnnotationsApiEndpoint(t *testing.T) { Id: 1, } + patchCmd := dtos.PatchAnnotationsCmd{ + Time: 8000, + Text: "annotation text 50", + Tags: []string{"foo", "bar"}, + Id: 1, + } + deleteCmd := dtos.DeleteAnnotationsCmd{ DashboardId: 1, PanelId: 1, @@ -136,6 +159,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 403) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 403) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -163,6 +191,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 200) }) + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) { sc.handlerFunc = DeleteAnnotationByID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() @@ -189,6 +222,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) { sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) }) + + patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) @@ -264,6 +303,29 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. }) } +func patchAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PatchAnnotation(c, cmd) + }) + + fakeAnnoRepo = &fakeAnnotationsRepo{} + annotations.SetRepository(fakeAnnoRepo) + + sc.m.Patch(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) { Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() diff --git a/pkg/api/api.go b/pkg/api/api.go index 980706d8355..0685ef3814d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -354,6 +354,7 @@ func (hs *HTTPServer) registerRoutes() { annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation)) annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation)) + annotationsRoute.Patch("/:annotationId", bind(dtos.PatchAnnotationsCmd{}), Wrap(PatchAnnotation)) annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion)) annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation)) }) diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index c917b0d9feb..b64329e56d1 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -22,6 +22,14 @@ type UpdateAnnotationsCmd struct { TimeEnd int64 `json:"timeEnd"` } +type PatchAnnotationsCmd struct { + Id int64 `json:"id"` + Time int64 `json:"time"` + Text string `json:"text"` + Tags []string `json:"tags"` + TimeEnd int64 `json:"timeEnd"` +} + type DeleteAnnotationsCmd struct { AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` From e4c92ae12433cefffc3b9855fc117def44a0ebb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 09:24:30 +0100 Subject: [PATCH 26/89] 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 27/89] 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 28/89] 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 29/89] 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 30/89] 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 31/89] 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 32/89] Prevent viewers from going into edit mode --- public/app/features/dashboard/containers/DashboardPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0d28ccb19a2..1bd5218fd60 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -130,7 +130,7 @@ export class DashboardPage extends PureComponent { if (panel) { dashboard.setViewMode(panel, urlFullscreen, urlEdit); this.setState({ - isEditing: urlEdit, + isEditing: urlEdit && dashboard.meta.canEdit, isFullscreen: urlFullscreen, fullscreenPanel: panel, rememberScrollTop: this.state.scrollTop, From ae0b027d69ce0fe2946aabfe55267150151a4038 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 09:34:04 +0100 Subject: [PATCH 33/89] chore: Replace sizeMe with AutoSizer in DashboardGrid --- .../dashboard/dashgrid/DashboardGrid.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 658bfad3816..5a65fadd74b 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -5,13 +5,12 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; +import { AutoSizer } from 'react-virtualized'; let lastGridWidth = 1200; let ignoreNextWidthChange = false; -interface GridWrapperProps { - size: { width: number; }; +interface SizedReactLayoutGridProps { layout: ReactGridLayout.Layout[]; onLayoutChange: (layout: ReactGridLayout.Layout[]) => void; children: JSX.Element | JSX.Element[]; @@ -25,8 +24,12 @@ interface GridWrapperProps { isFullscreen?: boolean; } +interface GridWrapperProps extends SizedReactLayoutGridProps { + sizedWidth: number; +} + function GridWrapper({ - size, + sizedWidth, layout, onLayoutChange, children, @@ -38,8 +41,8 @@ function GridWrapper({ isResizable, isDraggable, isFullscreen, -}: GridWrapperProps) { - const width = size.width > 0 ? size.width : lastGridWidth; +}: GridWrapperProps) { + const width = sizedWidth > 0 ? sizedWidth : lastGridWidth; // logic to ignore width changes (optimization) if (width !== lastGridWidth) { @@ -74,7 +77,16 @@ function GridWrapper({ ); } -const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); +const SizedReactLayoutGrid = (props: SizedReactLayoutGridProps) => ( + + {({width}) => ( + + )} + +); export interface DashboardGridProps { dashboard: DashboardModel; From 097396c517e1db4e5633f7625168c255511deb1c Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 09:57:54 +0100 Subject: [PATCH 34/89] chore: Replace withSize with AutoSizer in explore/Graph.tsx --- public/app/features/explore/Graph.test.tsx | 1 + public/app/features/explore/Graph.tsx | 27 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx index fe4deaf17aa..8976c677592 100644 --- a/public/app/features/explore/Graph.test.tsx +++ b/public/app/features/explore/Graph.test.tsx @@ -5,6 +5,7 @@ import { mockData } from './__mocks__/mockData'; const setup = (propOverrides?: object) => { const props = { + size: { width: 10, height: 20 }, data: mockData().slice(0, 19), range: { from: 'now-6h', to: 'now' }, ...propOverrides, diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 5d64dde28ce..b087f6a457d 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -1,7 +1,7 @@ import $ from 'jquery'; import React, { PureComponent } from 'react'; import moment from 'moment'; -import { withSize } from 'react-sizeme'; +import { AutoSizer } from 'react-virtualized'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; @@ -80,12 +80,15 @@ interface GraphProps { id?: string; range: RawTimeRange; split?: boolean; - size?: { width: number; height: number }; userOptions?: any; onChangeTime?: (range: RawTimeRange) => void; onToggleSeries?: (alias: string, hiddenSeries: Set) => void; } +interface SizedGraphProps extends GraphProps { + size: { width: number; height: number }; +} + interface GraphState { /** * Type parameter refers to the `alias` property of a `TimeSeries`. @@ -95,7 +98,7 @@ interface GraphState { showAllTimeSeries: boolean; } -export class Graph extends PureComponent { +export class Graph extends PureComponent { $el: any; dynamicOptions = null; @@ -116,7 +119,7 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { + componentDidUpdate(prevProps: SizedGraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || @@ -261,4 +264,18 @@ export class Graph extends PureComponent { } } -export default withSize()(Graph); +export default (props: GraphProps) => ( + + {({width, height}) => { + return ( + + ); + }} + +); From d68df9d704d2fb637ba302c86e11107fe5e1953e Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 12:09:24 +0100 Subject: [PATCH 35/89] fix: Calculation issue with AutoSizer in explore --- public/app/features/explore/Graph.tsx | 28 ++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index b087f6a457d..10006557349 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -265,17 +265,19 @@ export class Graph extends PureComponent { } export default (props: GraphProps) => ( - - {({width, height}) => { - return ( - - ); - }} - +
{/* div needed for AutoSizer to calculate, https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#observation */} + + {({width, height}) => ( +
+ {width > 0 && } +
+ )} +
+
); From 260b6f5de83133f668aa15a3874eeeb20d46cbe7 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 12:12:24 +0100 Subject: [PATCH 36/89] chore: Remove react-sizeme --- package.json | 1 - yarn.lock | 28 ---------------------------- 2 files changed, 29 deletions(-) diff --git a/package.json b/package.json index 77fd92baf57..18c0a56f0c4 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,6 @@ "react-highlight-words": "0.11.0", "react-popper": "^1.3.0", "react-redux": "^5.0.7", - "react-sizeme": "^2.3.6", "react-table": "^6.8.6", "react-transition-group": "^2.2.1", "react-virtualized": "^9.21.0", diff --git a/yarn.lock b/yarn.lock index 169abd40ee4..fd0c446fbce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3972,11 +3972,6 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" -batch-processor@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/batch-processor/-/batch-processor-1.0.0.tgz#75c95c32b748e0850d10c2b168f6bdbe9891ace8" - integrity sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg= - batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -6613,13 +6608,6 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= -element-resize-detector@^1.1.12: - version "1.2.0" - resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.2.0.tgz#63344fd6f4e5ecff6f018d027e17b281fd4fa338" - integrity sha512-UmhNB8sIJVZeg56gEjgmMd6p37sCg8j8trVW0LZM7Wzv+kxQ5CnRHcgRKBTB/kFUSn3e7UP59kl2V2U8Du1hmg== - dependencies: - batch-processor "1.0.0" - elliptic@^6.0.0: version "6.4.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" @@ -10900,11 +10888,6 @@ lodash.tail@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= - lodash.union@4.6.0, lodash.union@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" @@ -14215,17 +14198,6 @@ react-resizable@1.x: prop-types "15.x" react-draggable "^2.2.6 || ^3.0.3" -react-sizeme@^2.3.6: - version "2.5.2" - resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.5.2.tgz#e7041390cfb895ed15d896aa91d76e147e3b70b5" - integrity sha512-hYvcncV1FxVzPm2EhVwlOLf7Tk+k/ttO6rI7bfKUL/aL1gYzrY3DXJsdZ6nFaFgGSU/i8KC6gCoptOhBbRJpXQ== - dependencies: - element-resize-detector "^1.1.12" - invariant "^2.2.2" - lodash.debounce "^4.0.8" - lodash.throttle "^4.1.1" - shallowequal "^1.0.2" - react-split-pane@^0.1.84: version "0.1.85" resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.85.tgz#64819946a99b617ffa2d20f6f45a0056b6ee4faa" From f5431f521082743d930f54d1d93f6dcaface7f7a Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 5 Feb 2019 13:48:00 +0100 Subject: [PATCH 37/89] chore: Explore: Remove inner AutoSizer, spread the size-object to width/height, change height type to number --- public/app/features/explore/Explore.tsx | 2 +- public/app/features/explore/Graph.tsx | 38 +++++-------------- .../app/features/explore/GraphContainer.tsx | 6 ++- public/app/features/explore/Logs.tsx | 2 +- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b210bcccc18..437b50db63c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -211,7 +211,7 @@ export class Explore extends React.PureComponent { {showingStartPage && } {!showingStartPage && ( <> - {supportsGraph && !supportsLogs && } + {supportsGraph && !supportsLogs && } {supportsTable && } {supportsLogs && ( ) => void; } -interface SizedGraphProps extends GraphProps { - size: { width: number; height: number }; -} - interface GraphState { /** * Type parameter refers to the `alias` property of a `TimeSeries`. @@ -98,7 +94,7 @@ interface GraphState { showAllTimeSeries: boolean; } -export class Graph extends PureComponent { +export class Graph extends PureComponent { $el: any; dynamicOptions = null; @@ -119,13 +115,13 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: SizedGraphProps, prevState: GraphState) { + componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || - (prevProps.size && prevProps.size.width !== this.props.size.width) || + prevProps.width !== this.props.width || !equal(prevState.hiddenSeries, this.state.hiddenSeries) ) { this.draw(); @@ -147,8 +143,8 @@ export class Graph extends PureComponent { }; getDynamicOptions() { - const { range, size } = this.props; - const ticks = (size.width || 0) / 100; + const { range, width } = this.props; + const ticks = (width || 0) / 100; let { from, to } = range; if (!moment.isMoment(from)) { from = dateMath.parse(from, false); @@ -240,7 +236,7 @@ export class Graph extends PureComponent { } render() { - const { height = '100px', id = 'graph' } = this.props; + const { height = 100, id = 'graph' } = this.props; const { hiddenSeries } = this.state; const data = this.getGraphData(); @@ -264,20 +260,4 @@ export class Graph extends PureComponent { } } -export default (props: GraphProps) => ( -
{/* div needed for AutoSizer to calculate, https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#observation */} - - {({width, height}) => ( -
- {width > 0 && } -
- )} -
-
-); +export default Graph; diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx index 7263fd09288..3950d89c11f 100644 --- a/public/app/features/explore/GraphContainer.tsx +++ b/public/app/features/explore/GraphContainer.tsx @@ -20,6 +20,7 @@ interface GraphContainerProps { split: boolean; toggleGraph: typeof toggleGraph; changeTime: typeof changeTime; + width: number; } export class GraphContainer extends PureComponent { @@ -32,8 +33,8 @@ export class GraphContainer extends PureComponent { }; render() { - const { exploreId, graphResult, loading, showingGraph, showingTable, range, split } = this.props; - const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props; + const graphHeight = showingGraph && showingTable ? 200 : 400; if (!graphResult) { return null; @@ -48,6 +49,7 @@ export class GraphContainer extends PureComponent { onChangeTime={this.onChangeTime} range={range} split={split} + width={width} /> ); diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 490257cb9a9..b6c903bc504 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -214,7 +214,7 @@ export default class Logs extends PureComponent {
Date: Tue, 5 Feb 2019 14:09:25 +0100 Subject: [PATCH 38/89] fix: Update snapshot --- .../app/features/explore/__snapshots__/Graph.test.tsx.snap | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index a7ec6deb22c..c38fb26a252 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -7,7 +7,7 @@ exports[`Render should render component 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> @@ -480,7 +480,7 @@ exports[`Render should render component with disclaimer 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> @@ -962,7 +962,7 @@ exports[`Render should show query return no time series 1`] = ` id="graph" style={ Object { - "height": "100px", + "height": 100, } } /> From 6d874dd1f160f8e3124dec7ed892cf0f3e54c82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Feb 2019 14:42:29 +0100 Subject: [PATCH 39/89] 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 40/89] 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 @@
-
+