diff --git a/docker/blocks/openldap/ldap_dev.toml b/docker/blocks/openldap/ldap_dev.toml index e79771b57de..8767ff3c64a 100644 --- a/docker/blocks/openldap/ldap_dev.toml +++ b/docker/blocks/openldap/ldap_dev.toml @@ -72,6 +72,7 @@ email = "email" [[servers.group_mappings]] group_dn = "cn=admins,ou=groups,dc=grafana,dc=org" org_role = "Admin" +grafana_admin = true # The Grafana organization database id, optional, if left out the default org (id 1) will be used # org_id = 1 diff --git a/package.json b/package.json index 9ee81d7f8ac..7afe10c0772 100644 --- a/package.json +++ b/package.json @@ -160,9 +160,13 @@ "react-grid-layout": "0.16.6", "react-highlight-words": "^0.10.0", "react-popper": "^0.7.5", + "react-redux": "^5.0.7", "react-select": "^1.1.0", "react-sizeme": "^2.3.6", "react-transition-group": "^2.2.1", + "redux": "^4.0.0", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0", "remarkable": "^1.7.1", "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^5.4.3", diff --git a/public/app/containers/AlertRuleList/AlertRuleList.test.tsx b/public/app/containers/AlertRuleList/AlertRuleList.test.tsx deleted file mode 100644 index f88ff4522d4..00000000000 --- a/public/app/containers/AlertRuleList/AlertRuleList.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import { AlertRuleList } from './AlertRuleList'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv, createNavTree } from 'test/mocks/common'; -import { mount } from 'enzyme'; -import toJson from 'enzyme-to-json'; - -describe('AlertRuleList', () => { - let page, store; - - beforeAll(() => { - backendSrv.get.mockReturnValue( - Promise.resolve([ - { - id: 11, - dashboardId: 58, - panelId: 3, - name: 'Panel Title alert', - state: 'ok', - newStateDate: moment() - .subtract(5, 'minutes') - .format(), - evalData: {}, - executionError: '', - url: 'd/ufkcofof/my-goal', - canEdit: true, - }, - ]) - ); - - store = RootStore.create( - {}, - { - backendSrv: backendSrv, - navTree: createNavTree('alerting', 'alert-list'), - } - ); - - page = mount(); - }); - - it('should call api to get rules', () => { - expect(backendSrv.get.mock.calls[0][0]).toEqual('/api/alerts'); - }); - - it('should render 1 rule', () => { - page.update(); - const ruleNode = page.find('.alert-rule-item'); - expect(toJson(ruleNode)).toMatchSnapshot(); - }); - - it('toggle state should change pause rule if not paused', async () => { - backendSrv.post.mockReturnValue( - Promise.resolve({ - state: 'paused', - }) - ); - - page.find('.fa-pause').simulate('click'); - - // wait for api call to resolve - await Promise.resolve(); - page.update(); - - expect(store.alertList.rules[0].state).toBe('paused'); - expect(page.find('.fa-play')).toHaveLength(1); - }); -}); diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx deleted file mode 100644 index 668136dee6f..00000000000 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import classNames from 'classnames'; -import { inject, observer } from 'mobx-react'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { AlertRule } from 'app/stores/AlertListStore/AlertListStore'; -import appEvents from 'app/core/app_events'; -import ContainerProps from 'app/containers/ContainerProps'; -import Highlighter from 'react-highlight-words'; - -@inject('view', 'nav', 'alertList') -@observer -export class AlertRuleList extends React.Component { - stateFilters = [ - { text: 'All', value: 'all' }, - { text: 'OK', value: 'ok' }, - { text: 'Not OK', value: 'not_ok' }, - { text: 'Alerting', value: 'alerting' }, - { text: 'No Data', value: 'no_data' }, - { text: 'Paused', value: 'paused' }, - ]; - - constructor(props) { - super(props); - - this.props.nav.load('alerting', 'alert-list'); - this.fetchRules(); - } - - onStateFilterChanged = evt => { - this.props.view.updateQuery({ state: evt.target.value }); - this.fetchRules(); - }; - - fetchRules() { - this.props.alertList.loadRules({ - state: this.props.view.query.get('state') || 'all', - }); - } - - onOpenHowTo = () => { - appEvents.emit('show-modal', { - src: 'public/app/features/alerting/partials/alert_howto.html', - modalClass: 'confirm-modal', - model: {}, - }); - }; - - onSearchQueryChange = evt => { - this.props.alertList.setSearchQuery(evt.target.value); - }; - - render() { - const { nav, alertList } = this.props; - - return ( - - - - - - - - - - - - States - - - - {this.stateFilters.map(AlertStateFilterOption)} - - - - - - - - How to add an alert - - - - - - {alertList.filteredRules.map(rule => ( - - ))} - - - - - ); - } -} - -function AlertStateFilterOption({ text, value }) { - return ( - - {text} - - ); -} - -export interface AlertRuleItemProps { - rule: AlertRule; - search: string; -} - -@observer -export class AlertRuleItem extends React.Component { - toggleState = () => { - this.props.rule.togglePaused(); - }; - - renderText(text: string) { - return ( - - ); - } - - render() { - const { rule } = this.props; - - const stateClass = classNames({ - fa: true, - 'fa-play': rule.isPaused, - 'fa-pause': !rule.isPaused, - }); - - const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; - - return ( - - - - - - - - {this.renderText(rule.name)} - - - {this.renderText(rule.stateText)} - for {rule.stateAge} - - - {rule.info && {this.renderText(rule.info)}} - - - - - - - - - - - - ); - } -} - -export default hot(module)(AlertRuleList); diff --git a/public/app/containers/ContainerProps.ts b/public/app/containers/ContainerProps.ts index 97889278fdc..ce09b992f80 100644 --- a/public/app/containers/ContainerProps.ts +++ b/public/app/containers/ContainerProps.ts @@ -1,16 +1,10 @@ -import { SearchStore } from './../stores/SearchStore/SearchStore'; -import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore'; import { NavStore } from './../stores/NavStore/NavStore'; import { PermissionsStore } from './../stores/PermissionsStore/PermissionsStore'; -import { AlertListStore } from './../stores/AlertListStore/AlertListStore'; import { ViewStore } from './../stores/ViewStore/ViewStore'; import { FolderStore } from './../stores/FolderStore/FolderStore'; interface ContainerProps { - search: typeof SearchStore.Type; - serverStats: typeof ServerStatsStore.Type; nav: typeof NavStore.Type; - alertList: typeof AlertListStore.Type; permissions: typeof PermissionsStore.Type; view: typeof ViewStore.Type; folder: typeof FolderStore.Type; diff --git a/public/app/containers/ServerStats/ServerStats.test.tsx b/public/app/containers/ServerStats/ServerStats.test.tsx deleted file mode 100644 index a329a47527d..00000000000 --- a/public/app/containers/ServerStats/ServerStats.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import { ServerStats } from './ServerStats'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv, createNavTree } from 'test/mocks/common'; - -describe('ServerStats', () => { - it('Should render table with stats', done => { - backendSrv.get.mockReturnValue( - Promise.resolve({ - dashboards: 10, - }) - ); - - const store = RootStore.create( - {}, - { - backendSrv: backendSrv, - navTree: createNavTree('cfg', 'admin', 'server-stats'), - } - ); - - const page = renderer.create(); - - setTimeout(() => { - expect(page.toJSON()).toMatchSnapshot(); - done(); - }); - }); -}); diff --git a/public/app/containers/ServerStats/ServerStats.tsx b/public/app/containers/ServerStats/ServerStats.tsx deleted file mode 100644 index 63e78996041..00000000000 --- a/public/app/containers/ServerStats/ServerStats.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import ContainerProps from 'app/containers/ContainerProps'; - -@inject('nav', 'serverStats') -@observer -export class ServerStats extends React.Component { - constructor(props) { - super(props); - const { nav, serverStats } = this.props; - - nav.load('cfg', 'admin', 'server-stats'); - serverStats.load(); - } - - render() { - const { nav, serverStats } = this.props; - return ( - - - - - - - Name - Value - - - {serverStats.stats.map(StatItem)} - - - - ); - } -} - -function StatItem(stat) { - return ( - - {stat.name} - {stat.value} - - ); -} - -export default hot(module)(ServerStats); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts new file mode 100644 index 00000000000..74b61f845c0 --- /dev/null +++ b/public/app/core/actions/index.ts @@ -0,0 +1,3 @@ +import { updateLocation } from './location'; + +export { updateLocation }; diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts new file mode 100644 index 00000000000..6f7ac67363e --- /dev/null +++ b/public/app/core/actions/location.ts @@ -0,0 +1,13 @@ +import { LocationUpdate } from 'app/types'; + +export type Action = UpdateLocationAction; + +export interface UpdateLocationAction { + type: 'UPDATE_LOCATION'; + payload: LocationUpdate; +} + +export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ + type: 'UPDATE_LOCATION', + payload: location, +}); diff --git a/public/app/core/actions/navModel.ts b/public/app/core/actions/navModel.ts new file mode 100644 index 00000000000..56d129fd263 --- /dev/null +++ b/public/app/core/actions/navModel.ts @@ -0,0 +1,13 @@ +export type Action = UpdateNavIndexAction; + +// this action is not used yet +// kind of just a placeholder, will be need for dynamic pages +// like datasource edit, teams edit page + +export interface UpdateNavIndexAction { + type: 'UPDATE_NAV_INDEX'; +} + +export const updateNavIndex = (): UpdateNavIndexAction => ({ + type: 'UPDATE_NAV_INDEX', +}); diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index b7bef2495bb..9feddde68ce 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { observer } from 'mobx-react'; -import { NavModel, NavModelItem } from '../../nav_model_srv'; +import { NavModel, NavModelItem } from 'app/types'; import classNames from 'classnames'; import appEvents from 'app/core/app_events'; import { toJS } from 'mobx'; diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 6c7b8cf3bf7..926438ffbc9 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -10,6 +10,7 @@ import { createStore } from 'app/stores/store'; import colors from 'app/core/utils/colors'; import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { configureStore } from 'app/stores/configureStore'; export class GrafanaCtrl { /** @ngInject */ @@ -25,6 +26,7 @@ export class GrafanaCtrl { datasourceSrv: DatasourceSrv ) { // sets singleston instances for angular services so react components can access them + configureStore(); setBackendSrv(backendSrv); createStore({ backendSrv, datasourceSrv }); diff --git a/public/app/core/components/search/SearchResult.tsx b/public/app/core/components/search/SearchResult.tsx index 3141d29ac7f..13333c168f9 100644 --- a/public/app/core/components/search/SearchResult.tsx +++ b/public/app/core/components/search/SearchResult.tsx @@ -1,22 +1,13 @@ import React from 'react'; import classNames from 'classnames'; -import { observer } from 'mobx-react'; -import { store } from 'app/stores/store'; -export interface SearchResultProps { - search: any; -} - -@observer -export class SearchResult extends React.Component { +export class SearchResult extends React.Component { constructor(props) { super(props); this.state = { - search: store.search, + search: '', }; - - store.search.query(); } render() { @@ -30,7 +21,6 @@ export interface SectionProps { section: any; } -@observer export class SearchResultSection extends React.Component { constructor(props) { super(props); diff --git a/public/app/core/reducers/index.ts b/public/app/core/reducers/index.ts new file mode 100644 index 00000000000..be13528c91c --- /dev/null +++ b/public/app/core/reducers/index.ts @@ -0,0 +1,7 @@ +import { navIndexReducer as navIndex } from './navModel'; +import { locationReducer as location } from './location'; + +export default { + navIndex, + location, +}; diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts new file mode 100644 index 00000000000..4591448d082 --- /dev/null +++ b/public/app/core/reducers/location.ts @@ -0,0 +1,33 @@ +import { Action } from 'app/core/actions/location'; +import { LocationState, UrlQueryMap } from 'app/types'; +import { toUrlParams } from 'app/core/utils/url'; + +export const initialState: LocationState = { + url: '', + path: '', + query: {}, + routeParams: {}, +}; + +function renderUrl(path: string, query: UrlQueryMap): string { + if (Object.keys(query).length > 0) { + path += '?' + toUrlParams(query); + } + return path; +} + +export const locationReducer = (state = initialState, action: Action): LocationState => { + switch (action.type) { + case 'UPDATE_LOCATION': { + const { path, query, routeParams } = action.payload; + return { + url: renderUrl(path || state.path, query), + path: path || state.path, + query: query || state.query, + routeParams: routeParams || state.routeParams, + }; + } + } + + return state; +}; diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts new file mode 100644 index 00000000000..26acdb39a3d --- /dev/null +++ b/public/app/core/reducers/navModel.ts @@ -0,0 +1,29 @@ +import { Action } from 'app/core/actions/navModel'; +import { NavModelItem, NavIndex } from 'app/types'; +import config from 'app/core/config'; + +export function buildInitialState(): NavIndex { + const navIndex: NavIndex = {}; + const rootNodes = config.bootData.navTree as NavModelItem[]; + buildNavIndex(navIndex, rootNodes); + return navIndex; +} + +function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem?: NavModelItem) { + for (const node of children) { + navIndex[node.id] = { + ...node, + parentItem: parentItem, + }; + + if (node.children) { + buildNavIndex(navIndex, node.children, node); + } + } +} + +export const initialState: NavIndex = buildInitialState(); + +export const navIndexReducer = (state = initialState, action: Action): NavIndex => { + return state; +}; diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts new file mode 100644 index 00000000000..8b3a3edd84e --- /dev/null +++ b/public/app/core/selectors/navModel.ts @@ -0,0 +1,39 @@ +import { NavModel, NavModelItem, NavIndex } from 'app/types'; + +function getNotFoundModel(): NavModel { + const node: NavModelItem = { + id: 'not-found', + text: 'Page not found', + icon: 'fa fa-fw fa-warning', + subTitle: '404 Error', + url: 'not-found', + }; + + return { + node: node, + main: node, + }; +} + +export function getNavModel(navIndex: NavIndex, id: string): NavModel { + if (navIndex[id]) { + const node = navIndex[id]; + const main = { + ...node.parentItem, + }; + + main.children = main.children.map(item => { + return { + ...item, + active: item.url === node.url, + }; + }); + + return { + node: node, + main: main, + }; + } else { + return getNotFoundModel(); + } +} diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts index bdc2976a94c..29326794ac6 100644 --- a/public/app/core/services/bridge_srv.ts +++ b/public/app/core/services/bridge_srv.ts @@ -1,8 +1,10 @@ import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; import { store } from 'app/stores/store'; +import { store as reduxStore } from 'app/stores/configureStore'; import { reaction } from 'mobx'; import locationUtil from 'app/core/utils/location_util'; +import { updateLocation } from 'app/core/actions'; // Services that handles angular -> mobx store sync & other react <-> angular sync export class BridgeSrv { @@ -19,12 +21,30 @@ export class BridgeSrv { if (store.view.currentUrl !== angularUrl) { store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params); } + const state = reduxStore.getState(); + if (state.location.url !== angularUrl) { + reduxStore.dispatch( + updateLocation({ + path: this.$location.path(), + query: this.$location.search(), + routeParams: this.$route.current.params, + }) + ); + } }); this.$rootScope.$on('$routeChangeSuccess', (evt, data) => { store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params); + reduxStore.dispatch( + updateLocation({ + path: this.$location.path(), + query: this.$location.search(), + routeParams: this.$route.current.params, + }) + ); }); + // listen for mobx store changes and update angular reaction( () => store.view.currentUrl, currentUrl => { @@ -39,6 +59,19 @@ export class BridgeSrv { } ); + // Listen for changes in redux location -> update angular location + reduxStore.subscribe(() => { + const state = reduxStore.getState(); + const angularUrl = this.$location.url(); + const url = locationUtil.stripBaseFromUrl(state.location.url); + if (angularUrl !== url) { + this.$timeout(() => { + this.$location.url(url); + }); + console.log('store updating angular $location.url', url); + } + }); + appEvents.on('location-change', payload => { const urlWithoutBase = locationUtil.stripBaseFromUrl(payload.href); if (this.fullPageReloadRoutes.indexOf(urlWithoutBase) > -1) { diff --git a/public/app/features/admin/admin_edit_org_ctrl.ts b/public/app/features/admin/AdminEditOrgCtrl.ts similarity index 88% rename from public/app/features/admin/admin_edit_org_ctrl.ts rename to public/app/features/admin/AdminEditOrgCtrl.ts index ec3f8548023..3117c5f0f9b 100644 --- a/public/app/features/admin/admin_edit_org_ctrl.ts +++ b/public/app/features/admin/AdminEditOrgCtrl.ts @@ -1,6 +1,5 @@ -import angular from 'angular'; -export class AdminEditOrgCtrl { +export default class AdminEditOrgCtrl { /** @ngInject */ constructor($scope, $routeParams, backendSrv, $location, navModelSrv) { $scope.init = () => { @@ -48,4 +47,3 @@ export class AdminEditOrgCtrl { } } -angular.module('grafana.controllers').controller('AdminEditOrgCtrl', AdminEditOrgCtrl); diff --git a/public/app/features/admin/admin_edit_user_ctrl.ts b/public/app/features/admin/AdminEditUserCtrl.ts similarity index 95% rename from public/app/features/admin/admin_edit_user_ctrl.ts rename to public/app/features/admin/AdminEditUserCtrl.ts index c34ccdc1cad..bf72c1746aa 100644 --- a/public/app/features/admin/admin_edit_user_ctrl.ts +++ b/public/app/features/admin/AdminEditUserCtrl.ts @@ -1,7 +1,6 @@ -import angular from 'angular'; import _ from 'lodash'; -export class AdminEditUserCtrl { +export default class AdminEditUserCtrl { /** @ngInject */ constructor($scope, $routeParams, backendSrv, $location, navModelSrv) { $scope.user = {}; @@ -117,5 +116,3 @@ export class AdminEditUserCtrl { $scope.init(); } } - -angular.module('grafana.controllers').controller('AdminEditUserCtrl', AdminEditUserCtrl); diff --git a/public/app/features/admin/admin_list_orgs_ctrl.ts b/public/app/features/admin/AdminListOrgsCtrl.ts similarity index 84% rename from public/app/features/admin/admin_list_orgs_ctrl.ts rename to public/app/features/admin/AdminListOrgsCtrl.ts index 0513752aa3e..9190f7f494e 100644 --- a/public/app/features/admin/admin_list_orgs_ctrl.ts +++ b/public/app/features/admin/AdminListOrgsCtrl.ts @@ -1,6 +1,5 @@ -import angular from 'angular'; -export class AdminListOrgsCtrl { +export default class AdminListOrgsCtrl { /** @ngInject */ constructor($scope, backendSrv, navModelSrv) { $scope.init = () => { @@ -33,4 +32,3 @@ export class AdminListOrgsCtrl { } } -angular.module('grafana.controllers').controller('AdminListOrgsCtrl', AdminListOrgsCtrl); diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/AdminListUsersCtrl.ts similarity index 100% rename from public/app/features/admin/admin_list_users_ctrl.ts rename to public/app/features/admin/AdminListUsersCtrl.ts diff --git a/public/app/features/admin/ServerStats.test.tsx b/public/app/features/admin/ServerStats.test.tsx new file mode 100644 index 00000000000..cbcc580f612 --- /dev/null +++ b/public/app/features/admin/ServerStats.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ServerStats } from './ServerStats'; +import { createNavModel } from 'test/mocks/common'; +import { ServerStat } from './state/apis'; + +describe('ServerStats', () => { + it('Should render table with stats', done => { + const navModel = createNavModel('Admin', 'stats'); + const stats: ServerStat[] = [{ name: 'Total dashboards', value: 10 }, { name: 'Total Users', value: 1 }]; + + const getServerStats = () => { + return Promise.resolve(stats); + }; + + const page = renderer.create(); + + setTimeout(() => { + expect(page.toJSON()).toMatchSnapshot(); + done(); + }); + }); +}); diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx new file mode 100644 index 00000000000..40be87ed4d3 --- /dev/null +++ b/public/app/features/admin/ServerStats.tsx @@ -0,0 +1,73 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { NavModel, StoreState } from 'app/types'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getServerStats, ServerStat } from './state/apis'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; + +interface Props { + navModel: NavModel; + getServerStats: () => Promise; +} + +interface State { + stats: ServerStat[]; +} + +export class ServerStats extends PureComponent { + constructor(props) { + super(props); + + this.state = { + stats: [], + }; + } + + async componentDidMount() { + try { + const stats = await this.props.getServerStats(); + this.setState({ stats }); + } catch (error) { + console.error(error); + } + } + + render() { + const { navModel } = this.props; + const { stats } = this.state; + + return ( + + + + + + + Name + Value + + + {stats.map(StatItem)} + + + + ); + } +} + +function StatItem(stat: ServerStat) { + return ( + + {stat.name} + {stat.value} + + ); +} + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'server-stats'), + getServerStats: getServerStats, +}); + +export default hot(module)(connect(mapStateToProps)(ServerStats)); diff --git a/public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap similarity index 60% rename from public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap rename to public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap index eac793ca2ca..63de5bcd870 100644 --- a/public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap +++ b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap @@ -17,8 +17,9 @@ exports[`ServerStats Should render table with stats 1`] = ` - - + - admin-Text + Admin - + + subTitle + @@ -36,19 +41,19 @@ exports[`ServerStats Should render table with stats 1`] = ` className="gf-form-select-wrapper width-20 page-header__select-nav" > - server-stats-Text + Admin @@ -60,13 +65,13 @@ exports[`ServerStats Should render table with stats 1`] = ` > - server-stats-Text + Admin @@ -101,66 +106,10 @@ exports[`ServerStats Should render table with stats 1`] = ` - Total users + Total Users - 0 - - - - - Active users (seen last 30 days) - - - 0 - - - - - Total orgs - - - 0 - - - - - Total playlists - - - 0 - - - - - Total snapshots - - - 0 - - - - - Total dashboard tags - - - 0 - - - - - Total starred dashboards - - - 0 - - - - - Total alerts - - - 0 + 1 diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/index.ts similarity index 57% rename from public/app/features/admin/admin.ts rename to public/app/features/admin/index.ts index 00e98821779..7fc14791ea7 100644 --- a/public/app/features/admin/admin.ts +++ b/public/app/features/admin/index.ts @@ -1,7 +1,7 @@ -import AdminListUsersCtrl from './admin_list_users_ctrl'; -import './admin_list_orgs_ctrl'; -import './admin_edit_org_ctrl'; -import './admin_edit_user_ctrl'; +import AdminListUsersCtrl from './AdminListUsersCtrl'; +import AdminEditUserCtrl from './AdminEditUserCtrl'; +import AdminListOrgsCtrl from './AdminListOrgsCtrl'; +import AdminEditOrgCtrl from './AdminEditOrgCtrl'; import coreModule from 'app/core/core_module'; @@ -27,21 +27,11 @@ class AdminHomeCtrl { } } -export class AdminStatsCtrl { - stats: any; - navModel: any; +coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); +coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl); - /** @ngInject */ - constructor(backendSrv: any, navModelSrv) { - this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-stats', 1); - - backendSrv.get('/api/admin/stats').then(stats => { - this.stats = stats; - }); - } -} +coreModule.controller('AdminListOrgsCtrl', AdminListOrgsCtrl); +coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl); coreModule.controller('AdminSettingsCtrl', AdminSettingsCtrl); coreModule.controller('AdminHomeCtrl', AdminHomeCtrl); -coreModule.controller('AdminStatsCtrl', AdminStatsCtrl); -coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts new file mode 100644 index 00000000000..d81fd299493 --- /dev/null +++ b/public/app/features/admin/state/apis.ts @@ -0,0 +1,26 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; + +export interface ServerStat { + name: string; + value: number; +} + +export const getServerStats = async (): Promise => { + try { + const res = await getBackendSrv().get('api/admin/stats'); + return [ + { name: 'Total users', value: res.users }, + { name: 'Total dashboards', value: res.dashboards }, + { name: 'Active users (seen last 30 days)', value: res.activeUsers }, + { name: 'Total orgs', value: res.orgs }, + { name: 'Total playlists', value: res.playlists }, + { name: 'Total snapshots', value: res.snapshots }, + { name: 'Total dashboard tags', value: res.tags }, + { name: 'Total starred dashboards', value: res.stars }, + { name: 'Total alerts', value: res.alerts }, + ]; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/public/app/features/alerting/AlertRuleItem.test.tsx b/public/app/features/alerting/AlertRuleItem.test.tsx new file mode 100644 index 00000000000..bd37e127c39 --- /dev/null +++ b/public/app/features/alerting/AlertRuleItem.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import AlertRuleItem, { Props } from './AlertRuleItem'; + +jest.mock('react-redux', () => ({ + connect: () => params => params, +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + rule: { + id: 1, + dashboardId: 1, + panelId: 1, + name: 'Some rule', + state: 'Open', + stateText: 'state text', + stateIcon: 'icon', + stateClass: 'state class', + stateAge: 'age', + url: 'https://something.something.darkside', + }, + search: '', + onTogglePause: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/AlertRuleItem.tsx b/public/app/features/alerting/AlertRuleItem.tsx new file mode 100644 index 00000000000..f47a6348303 --- /dev/null +++ b/public/app/features/alerting/AlertRuleItem.tsx @@ -0,0 +1,69 @@ +import React, { PureComponent } from 'react'; +import Highlighter from 'react-highlight-words'; +import classNames from 'classnames/bind'; +import { AlertRule } from '../../types'; + +export interface Props { + rule: AlertRule; + search: string; + onTogglePause: () => void; +} + +class AlertRuleItem extends PureComponent { + renderText(text: string) { + return ( + + ); + } + + render() { + const { rule, onTogglePause } = this.props; + + const stateClass = classNames({ + fa: true, + 'fa-play': rule.state === 'paused', + 'fa-pause': rule.state !== 'paused', + }); + + const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; + + return ( + + + + + + + + {this.renderText(rule.name)} + + + {this.renderText(rule.stateText)} + for {rule.stateAge} + + + {rule.info && {this.renderText(rule.info)}} + + + + + + + + + + + + ); + } +} + +export default AlertRuleItem; diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx new file mode 100644 index 00000000000..2d1cf653540 --- /dev/null +++ b/public/app/features/alerting/AlertRuleList.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AlertRuleList, Props } from './AlertRuleList'; +import { AlertRule, NavModel } from '../../types'; +import appEvents from '../../core/app_events'; + +jest.mock('../../core/app_events', () => ({ + emit: jest.fn(), +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + alertRules: [] as AlertRule[], + updateLocation: jest.fn(), + getAlertRulesAsync: jest.fn(), + setSearchQuery: jest.fn(), + togglePauseAlertRule: jest.fn(), + stateFilter: '', + search: '', + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + + return { + wrapper, + instance: wrapper.instance() as AlertRuleList, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render alert rules', () => { + const { wrapper } = setup({ + alertRules: [ + { + id: 1, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Always OK', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 3, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - ok', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ], + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Life cycle', () => { + describe('component did mount', () => { + it('should call fetchrules', () => { + const { instance } = setup(); + instance.fetchRules = jest.fn(); + instance.componentDidMount(); + expect(instance.fetchRules).toHaveBeenCalled(); + }); + }); + + describe('component did update', () => { + it('should call fetchrules if props differ', () => { + const { instance } = setup(); + instance.fetchRules = jest.fn(); + + instance.componentDidUpdate({ stateFilter: 'ok' } as Props); + + expect(instance.fetchRules).toHaveBeenCalled(); + }); + }); +}); + +describe('Functions', () => { + describe('Get state filter', () => { + it('should get all if prop is not set', () => { + const { instance } = setup(); + + const stateFilter = instance.getStateFilter(); + + expect(stateFilter).toEqual('all'); + }); + + it('should return state filter if set', () => { + const { instance } = setup({ + stateFilter: 'ok', + }); + + const stateFilter = instance.getStateFilter(); + + expect(stateFilter).toEqual('ok'); + }); + }); + + describe('State filter changed', () => { + it('should update location', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'alerting' } }; + + instance.onStateFilterChanged(mockEvent); + + expect(instance.props.updateLocation).toHaveBeenCalledWith({ query: { state: 'alerting' } }); + }); + }); + + describe('Open how to', () => { + it('should emit show-modal event', () => { + const { instance } = setup(); + + instance.onOpenHowTo(); + + expect(appEvents.emit).toHaveBeenCalledWith('show-modal', { + src: 'public/app/features/alerting/partials/alert_howto.html', + modalClass: 'confirm-modal', + model: {}, + }); + }); + }); + + describe('Search query change', () => { + it('should set search query', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'dashboard' } }; + + instance.onSearchQueryChange(mockEvent); + + expect(instance.props.setSearchQuery).toHaveBeenCalledWith('dashboard'); + }); + }); +}); diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx new file mode 100644 index 00000000000..d25fc659af5 --- /dev/null +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -0,0 +1,153 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import AlertRuleItem from './AlertRuleItem'; +import appEvents from 'app/core/app_events'; +import { updateLocation } from 'app/core/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { NavModel, StoreState, AlertRule } from 'app/types'; +import { getAlertRulesAsync, setSearchQuery, togglePauseAlertRule } from './state/actions'; +import { getAlertRuleItems, getSearchQuery } from './state/selectors'; + +export interface Props { + navModel: NavModel; + alertRules: AlertRule[]; + updateLocation: typeof updateLocation; + getAlertRulesAsync: typeof getAlertRulesAsync; + setSearchQuery: typeof setSearchQuery; + togglePauseAlertRule: typeof togglePauseAlertRule; + stateFilter: string; + search: string; +} + +export class AlertRuleList extends PureComponent { + stateFilters = [ + { text: 'All', value: 'all' }, + { text: 'OK', value: 'ok' }, + { text: 'Not OK', value: 'not_ok' }, + { text: 'Alerting', value: 'alerting' }, + { text: 'No Data', value: 'no_data' }, + { text: 'Paused', value: 'paused' }, + ]; + + componentDidMount() { + this.fetchRules(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.stateFilter !== this.props.stateFilter) { + this.fetchRules(); + } + } + + async fetchRules() { + await this.props.getAlertRulesAsync({ state: this.getStateFilter() }); + } + + getStateFilter(): string { + const { stateFilter } = this.props; + if (stateFilter) { + return stateFilter.toString(); + } + return 'all'; + } + + onStateFilterChanged = event => { + this.props.updateLocation({ + query: { state: event.target.value }, + }); + }; + + onOpenHowTo = () => { + appEvents.emit('show-modal', { + src: 'public/app/features/alerting/partials/alert_howto.html', + modalClass: 'confirm-modal', + model: {}, + }); + }; + + onSearchQueryChange = event => { + const { value } = event.target; + this.props.setSearchQuery(value); + }; + + onTogglePause = (rule: AlertRule) => { + this.props.togglePauseAlertRule(rule.id, { paused: rule.state !== 'paused' }); + }; + + alertStateFilterOption = ({ text, value }) => { + return ( + + {text} + + ); + }; + + render() { + const { navModel, alertRules, search } = this.props; + + return ( + + + + + + + + + + + + States + + + + {this.stateFilters.map(this.alertStateFilterOption)} + + + + + + How to add an alert + + + + + {alertRules.map(rule => ( + this.onTogglePause(rule)} + /> + ))} + + + + + ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'alert-list'), + alertRules: getAlertRuleItems(state.alertRules), + stateFilter: state.location.query.state, + search: getSearchQuery(state.alertRules), +}); + +const mapDispatchToProps = { + updateLocation, + getAlertRulesAsync, + setSearchQuery, + togglePauseAlertRule, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(AlertRuleList)); diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/AlertTabCtrl.ts similarity index 99% rename from public/app/features/alerting/alert_tab_ctrl.ts rename to public/app/features/alerting/AlertTabCtrl.ts index 53f7e57fd69..c91ff5cd6c3 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { ThresholdMapper } from './threshold_mapper'; +import { ThresholdMapper } from './state/ThresholdMapper'; import { QueryPart } from 'app/core/components/query_part/query_part'; -import alertDef from './alert_def'; +import alertDef from './state/alertDef'; import config from 'app/core/config'; import appEvents from 'app/core/app_events'; diff --git a/public/app/features/alerting/notification_edit_ctrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts similarity index 100% rename from public/app/features/alerting/notification_edit_ctrl.ts rename to public/app/features/alerting/NotificationsEditCtrl.ts diff --git a/public/app/features/alerting/notifications_list_ctrl.ts b/public/app/features/alerting/NotificationsListCtrl.ts similarity index 100% rename from public/app/features/alerting/notifications_list_ctrl.ts rename to public/app/features/alerting/NotificationsListCtrl.ts diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap similarity index 60% rename from public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap rename to public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap index f408f6409be..f686127ebf3 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap @@ -1,14 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AlertRuleList should render 1 rule 1`] = ` +exports[`Render should render component 1`] = ` - - - Panel Title alert - - - + textToHighlight="Some rule" + /> - - - OK - - - + textToHighlight="state text" + /> for - 5 minutes + age @@ -82,7 +64,7 @@ exports[`AlertRuleList should render 1 rule 1`] = ` > + + + + + + + + + + + + States + + + + + All + + + OK + + + Not OK + + + Alerting + + + No Data + + + Paused + + + + + + + + How to add an alert + + + + + + + + + + +`; + +exports[`Render should render component 1`] = ` + + + + + + + + + + + + + States + + + + + All + + + OK + + + Not OK + + + Alerting + + + No Data + + + Paused + + + + + + + + How to add an alert + + + + + + + +`; diff --git a/public/app/features/alerting/all.ts b/public/app/features/alerting/all.ts deleted file mode 100644 index 91d3a4109e7..00000000000 --- a/public/app/features/alerting/all.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './notifications_list_ctrl'; -import './notification_edit_ctrl'; diff --git a/public/app/features/alerting/specs/threshold_mapper.test.ts b/public/app/features/alerting/state/ThresholdMapper.test.ts similarity index 97% rename from public/app/features/alerting/specs/threshold_mapper.test.ts rename to public/app/features/alerting/state/ThresholdMapper.test.ts index 922d9c8787e..8e91d0b6d0a 100644 --- a/public/app/features/alerting/specs/threshold_mapper.test.ts +++ b/public/app/features/alerting/state/ThresholdMapper.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'test/lib/common'; -import { ThresholdMapper } from '../threshold_mapper'; +import { ThresholdMapper } from './ThresholdMapper'; describe('ThresholdMapper', () => { describe('with greater than evaluator', () => { diff --git a/public/app/features/alerting/threshold_mapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts similarity index 100% rename from public/app/features/alerting/threshold_mapper.ts rename to public/app/features/alerting/state/ThresholdMapper.ts diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts new file mode 100644 index 00000000000..ca50d9e1038 --- /dev/null +++ b/public/app/features/alerting/state/actions.ts @@ -0,0 +1,47 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { AlertRuleApi, StoreState } from 'app/types'; +import { ThunkAction } from 'redux-thunk'; + +export enum ActionTypes { + LoadAlertRules = 'LOAD_ALERT_RULES', + SetSearchQuery = 'SET_SEARCH_QUERY', +} + +export interface LoadAlertRulesAction { + type: ActionTypes.LoadAlertRules; + payload: AlertRuleApi[]; +} + +export interface SetSearchQueryAction { + type: ActionTypes.SetSearchQuery; + payload: string; +} + +export const loadAlertRules = (rules: AlertRuleApi[]): LoadAlertRulesAction => ({ + type: ActionTypes.LoadAlertRules, + payload: rules, +}); + +export const setSearchQuery = (query: string): SetSearchQueryAction => ({ + type: ActionTypes.SetSearchQuery, + payload: query, +}); + +export type Action = LoadAlertRulesAction | SetSearchQueryAction; + +type ThunkResult = ThunkAction; + +export function getAlertRulesAsync(options: { state: string }): ThunkResult { + return async dispatch => { + const rules = await getBackendSrv().get('/api/alerts', options); + dispatch(loadAlertRules(rules)); + }; +} + +export function togglePauseAlertRule(id: number, options: { paused: boolean }): ThunkResult { + return async (dispatch, getState) => { + await getBackendSrv().post(`/api/alerts/${id}/pause`, options); + const stateFilter = getState().location.query.state || 'all'; + dispatch(getAlertRulesAsync({ state: stateFilter.toString() })); + }; +} diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/state/alertDef.ts similarity index 100% rename from public/app/features/alerting/alert_def.ts rename to public/app/features/alerting/state/alertDef.ts diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts new file mode 100644 index 00000000000..96ca7bacf6c --- /dev/null +++ b/public/app/features/alerting/state/reducers.test.ts @@ -0,0 +1,91 @@ +import { ActionTypes, Action } from './actions'; +import { alertRulesReducer, initialState } from './reducers'; +import { AlertRuleApi } from '../../../types'; + +describe('Alert rules', () => { + const payload: AlertRuleApi[] = [ + { + id: 2, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 4, + name: 'TestData - Always Alerting', + state: 'alerting', + newStateDate: '2018-09-04T10:00:30+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: { evalMatches: [{ metric: 'A-series', tags: null, value: 215 }] }, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 1, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Always OK', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 3, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - ok', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 4, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Paused', + state: 'paused', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 5, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Ok', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: { + noData: true, + }, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ]; + + it('should set alert rules', () => { + const action: Action = { + type: ActionTypes.LoadAlertRules, + payload: payload, + }; + + const result = alertRulesReducer(initialState, action); + + expect(result.items).toEqual(payload); + }); +}); diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts new file mode 100644 index 00000000000..73feb3cb260 --- /dev/null +++ b/public/app/features/alerting/state/reducers.ts @@ -0,0 +1,50 @@ +import moment from 'moment'; +import { AlertRuleApi, AlertRule, AlertRulesState } from 'app/types'; +import { Action, ActionTypes } from './actions'; +import alertDef from './alertDef'; + +export const initialState: AlertRulesState = { items: [], searchQuery: '' }; + +function convertToAlertRule(rule, state): AlertRule { + const stateModel = alertDef.getStateDisplayModel(state); + rule.stateText = stateModel.text; + rule.stateIcon = stateModel.iconClass; + rule.stateClass = stateModel.stateClass; + rule.stateAge = moment(rule.newStateDate) + .fromNow() + .replace(' ago', ''); + + if (rule.state !== 'paused') { + if (rule.executionError) { + rule.info = 'Execution Error: ' + rule.executionError; + } + if (rule.evalData && rule.evalData.noData) { + rule.info = 'Query returned no data'; + } + } + + return rule; +} + +export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => { + switch (action.type) { + case ActionTypes.LoadAlertRules: { + const alertRules: AlertRuleApi[] = action.payload; + + const alertRulesViewModel: AlertRule[] = alertRules.map(rule => { + return convertToAlertRule(rule, rule.state); + }); + + return { items: alertRulesViewModel, searchQuery: state.searchQuery }; + } + + case ActionTypes.SetSearchQuery: + return { items: state.items, searchQuery: action.payload }; + } + + return state; +}; + +export default { + alertRules: alertRulesReducer, +}; diff --git a/public/app/features/alerting/state/selectors.test.ts b/public/app/features/alerting/state/selectors.test.ts new file mode 100644 index 00000000000..e853b146c03 --- /dev/null +++ b/public/app/features/alerting/state/selectors.test.ts @@ -0,0 +1,94 @@ +import { getSearchQuery, getAlertRuleItems } from './selectors'; + +describe('Get search query', () => { + it('should get search query', () => { + const state = { searchQuery: 'dashboard' }; + const result = getSearchQuery(state); + + expect(result).toEqual(state.searchQuery); + }); +}); + +describe('Get alert rule items', () => { + it('should get alert rule items', () => { + const state = { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: '', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: '', + }; + + const result = getAlertRuleItems(state); + expect(result.length).toEqual(1); + }); + + it('should filter rule items based on search query', () => { + const state = { + items: [ + { + id: 1, + dashboardId: 1, + panelId: 1, + name: 'dashboard', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 2, + dashboardId: 3, + panelId: 1, + name: 'dashboard2', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 3, + dashboardId: 5, + panelId: 1, + name: 'hello', + state: '', + stateText: '', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + { + id: 4, + dashboardId: 7, + panelId: 1, + name: 'test', + state: '', + stateText: 'dashboard', + stateIcon: '', + stateClass: '', + stateAge: '', + url: '', + }, + ], + searchQuery: 'dashboard', + }; + + const result = getAlertRuleItems(state); + expect(result.length).toEqual(3); + }); +}); diff --git a/public/app/features/alerting/state/selectors.ts b/public/app/features/alerting/state/selectors.ts new file mode 100644 index 00000000000..7c72520d773 --- /dev/null +++ b/public/app/features/alerting/state/selectors.ts @@ -0,0 +1,9 @@ +export const getSearchQuery = state => state.searchQuery; + +export const getAlertRuleItems = state => { + const regex = new RegExp(state.searchQuery, 'i'); + + return state.items.filter(item => { + return regex.test(item.name) || regex.test(item.stateText) || regex.test(item.info); + }); +}; diff --git a/public/app/features/all.ts b/public/app/features/all.ts index df987a8b59b..0285e9c352a 100644 --- a/public/app/features/all.ts +++ b/public/app/features/all.ts @@ -8,6 +8,7 @@ import './playlist/all'; import './snapshot/all'; import './panel/all'; import './org/all'; -import './admin/admin'; -import './alerting/all'; +import './admin'; +import './alerting/NotificationsEditCtrl'; +import './alerting/NotificationsListCtrl'; import './styleguide/styleguide'; diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 7e626bc5860..16c18005204 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import $ from 'jquery'; import coreModule from 'app/core/core_module'; -import alertDef from '../alerting/alert_def'; +import alertDef from '../alerting/state/alertDef'; /** @ngInject */ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, $compile) { diff --git a/public/app/plugins/panel/alertlist/module.ts b/public/app/plugins/panel/alertlist/module.ts index 0726da41c7a..00eea4b2eb7 100644 --- a/public/app/plugins/panel/alertlist/module.ts +++ b/public/app/plugins/panel/alertlist/module.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import moment from 'moment'; -import alertDef from '../../../features/alerting/alert_def'; +import alertDef from '../../../features/alerting/state/alertDef'; import { PanelCtrl } from 'app/plugins/sdk'; import * as dateMath from 'app/core/utils/datemath'; diff --git a/public/app/plugins/sdk.ts b/public/app/plugins/sdk.ts index 2734426bd19..0f183271495 100644 --- a/public/app/plugins/sdk.ts +++ b/public/app/plugins/sdk.ts @@ -1,7 +1,7 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import { MetricsPanelCtrl } from 'app/features/panel/metrics_panel_ctrl'; import { QueryCtrl } from 'app/features/panel/query_ctrl'; -import { alertTab } from 'app/features/alerting/alert_tab_ctrl'; +import { alertTab } from 'app/features/alerting/AlertTabCtrl'; import { loadPluginCss } from 'app/features/plugins/plugin_loader'; export { PanelCtrl, MetricsPanelCtrl, QueryCtrl, alertTab, loadPluginCss }; diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index 438cdaa9137..8a3d7e643f9 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -1,18 +1,22 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'mobx-react'; +import { Provider as ReduxProvider } from 'react-redux'; import coreModule from 'app/core/core_module'; import { store } from 'app/stores/store'; +import { store as reduxStore } from 'app/stores/configureStore'; import { BackendSrv } from 'app/core/services/backend_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ContextSrv } from 'app/core/services/context_srv'; function WrapInProvider(store, Component, props) { return ( - - - + + + + + ); } diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 55d76dc7239..0fd76a8c8eb 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -1,8 +1,8 @@ import './dashboard_loaders'; import './ReactContainer'; -import ServerStats from 'app/containers/ServerStats/ServerStats'; -import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList'; +import ServerStats from 'app/features/admin/ServerStats'; +import AlertRuleList from 'app/features/alerting/AlertRuleList'; import FolderSettings from 'app/containers/ManageDashboards/FolderSettings'; import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions'; import TeamPages from 'app/containers/Teams/TeamPages'; diff --git a/public/app/stores/AlertListStore/AlertListStore.test.ts b/public/app/stores/AlertListStore/AlertListStore.test.ts deleted file mode 100644 index f0ab24b6cfc..00000000000 --- a/public/app/stores/AlertListStore/AlertListStore.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AlertListStore } from './AlertListStore'; -import { backendSrv } from 'test/mocks/common'; -import moment from 'moment'; - -function getRule(name, state, info) { - return { - id: 11, - dashboardId: 58, - panelId: 3, - name: name, - state: state, - newStateDate: moment() - .subtract(5, 'minutes') - .format(), - evalData: {}, - executionError: '', - url: 'db/mygool', - stateText: state, - stateIcon: 'fa', - stateClass: 'asd', - stateAge: '10m', - info: info, - canEdit: true, - }; -} - -describe('AlertListStore', () => { - let store; - - beforeAll(() => { - store = AlertListStore.create( - { - rules: [ - getRule('Europe', 'OK', 'backend-01'), - getRule('Google', 'ALERTING', 'backend-02'), - getRule('Amazon', 'PAUSED', 'backend-03'), - getRule('West-Europe', 'PAUSED', 'backend-03'), - ], - search: '', - }, - { - backendSrv: backendSrv, - } - ); - }); - - it('search should filter list on name', () => { - store.setSearchQuery('urope'); - expect(store.filteredRules).toHaveLength(2); - }); - - it('search should filter list on state', () => { - store.setSearchQuery('ale'); - expect(store.filteredRules).toHaveLength(1); - }); - - it('search should filter list on info', () => { - store.setSearchQuery('-0'); - expect(store.filteredRules).toHaveLength(4); - }); - - it('search should be equal', () => { - store.setSearchQuery('alert'); - expect(store.search).toBe('alert'); - }); -}); diff --git a/public/app/stores/AlertListStore/AlertListStore.ts b/public/app/stores/AlertListStore/AlertListStore.ts deleted file mode 100644 index c2b9f5e4962..00000000000 --- a/public/app/stores/AlertListStore/AlertListStore.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { types, getEnv, flow } from 'mobx-state-tree'; -import { AlertRule as AlertRuleModel } from './AlertRule'; -import { setStateFields } from './helpers'; - -type AlertRuleType = typeof AlertRuleModel.Type; -export interface AlertRule extends AlertRuleType {} - -export const AlertListStore = types - .model('AlertListStore', { - rules: types.array(AlertRuleModel), - stateFilter: types.optional(types.string, 'all'), - search: types.optional(types.string, ''), - }) - .views(self => ({ - get filteredRules() { - const regex = new RegExp(self.search, 'i'); - return self.rules.filter(alert => { - return regex.test(alert.name) || regex.test(alert.stateText) || regex.test(alert.info); - }); - }, - })) - .actions(self => ({ - loadRules: flow(function* load(filters) { - const backendSrv = getEnv(self).backendSrv; - self.stateFilter = filters.state; // store state filter used in api query - const apiRules = yield backendSrv.get('/api/alerts', filters); - self.rules.clear(); - - for (const rule of apiRules) { - setStateFields(rule, rule.state); - - if (rule.state !== 'paused') { - if (rule.executionError) { - rule.info = 'Execution Error: ' + rule.executionError; - } - if (rule.evalData && rule.evalData.noData) { - rule.info = 'Query returned no data'; - } - } - - self.rules.push(AlertRuleModel.create(rule)); - } - }), - setSearchQuery(query: string) { - self.search = query; - }, - })); diff --git a/public/app/stores/AlertListStore/AlertRule.ts b/public/app/stores/AlertListStore/AlertRule.ts deleted file mode 100644 index 9c039be6ec2..00000000000 --- a/public/app/stores/AlertListStore/AlertRule.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { types, getEnv, flow } from 'mobx-state-tree'; -import { setStateFields } from './helpers'; - -export const AlertRule = types - .model('AlertRule', { - id: types.identifier(types.number), - dashboardId: types.number, - panelId: types.number, - name: types.string, - state: types.string, - stateText: types.string, - stateIcon: types.string, - stateClass: types.string, - stateAge: types.string, - info: types.optional(types.string, ''), - url: types.string, - }) - .views(self => ({ - get isPaused() { - return self.state === 'paused'; - }, - })) - .actions(self => ({ - /** - * will toggle alert rule paused state - */ - togglePaused: flow(function* togglePaused() { - const backendSrv = getEnv(self).backendSrv; - const payload = { paused: !self.isPaused }; - const res = yield backendSrv.post(`/api/alerts/${self.id}/pause`, payload); - setStateFields(self, res.state); - self.info = ''; - }), - })); diff --git a/public/app/stores/AlertListStore/helpers.ts b/public/app/stores/AlertListStore/helpers.ts deleted file mode 100644 index c460a697967..00000000000 --- a/public/app/stores/AlertListStore/helpers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import moment from 'moment'; -import alertDef from 'app/features/alerting/alert_def'; - -export function setStateFields(rule, state) { - const stateModel = alertDef.getStateDisplayModel(state); - rule.state = state; - rule.stateText = stateModel.text; - rule.stateIcon = stateModel.iconClass; - rule.stateClass = stateModel.stateClass; - rule.stateAge = moment(rule.newStateDate) - .fromNow() - .replace(' ago', ''); -} diff --git a/public/app/stores/RootStore/RootStore.ts b/public/app/stores/RootStore/RootStore.ts index bb85a85d9dd..fba25e5f015 100644 --- a/public/app/stores/RootStore/RootStore.ts +++ b/public/app/stores/RootStore/RootStore.ts @@ -1,24 +1,12 @@ import { types } from 'mobx-state-tree'; -import { SearchStore } from './../SearchStore/SearchStore'; -import { ServerStatsStore } from './../ServerStatsStore/ServerStatsStore'; import { NavStore } from './../NavStore/NavStore'; -import { AlertListStore } from './../AlertListStore/AlertListStore'; import { ViewStore } from './../ViewStore/ViewStore'; import { FolderStore } from './../FolderStore/FolderStore'; import { PermissionsStore } from './../PermissionsStore/PermissionsStore'; import { TeamsStore } from './../TeamsStore/TeamsStore'; export const RootStore = types.model({ - search: types.optional(SearchStore, { - sections: [], - }), - serverStats: types.optional(ServerStatsStore, { - stats: [], - }), nav: types.optional(NavStore, {}), - alertList: types.optional(AlertListStore, { - rules: [], - }), permissions: types.optional(PermissionsStore, { fetching: false, items: [], diff --git a/public/app/stores/SearchStore/ResultItem.ts b/public/app/stores/SearchStore/ResultItem.ts deleted file mode 100644 index eb0ff021526..00000000000 --- a/public/app/stores/SearchStore/ResultItem.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { types } from 'mobx-state-tree'; - -export const ResultItem = types.model('ResultItem', { - id: types.identifier(types.number), - folderId: types.optional(types.number, 0), - title: types.string, - url: types.string, - icon: types.string, - folderTitle: types.optional(types.string, ''), -}); diff --git a/public/app/stores/SearchStore/SearchResultSection.ts b/public/app/stores/SearchStore/SearchResultSection.ts deleted file mode 100644 index 70b3ad48e96..00000000000 --- a/public/app/stores/SearchStore/SearchResultSection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { types } from 'mobx-state-tree'; -import { ResultItem } from './ResultItem'; - -export const SearchResultSection = types - .model('SearchResultSection', { - id: types.identifier(), - title: types.string, - icon: types.string, - expanded: types.boolean, - items: types.array(ResultItem), - }) - .actions(self => ({ - toggle() { - self.expanded = !self.expanded; - - for (let i = 0; i < 100; i++) { - self.items.push( - ResultItem.create({ - id: i, - title: 'Dashboard ' + self.items.length, - icon: 'gicon gicon-dashboard', - url: 'asd', - }) - ); - } - }, - })); diff --git a/public/app/stores/SearchStore/SearchStore.ts b/public/app/stores/SearchStore/SearchStore.ts deleted file mode 100644 index 36897f05f38..00000000000 --- a/public/app/stores/SearchStore/SearchStore.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { types } from 'mobx-state-tree'; -import { SearchResultSection } from './SearchResultSection'; - -export const SearchStore = types - .model('SearchStore', { - sections: types.array(SearchResultSection), - }) - .actions(self => ({ - query() { - for (let i = 0; i < 100; i++) { - self.sections.push( - SearchResultSection.create({ - id: 'starred' + i, - title: 'starred', - icon: 'fa fa-fw fa-star-o', - expanded: false, - items: [], - }) - ); - } - }, - })); diff --git a/public/app/stores/ServerStatsStore/ServerStat.ts b/public/app/stores/ServerStatsStore/ServerStat.ts deleted file mode 100644 index bd819a51e76..00000000000 --- a/public/app/stores/ServerStatsStore/ServerStat.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { types } from 'mobx-state-tree'; - -export const ServerStat = types.model('ServerStat', { - name: types.string, - value: types.optional(types.number, 0), -}); diff --git a/public/app/stores/ServerStatsStore/ServerStatsStore.ts b/public/app/stores/ServerStatsStore/ServerStatsStore.ts deleted file mode 100644 index d27285d7a3b..00000000000 --- a/public/app/stores/ServerStatsStore/ServerStatsStore.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { types, getEnv, flow } from 'mobx-state-tree'; -import { ServerStat } from './ServerStat'; - -export const ServerStatsStore = types - .model('ServerStatsStore', { - stats: types.array(ServerStat), - error: types.optional(types.string, ''), - }) - .actions(self => ({ - load: flow(function* load() { - const backendSrv = getEnv(self).backendSrv; - const res = yield backendSrv.get('/api/admin/stats'); - self.stats.clear(); - self.stats.push(ServerStat.create({ name: 'Total dashboards', value: res.dashboards })); - self.stats.push(ServerStat.create({ name: 'Total users', value: res.users })); - self.stats.push(ServerStat.create({ name: 'Active users (seen last 30 days)', value: res.activeUsers })); - self.stats.push(ServerStat.create({ name: 'Total orgs', value: res.orgs })); - self.stats.push(ServerStat.create({ name: 'Total playlists', value: res.playlists })); - self.stats.push(ServerStat.create({ name: 'Total snapshots', value: res.snapshots })); - self.stats.push(ServerStat.create({ name: 'Total dashboard tags', value: res.tags })); - self.stats.push(ServerStat.create({ name: 'Total starred dashboards', value: res.stars })); - self.stats.push(ServerStat.create({ name: 'Total alerts', value: res.alerts })); - }), - })); diff --git a/public/app/stores/configureStore.ts b/public/app/stores/configureStore.ts new file mode 100644 index 00000000000..b101b31a2a2 --- /dev/null +++ b/public/app/stores/configureStore.ts @@ -0,0 +1,23 @@ +import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; +import thunk from 'redux-thunk'; +import { createLogger } from 'redux-logger'; +import sharedReducers from 'app/core/reducers'; +import alertingReducers from 'app/features/alerting/state/reducers'; + +const rootReducer = combineReducers({ + ...sharedReducers, + ...alertingReducers, +}); + +export let store; + +export function configureStore() { + const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + if (process.env.NODE_ENV !== 'production') { + // DEV builds we had the logger middleware + store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger()))); + } else { + store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk))); + } +} diff --git a/public/app/types/index.ts b/public/app/types/index.ts new file mode 100644 index 00000000000..debfcf58ac8 --- /dev/null +++ b/public/app/types/index.ts @@ -0,0 +1,96 @@ +// +// Location +// + +export interface LocationUpdate { + path?: string; + query?: UrlQueryMap; + routeParams?: UrlQueryMap; +} + +export interface LocationState { + url: string; + path: string; + query: UrlQueryMap; + routeParams: UrlQueryMap; +} + +export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[]; +export type UrlQueryMap = { [s: string]: UrlQueryValue }; + +// +// Alerting +// + +export interface AlertRuleApi { + id: number; + dashboardId: number; + dashboardUid: string; + dashboardSlug: string; + panelId: number; + name: string; + state: string; + newStateDate: string; + evalDate: string; + evalData?: object; + executionError: string; + url: string; +} + +export interface AlertRule { + id: number; + dashboardId: number; + panelId: number; + name: string; + state: string; + stateText: string; + stateIcon: string; + stateClass: string; + stateAge: string; + url: string; + info?: string; + executionError?: string; + evalData?: { noData: boolean }; +} + +// +// NavModel +// + +export interface NavModelItem { + text: string; + url: string; + subTitle?: string; + icon?: string; + img?: string; + id: string; + active?: boolean; + hideFromTabs?: boolean; + divider?: boolean; + children?: NavModelItem[]; + breadcrumbs?: NavModelItem[]; + target?: string; + parentItem?: NavModelItem; +} + +export interface NavModel { + main: NavModelItem; + node: NavModelItem; +} + +export type NavIndex = { [s: string]: NavModelItem }; + +// +// Store +// + +export interface AlertRulesState { + items: AlertRule[]; + searchQuery: string; +} + +export interface StoreState { + navIndex: NavIndex; + location: LocationState; + alertRules: AlertRulesState; +} diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index 47cbbcb44f6..9079754dc28 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -20,3 +20,24 @@ configure({ adapter: new Adapter() }); const global = window as any; global.$ = global.jQuery = $; + +const localStorageMock = (() => { + let store = {}; + return { + getItem: key => { + return store[key]; + }, + setItem: (key, value) => { + store[key] = value.toString(); + }, + clear: () => { + store = {}; + }, + removeItem: key => { + delete store[key]; + }, + }; +})(); + +global.localStorage = localStorageMock; +// Object.defineProperty(window, 'localStorage', { value: localStorageMock }); diff --git a/public/test/mocks/common.ts b/public/test/mocks/common.ts index 64d12fdf725..385f72621a9 100644 --- a/public/test/mocks/common.ts +++ b/public/test/mocks/common.ts @@ -1,3 +1,5 @@ +import { NavModel, NavModelItem } from 'app/types'; + export const backendSrv = { get: jest.fn(), getDashboard: jest.fn(), @@ -17,3 +19,33 @@ export function createNavTree(...args) { return root; } + +export function createNavModel(title: string, ...tabs: string[]): NavModel { + const node: NavModelItem = { + id: title, + text: title, + icon: 'fa fa-fw fa-warning', + subTitle: 'subTitle', + url: title, + children: [], + breadcrumbs: [], + }; + + for (const tab of tabs) { + node.children.push({ + id: tab, + icon: 'icon', + subTitle: 'subTitle', + url: title, + text: title, + active: false, + }); + } + + node.children[0].active = true; + + return { + node: node, + main: node, + }; +} diff --git a/yarn.lock b/yarn.lock index 14712fa399b..d11947c42b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3188,6 +3188,10 @@ dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -5625,7 +5629,7 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -invariant@^2.2.2: +invariant@^2.0.0, invariant@^2.2.2: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" dependencies: @@ -6909,6 +6913,10 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" +lodash-es@^4.17.5: + version "4.17.10" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.10.tgz#62cd7104cdf5dd87f235a837f0ede0e8e5117e05" + lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -9610,6 +9618,17 @@ react-reconciler@^0.7.0: object-assign "^4.1.1" prop-types "^15.6.0" +react-redux@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" + dependencies: + hoist-non-react-statics "^2.5.0" + invariant "^2.0.0" + lodash "^4.17.5" + lodash-es "^4.17.5" + loose-envify "^1.1.0" + prop-types "^15.6.0" + react-resizable@1.x: version "1.7.5" resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.7.5.tgz#83eb75bb3684da6989bbbf4f826e1470f0af902e" @@ -9864,6 +9883,23 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + dependencies: + deep-diff "^0.3.5" + +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + +redux@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" + dependencies: + loose-envify "^1.1.0" + symbol-observable "^1.2.0" + regenerate@^1.2.1: version "1.4.0" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" @@ -11182,7 +11218,7 @@ symbol-observable@^0.2.2: version "0.2.4" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-0.2.4.tgz#95a83db26186d6af7e7a18dbd9760a2f86d08f40" -symbol-observable@^1.1.0: +symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"