diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx new file mode 100644 index 00000000000..2de43bbd0b2 --- /dev/null +++ b/public/app/core/components/AlertBox/AlertBox.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent } from 'react'; +import { AppNotificationSeverity } from 'app/types'; + +interface Props { + title: string; + icon?: string; + text?: string; + severity: AppNotificationSeverity; + onClose?: () => void; +} + +function getIconFromSeverity(severity: AppNotificationSeverity): string { + switch (severity) { + case AppNotificationSeverity.Error: { + return 'fa fa-exclamation-triangle'; + } + case AppNotificationSeverity.Success: { + return 'fa fa-check'; + } + default: return null; + } +} + +export const AlertBox: FunctionComponent = ({ title, icon, text, severity, onClose }) => { + return ( +
+
+ +
+
+
{title}
+ {text &&
{text}
} +
+ {onClose && ( + + )} +
+ ); +}; diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx index 6b4b268eb13..d1fc506d54c 100644 --- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { AppNotification } from 'app/types'; +import { AlertBox } from '../AlertBox/AlertBox'; interface Props { appNotification: AppNotification; @@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component { const { appNotification, onClearNotification } = this.props; return ( -
-
- -
-
-
{appNotification.title}
-
{appNotification.text}
-
- -
+ onClearNotification(appNotification.id)} + /> ); } } diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 0062cd08fa6..2869c121fa8 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,5 +1,5 @@ -import _ from 'lodash'; import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; +import { getMessageFromError } from 'app/core/utils/errors'; const defaultSuccessNotification: AppNotification = { title: '', @@ -33,21 +33,10 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti }); export const createErrorNotification = (title: string, text?: any): AppNotification => { - // Handling if text is an error object - if (text && !_.isString(text)) { - if (text.message) { - text = text.message; - } else if (text.data && text.data.message) { - text = text.data.message; - } else { - text = text.toString(); - } - } - return { ...defaultErrorNotification, title: title, - text: text, + text: getMessageFromError(text), id: Date.now(), }; }; diff --git a/public/app/core/utils/errors.ts b/public/app/core/utils/errors.ts new file mode 100644 index 00000000000..52a7c39f713 --- /dev/null +++ b/public/app/core/utils/errors.ts @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +export function getMessageFromError(err: any): string | null { + if (err && !_.isString(err)) { + if (err.message) { + return err.message; + } else if (err.data && err.data.message) { + return err.data.message; + } else { + return JSON.stringify(err); + } + } + + return null; +} diff --git a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts deleted file mode 100644 index fbf84d354e3..00000000000 --- a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts +++ /dev/null @@ -1,117 +0,0 @@ -import moment from 'moment'; -import angular from 'angular'; -import { appEvents, NavModel } from 'app/core/core'; -import { DashboardModel } from '../../state/DashboardModel'; - -export class DashNavCtrl { - dashboard: DashboardModel; - navModel: NavModel; - titleTooltip: string; - - /** @ngInject */ - constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) { - if (this.dashboard.meta.isSnapshot) { - const meta = this.dashboard.meta; - this.titleTooltip = 'Created:  ' + moment(meta.created).calendar(); - if (meta.expires) { - this.titleTooltip += '
Expires:  ' + moment(meta.expires).fromNow() + '
'; - } - } - } - - toggleSettings() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else { - search.editview = 'settings'; - } - this.$location.search(search); - } - - toggleViewMode() { - appEvents.emit('toggle-kiosk-mode'); - } - - close() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else if (search.fullscreen) { - delete search.fullscreen; - delete search.edit; - delete search.tab; - delete search.panelId; - } - this.$location.search(search); - } - - starDashboard() { - this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => { - this.dashboard.meta.isStarred = newState; - }); - } - - shareDashboard(tabIndex) { - const modalScope = this.$scope.$new(); - modalScope.tabIndex = tabIndex; - modalScope.dashboard = this.dashboard; - - appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - scope: modalScope, - }); - } - - hideTooltip(evt) { - angular.element(evt.currentTarget).tooltip('hide'); - } - - saveDashboard() { - return this.dashboardSrv.saveDashboard(); - } - - showSearch() { - if (this.dashboard.meta.fullscreen) { - this.close(); - return; - } - - appEvents.emit('show-dash-search'); - } - - addPanel() { - appEvents.emit('dash-scroll', { animate: true, evt: 0 }); - - if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') { - return; // Return if the "Add panel" exists already - } - - this.dashboard.addPanel({ - type: 'add-panel', - gridPos: { x: 0, y: 0, w: 12, h: 8 }, - title: 'Panel Title', - }); - } - - navItemClicked(navItem, evt) { - if (navItem.clickHandler) { - navItem.clickHandler(); - evt.preventDefault(); - } - } -} - -export function dashNavDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/DashNav/template.html', - controller: DashNavCtrl, - bindToController: true, - controllerAs: 'ctrl', - transclude: true, - scope: { dashboard: '=' }, - }; -} - -angular.module('grafana.directives').directive('dashnav', dashNavDirective); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts index cfa9003cd8a..be07fd0d2a3 100644 --- a/public/app/features/dashboard/components/DashNav/index.ts +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -1,3 +1,2 @@ -export { DashNavCtrl } from './DashNavCtrl'; import DashNav from './DashNav'; export { DashNav }; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 59e71c69757..7ef4918aa90 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { DashboardPage, Props, State } from './DashboardPage'; import { DashboardModel } from '../state'; -import { setDashboardModel } from '../state/actions'; -import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; +import { cleanUpDashboard } from '../state/actions'; +import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; jest.mock('sass/_variables.scss', () => ({ panelhorizontalpadding: 10, @@ -22,13 +22,13 @@ function setup(propOverrides?: Partial): ShallowWrapper { canEdit: true, canSave: true, }); - wrapper.setProps({ dashboard, loadingState: DashboardLoadingState.Done }); + wrapper.setProps({ dashboard, initPhase: DashboardInitPhase.Completed }); }); it('Should update title', () => { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 3e018e3a5f0..0773d85b368 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; // Services & Utils import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getMessageFromError } from 'app/core/utils/errors'; // Components import { DashboardGrid } from '../dashgrid/DashboardGrid'; @@ -14,15 +15,22 @@ import { DashNav } from '../components/DashNav'; import { SubMenu } from '../components/SubMenu'; import { DashboardSettings } from '../components/DashboardSettings'; import { CustomScrollbar } from '@grafana/ui'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; // Redux import { initDashboard } from '../state/initDashboard'; -import { setDashboardModel } from '../state/actions'; +import { cleanUpDashboard } from '../state/actions'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { StoreState, DashboardLoadingState, DashboardRouteInfo } from 'app/types'; +import { + StoreState, + DashboardInitPhase, + DashboardRouteInfo, + DashboardInitError, + AppNotificationSeverity, +} from 'app/types'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; export interface Props { @@ -37,11 +45,12 @@ export interface Props { routeInfo: DashboardRouteInfo; urlEdit: boolean; urlFullscreen: boolean; - loadingState: DashboardLoadingState; - isLoadingSlow: boolean; + initPhase: DashboardInitPhase; + isInitSlow: boolean; dashboard: DashboardModel | null; + initError?: DashboardInitError; initDashboard: typeof initDashboard; - setDashboardModel: typeof setDashboardModel; + cleanUpDashboard: typeof cleanUpDashboard; notifyApp: typeof notifyApp; updateLocation: typeof updateLocation; } @@ -83,7 +92,7 @@ export class DashboardPage extends PureComponent { componentWillUnmount() { if (this.props.dashboard) { this.props.dashboard.destroy(); - this.props.setDashboardModel(null); + this.props.cleanUpDashboard(); } } @@ -204,23 +213,37 @@ export class DashboardPage extends PureComponent { this.setState({ scrollTop: 0 }); }; - renderLoadingState() { + renderSlowInitState() { return (
- Dashboard {this.props.loadingState} + {this.props.initPhase}
); } + renderInitFailedState() { + const { initError } = this.props; + + return ( +
+ +
+ ); + } + render() { - const { dashboard, editview, $injector, isLoadingSlow } = this.props; + const { dashboard, editview, $injector, isInitSlow, initError } = this.props; const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state; if (!dashboard) { - if (isLoadingSlow) { - return this.renderLoadingState(); + if (isInitSlow) { + return this.renderSlowInitState(); } return null; } @@ -249,6 +272,8 @@ export class DashboardPage extends PureComponent { {editview && } + {initError && this.renderInitFailedState()} +
{dashboard.meta.submenuEnabled && } @@ -269,14 +294,15 @@ const mapStateToProps = (state: StoreState) => ({ urlFolderId: state.location.query.folderId, urlFullscreen: state.location.query.fullscreen === true, urlEdit: state.location.query.edit === true, - loadingState: state.dashboard.loadingState, - isLoadingSlow: state.dashboard.isLoadingSlow, + initPhase: state.dashboard.initPhase, + isInitSlow: state.dashboard.isInitSlow, + initError: state.dashboard.initError, dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { initDashboard, - setDashboardModel, + cleanUpDashboard, notifyApp, updateLocation, }; diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 915d2e03965..6dcf2775547 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -100,7 +100,6 @@ const mapStateToProps = (state: StoreState) => ({ urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, urlPanelId: state.location.query.panelId, - loadingState: state.dashboard.loadingState, dashboard: state.dashboard.model as DashboardModel, }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 50ff004ad48..f5911a233f7 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -8,20 +8,36 @@ import { loadPluginDashboards } from '../../plugins/state/actions'; import { notifyApp } from 'app/core/actions'; // Types -import { ThunkResult } from 'app/types'; import { + ThunkResult, DashboardAcl, DashboardAclDTO, PermissionLevel, DashboardAclUpdateDTO, NewDashboardAclItem, -} from 'app/types/acl'; -import { DashboardLoadingState, MutableDashboard } from 'app/types/dashboard'; + MutableDashboard, + DashboardInitError, +} from 'app/types'; export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); -export const setDashboardLoadingState = actionCreatorFactory('SET_DASHBOARD_LOADING_STATE').create(); -export const setDashboardModel = actionCreatorFactory('SET_DASHBOARD_MODEL').create(); -export const setDashboardLoadingSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_LOADING_SLOW').create(); + +export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create(); + +export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create(); + +export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create(); + +export const dashboardInitCompleted = actionCreatorFactory('DASHBOARD_INIT_COMLETED').create(); + +/* + * Unrecoverable init failure (fetch or model creation failed) + */ +export const dashboardInitFailed = actionCreatorFactory('DASHBOARD_INIT_FAILED').create(); + +/* + * When leaving dashboard, resets state + * */ +export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create(); export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index eebeb5010fb..86d75e883cc 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -1,7 +1,7 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { initDashboard, InitDashboardArgs } from './initDashboard'; -import { DashboardRouteInfo, DashboardLoadingState } from 'app/types'; +import { DashboardRouteInfo } from 'app/types'; const mockStore = configureMockStore([thunk]); @@ -98,13 +98,11 @@ describeInitScenario('Initializing new dashboard', ctx => { }); it('Should send action to set loading state to fetching', () => { - expect(ctx.actions[0].type).toBe('SET_DASHBOARD_LOADING_STATE'); - expect(ctx.actions[0].payload).toBe(DashboardLoadingState.Fetching); + expect(ctx.actions[0].type).toBe('DASHBOARD_INIT_FETCHING'); }); it('Should send action to set loading state to Initializing', () => { - expect(ctx.actions[1].type).toBe('SET_DASHBOARD_LOADING_STATE'); - expect(ctx.actions[1].payload).toBe(DashboardLoadingState.Initializing); + expect(ctx.actions[1].type).toBe('DASHBOARD_INIT_SERVICES'); }); it('Should update location with orgId query param', () => { @@ -113,7 +111,7 @@ describeInitScenario('Initializing new dashboard', ctx => { }); it('Should send action to set dashboard model', () => { - expect(ctx.actions[3].type).toBe('SET_DASHBOARD_MODEL'); + expect(ctx.actions[3].type).toBe('DASHBOARD_INIT_COMLETED'); expect(ctx.actions[3].payload.title).toBe('New dashboard'); }); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 941ac332f3c..e6f83780430 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -12,17 +12,16 @@ import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import { updateLocation } from 'app/core/actions'; import { notifyApp } from 'app/core/actions'; import locationUtil from 'app/core/utils/location_util'; -import { setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; +import { + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitSlow, + dashboardInitServices, +} from './actions'; // Types -import { - DashboardLoadingState, - DashboardRouteInfo, - StoreState, - ThunkDispatch, - ThunkResult, - DashboardDTO, -} from 'app/types'; +import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types'; import { DashboardModel } from './DashboardModel'; export interface InitDashboardArgs { @@ -106,8 +105,7 @@ async function fetchDashboard( throw { message: 'Unknown route ' + args.routeInfo }; } } catch (err) { - dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); - dispatch(notifyApp(createErrorNotification('Dashboard fetch failed', err))); + dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err })); console.log(err); return null; } @@ -125,13 +123,13 @@ async function fetchDashboard( export function initDashboard(args: InitDashboardArgs): ThunkResult { return async (dispatch, getState) => { // set fetching state - dispatch(setDashboardLoadingState(DashboardLoadingState.Fetching)); + dispatch(dashboardInitFetching()); // Detect slow loading / initializing and set state flag // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing setTimeout(() => { if (getState().dashboard.model === null) { - dispatch(setDashboardLoadingSlow()); + dispatch(dashboardInitSlow()); } }, 500); @@ -144,15 +142,14 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { } // set initializing state - dispatch(setDashboardLoadingState(DashboardLoadingState.Initializing)); + dispatch(dashboardInitServices()); // create model let dashboard: DashboardModel; try { dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); } catch (err) { - dispatch(setDashboardLoadingState(DashboardLoadingState.Error)); - dispatch(notifyApp(createErrorNotification('Dashboard model initializing failure', err))); + dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err })); console.log(err); return; } @@ -203,8 +200,8 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { // legacy srv state dashboardSrv.setCurrent(dashboard); - // set model in redux (even though it's mutable) - dispatch(setDashboardModel(dashboard)); + // yay we are done + dispatch(dashboardInitCompleted(dashboard)); }; } diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 5566363c996..ecf34f2f1a3 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,11 +1,20 @@ -import { DashboardState, DashboardLoadingState } from 'app/types/dashboard'; -import { loadDashboardPermissions, setDashboardLoadingState, setDashboardModel, setDashboardLoadingSlow } from './actions'; +import { DashboardState, DashboardInitPhase } from 'app/types'; +import { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitSlow, + dashboardInitServices, + dashboardInitFailed, + dashboardInitCompleted, + cleanUpDashboard, +} from './actions'; import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; +import { DashboardModel } from './DashboardModel'; export const initialState: DashboardState = { - loadingState: DashboardLoadingState.NotStarted, - isLoadingSlow: false, + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, model: null, permissions: [], }; @@ -19,26 +28,59 @@ export const dashboardReducer = reducerFactory(initialState) }), }) .addMapper({ - filter: setDashboardLoadingState, - mapper: (state, action) => ({ + filter: dashboardInitFetching, + mapper: state => ({ ...state, - loadingState: action.payload + initPhase: DashboardInitPhase.Fetching, }), }) .addMapper({ - filter: setDashboardModel, + filter: dashboardInitServices, + mapper: state => ({ + ...state, + initPhase: DashboardInitPhase.Services, + }), + }) + .addMapper({ + filter: dashboardInitSlow, + mapper: state => ({ + ...state, + isInitSlow: true, + }), + }) + .addMapper({ + filter: dashboardInitFailed, mapper: (state, action) => ({ ...state, + initPhase: DashboardInitPhase.Failed, + isInitSlow: false, + initError: action.payload, + model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }), + }), + }) + .addMapper({ + filter: dashboardInitCompleted, + mapper: (state, action) => ({ + ...state, + initPhase: DashboardInitPhase.Completed, model: action.payload, - isLoadingSlow: false, + isInitSlow: false, }), }) .addMapper({ - filter: setDashboardLoadingSlow, - mapper: (state, action) => ({ - ...state, - isLoadingSlow: true, - }), + filter: cleanUpDashboard, + mapper: (state, action) => { + // tear down current dashboard + state.model.destroy(); + + return { + ...state, + initPhase: DashboardInitPhase.NotStarted, + model: null, + isInitSlow: false, + initError: null, + }; + }, }) .create(); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index fac8761fa7a..9bcf258cf76 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -2,6 +2,7 @@ import { DashboardAcl } from './acl'; export interface MutableDashboard { meta: DashboardMeta; + destroy: () => void; } export interface DashboardDTO { @@ -44,12 +45,17 @@ export enum DashboardRouteInfo { Scripted = 'scripted-dashboard', } -export enum DashboardLoadingState { +export enum DashboardInitPhase { NotStarted = 'Not started', Fetching = 'Fetching', - Initializing = 'Initializing', - Error = 'Error', - Done = 'Done', + Services = 'Services', + Failed = 'Failed', + Completed = 'Completed', +} + +export interface DashboardInitError { + message: string; + error: any; } export const KIOSK_MODE_TV = 'tv'; @@ -57,7 +63,8 @@ export type KioskUrlValue = 'tv' | '1' | true; export interface DashboardState { model: MutableDashboard | null; - loadingState: DashboardLoadingState; - isLoadingSlow: boolean; + initPhase: DashboardInitPhase; + isInitSlow: boolean; + initError?: DashboardInitError; permissions: DashboardAcl[] | null; } diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 0f37ffc850e..c02c9227d29 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -282,6 +282,11 @@ div.flot-text { display: flex; align-items: center; justify-content: center; + + .alert { + max-width: 600px; + min-width: 600px; + } } .dashboard-loading__text {