Torkel Ödegaard пре 7 година
родитељ
комит
298c088d57
66 измењених фајлова са 1534 додато и 749 уклоњено
  1. 1 0
      docker/blocks/openldap/ldap_dev.toml
  2. 4 0
      package.json
  3. 0 69
      public/app/containers/AlertRuleList/AlertRuleList.test.tsx
  4. 0 178
      public/app/containers/AlertRuleList/AlertRuleList.tsx
  5. 0 6
      public/app/containers/ContainerProps.ts
  6. 0 30
      public/app/containers/ServerStats/ServerStats.test.tsx
  7. 0 48
      public/app/containers/ServerStats/ServerStats.tsx
  8. 3 0
      public/app/core/actions/index.ts
  9. 13 0
      public/app/core/actions/location.ts
  10. 13 0
      public/app/core/actions/navModel.ts
  11. 1 1
      public/app/core/components/PageHeader/PageHeader.tsx
  12. 2 0
      public/app/core/components/grafana_app.ts
  13. 2 12
      public/app/core/components/search/SearchResult.tsx
  14. 7 0
      public/app/core/reducers/index.ts
  15. 33 0
      public/app/core/reducers/location.ts
  16. 29 0
      public/app/core/reducers/navModel.ts
  17. 39 0
      public/app/core/selectors/navModel.ts
  18. 33 0
      public/app/core/services/bridge_srv.ts
  19. 1 3
      public/app/features/admin/AdminEditOrgCtrl.ts
  20. 1 4
      public/app/features/admin/AdminEditUserCtrl.ts
  21. 1 3
      public/app/features/admin/AdminListOrgsCtrl.ts
  22. 0 0
      public/app/features/admin/AdminListUsersCtrl.ts
  23. 23 0
      public/app/features/admin/ServerStats.test.tsx
  24. 73 0
      public/app/features/admin/ServerStats.tsx
  25. 18 69
      public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap
  26. 8 18
      public/app/features/admin/index.ts
  27. 26 0
      public/app/features/admin/state/apis.ts
  28. 38 0
      public/app/features/alerting/AlertRuleItem.test.tsx
  29. 69 0
      public/app/features/alerting/AlertRuleItem.tsx
  30. 156 0
      public/app/features/alerting/AlertRuleList.test.tsx
  31. 153 0
      public/app/features/alerting/AlertRuleList.tsx
  32. 2 2
      public/app/features/alerting/AlertTabCtrl.ts
  33. 0 0
      public/app/features/alerting/NotificationsEditCtrl.ts
  34. 0 0
      public/app/features/alerting/NotificationsListCtrl.ts
  35. 12 30
      public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap
  36. 256 0
      public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap
  37. 0 2
      public/app/features/alerting/all.ts
  38. 1 1
      public/app/features/alerting/state/ThresholdMapper.test.ts
  39. 0 0
      public/app/features/alerting/state/ThresholdMapper.ts
  40. 47 0
      public/app/features/alerting/state/actions.ts
  41. 0 0
      public/app/features/alerting/state/alertDef.ts
  42. 91 0
      public/app/features/alerting/state/reducers.test.ts
  43. 50 0
      public/app/features/alerting/state/reducers.ts
  44. 94 0
      public/app/features/alerting/state/selectors.test.ts
  45. 9 0
      public/app/features/alerting/state/selectors.ts
  46. 3 2
      public/app/features/all.ts
  47. 1 1
      public/app/features/annotations/annotation_tooltip.ts
  48. 1 1
      public/app/plugins/panel/alertlist/module.ts
  49. 1 1
      public/app/plugins/sdk.ts
  50. 7 3
      public/app/routes/ReactContainer.tsx
  51. 2 2
      public/app/routes/routes.ts
  52. 0 66
      public/app/stores/AlertListStore/AlertListStore.test.ts
  53. 0 47
      public/app/stores/AlertListStore/AlertListStore.ts
  54. 0 34
      public/app/stores/AlertListStore/AlertRule.ts
  55. 0 13
      public/app/stores/AlertListStore/helpers.ts
  56. 0 12
      public/app/stores/RootStore/RootStore.ts
  57. 0 10
      public/app/stores/SearchStore/ResultItem.ts
  58. 0 27
      public/app/stores/SearchStore/SearchResultSection.ts
  59. 0 22
      public/app/stores/SearchStore/SearchStore.ts
  60. 0 6
      public/app/stores/ServerStatsStore/ServerStat.ts
  61. 0 24
      public/app/stores/ServerStatsStore/ServerStatsStore.ts
  62. 23 0
      public/app/stores/configureStore.ts
  63. 96 0
      public/app/types/index.ts
  64. 21 0
      public/test/jest-setup.ts
  65. 32 0
      public/test/mocks/common.ts
  66. 38 2
      yarn.lock

+ 1 - 0
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
 

+ 4 - 0
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",

+ 0 - 69
public/app/containers/AlertRuleList/AlertRuleList.test.tsx

@@ -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(<AlertRuleList {...store} />);
-  });
-
-  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);
-  });
-});

+ 0 - 178
public/app/containers/AlertRuleList/AlertRuleList.tsx

@@ -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<ContainerProps, any> {
-  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 (
-      <div>
-        <PageHeader model={nav as any} />
-        <div className="page-container page-body">
-          <div className="page-action-bar">
-            <div className="gf-form gf-form--grow">
-              <label className="gf-form--has-input-icon gf-form--grow">
-                <input
-                  type="text"
-                  className="gf-form-input"
-                  placeholder="Search alerts"
-                  value={alertList.search}
-                  onChange={this.onSearchQueryChange}
-                />
-                <i className="gf-form-input-icon fa fa-search" />
-              </label>
-            </div>
-            <div className="gf-form">
-              <label className="gf-form-label">States</label>
-
-              <div className="gf-form-select-wrapper width-13">
-                <select className="gf-form-input" onChange={this.onStateFilterChanged} value={alertList.stateFilter}>
-                  {this.stateFilters.map(AlertStateFilterOption)}
-                </select>
-              </div>
-            </div>
-
-            <div className="page-action-bar__spacer" />
-
-            <a className="btn btn-secondary" onClick={this.onOpenHowTo}>
-              <i className="fa fa-info-circle" /> How to add an alert
-            </a>
-          </div>
-
-          <section>
-            <ol className="alert-rule-list">
-              {alertList.filteredRules.map(rule => (
-                <AlertRuleItem rule={rule} key={rule.id} search={alertList.search} />
-              ))}
-            </ol>
-          </section>
-        </div>
-      </div>
-    );
-  }
-}
-
-function AlertStateFilterOption({ text, value }) {
-  return (
-    <option key={value} value={value}>
-      {text}
-    </option>
-  );
-}
-
-export interface AlertRuleItemProps {
-  rule: AlertRule;
-  search: string;
-}
-
-@observer
-export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
-  toggleState = () => {
-    this.props.rule.togglePaused();
-  };
-
-  renderText(text: string) {
-    return (
-      <Highlighter
-        highlightClassName="highlight-search-match"
-        textToHighlight={text}
-        searchWords={[this.props.search]}
-      />
-    );
-  }
-
-  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 (
-      <li className="alert-rule-item">
-        <span className={`alert-rule-item__icon ${rule.stateClass}`}>
-          <i className={rule.stateIcon} />
-        </span>
-        <div className="alert-rule-item__body">
-          <div className="alert-rule-item__header">
-            <div className="alert-rule-item__name">
-              <a href={ruleUrl}>{this.renderText(rule.name)}</a>
-            </div>
-            <div className="alert-rule-item__text">
-              <span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
-              <span className="alert-rule-item__time"> for {rule.stateAge}</span>
-            </div>
-          </div>
-          {rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
-        </div>
-
-        <div className="alert-rule-item__actions">
-          <button
-            className="btn btn-small btn-inverse alert-list__btn width-2"
-            title="Pausing an alert rule prevents it from executing"
-            onClick={this.toggleState}
-          >
-            <i className={stateClass} />
-          </button>
-          <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
-            <i className="icon-gf icon-gf-settings" />
-          </a>
-        </div>
-      </li>
-    );
-  }
-}
-
-export default hot(module)(AlertRuleList);

+ 0 - 6
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;

+ 0 - 30
public/app/containers/ServerStats/ServerStats.test.tsx

@@ -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(<ServerStats backendSrv={backendSrv} {...store} />);
-
-    setTimeout(() => {
-      expect(page.toJSON()).toMatchSnapshot();
-      done();
-    });
-  });
-});

+ 0 - 48
public/app/containers/ServerStats/ServerStats.tsx

@@ -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<ContainerProps, any> {
-  constructor(props) {
-    super(props);
-    const { nav, serverStats } = this.props;
-
-    nav.load('cfg', 'admin', 'server-stats');
-    serverStats.load();
-  }
-
-  render() {
-    const { nav, serverStats } = this.props;
-    return (
-      <div>
-        <PageHeader model={nav as any} />
-        <div className="page-container page-body">
-          <table className="filter-table form-inline">
-            <thead>
-              <tr>
-                <th>Name</th>
-                <th>Value</th>
-              </tr>
-            </thead>
-            <tbody>{serverStats.stats.map(StatItem)}</tbody>
-          </table>
-        </div>
-      </div>
-    );
-  }
-}
-
-function StatItem(stat) {
-  return (
-    <tr key={stat.name}>
-      <td>{stat.name}</td>
-      <td>{stat.value}</td>
-    </tr>
-  );
-}
-
-export default hot(module)(ServerStats);

+ 3 - 0
public/app/core/actions/index.ts

@@ -0,0 +1,3 @@
+import { updateLocation } from './location';
+
+export { updateLocation };

+ 13 - 0
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,
+});

+ 13 - 0
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',
+});

+ 1 - 1
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';

+ 2 - 0
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 });
 

+ 2 - 12
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<SearchResultProps, any> {
+export class SearchResult extends React.Component<any, any> {
   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<SectionProps, any> {
   constructor(props) {
     super(props);

+ 7 - 0
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,
+};

+ 33 - 0
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;
+};

+ 29 - 0
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;
+};

+ 39 - 0
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();
+  }
+}

+ 33 - 0
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) {

+ 1 - 3
public/app/features/admin/admin_edit_org_ctrl.ts → 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);

+ 1 - 4
public/app/features/admin/admin_edit_user_ctrl.ts → 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);

+ 1 - 3
public/app/features/admin/admin_list_orgs_ctrl.ts → 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);

+ 0 - 0
public/app/features/admin/admin_list_users_ctrl.ts → public/app/features/admin/AdminListUsersCtrl.ts


+ 23 - 0
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(<ServerStats navModel={navModel} getServerStats={getServerStats} />);
+
+    setTimeout(() => {
+      expect(page.toJSON()).toMatchSnapshot();
+      done();
+    });
+  });
+});

+ 73 - 0
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<ServerStat[]>;
+}
+
+interface State {
+  stats: ServerStat[];
+}
+
+export class ServerStats extends PureComponent<Props, State> {
+  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 (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <table className="filter-table form-inline">
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>Value</th>
+              </tr>
+            </thead>
+            <tbody>{stats.map(StatItem)}</tbody>
+          </table>
+        </div>
+      </div>
+    );
+  }
+}
+
+function StatItem(stat: ServerStat) {
+  return (
+    <tr key={stat.name}>
+      <td>{stat.name}</td>
+      <td>{stat.value}</td>
+    </tr>
+  );
+}
+
+const mapStateToProps = (state: StoreState) => ({
+  navModel: getNavModel(state.navIndex, 'server-stats'),
+  getServerStats: getServerStats,
+});
+
+export default hot(module)(connect(mapStateToProps)(ServerStats));

+ 18 - 69
public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap → public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap

@@ -17,8 +17,9 @@ exports[`ServerStats Should render table with stats 1`] = `
           <span
             className="page-header__logo"
           >
-            
-            
+            <i
+              className="page-header__icon fa fa-fw fa-warning"
+            />
           </span>
           <div
             className="page-header__info-block"
@@ -26,9 +27,13 @@ exports[`ServerStats Should render table with stats 1`] = `
             <h1
               className="page-header__title"
             >
-              admin-Text
+              Admin
             </h1>
-            
+            <div
+              className="page-header__sub-title"
+            >
+              subTitle
+            </div>
           </div>
         </div>
         <nav>
@@ -36,19 +41,19 @@ exports[`ServerStats Should render table with stats 1`] = `
             className="gf-form-select-wrapper width-20 page-header__select-nav"
           >
             <label
-              className="gf-form-select-icon "
+              className="gf-form-select-icon icon"
               htmlFor="page-header-select-nav"
             />
             <select
               className="gf-select-nav gf-form-input"
               id="page-header-select-nav"
               onChange={[Function]}
-              value="/url/server-stats"
+              value="Admin"
             >
               <option
-                value="/url/server-stats"
+                value="Admin"
               >
-                server-stats-Text
+                Admin
               </option>
             </select>
           </div>
@@ -60,13 +65,13 @@ exports[`ServerStats Should render table with stats 1`] = `
             >
               <a
                 className="gf-tabs-link active"
-                href="/url/server-stats"
+                href="Admin"
                 target={undefined}
               >
                 <i
-                  className=""
+                  className="icon"
                 />
-                server-stats-Text
+                Admin
               </a>
             </li>
           </ul>
@@ -101,66 +106,10 @@ exports[`ServerStats Should render table with stats 1`] = `
         </tr>
         <tr>
           <td>
-            Total users
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Active users (seen last 30 days)
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total orgs
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total playlists
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total snapshots
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total dashboard tags
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total starred dashboards
-          </td>
-          <td>
-            0
-          </td>
-        </tr>
-        <tr>
-          <td>
-            Total alerts
+            Total Users
           </td>
           <td>
-            0
+            1
           </td>
         </tr>
       </tbody>

+ 8 - 18
public/app/features/admin/admin.ts → 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;
-
-  /** @ngInject */
-  constructor(backendSrv: any, navModelSrv) {
-    this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-stats', 1);
+coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl);
+coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl);
 
-    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);

+ 26 - 0
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<ServerStat[]> => {
+  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;
+  }
+};

+ 38 - 0
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(<AlertRuleItem {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 69 - 0
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<Props> {
+  renderText(text: string) {
+    return (
+      <Highlighter
+        highlightClassName="highlight-search-match"
+        textToHighlight={text}
+        searchWords={[this.props.search]}
+      />
+    );
+  }
+
+  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 (
+      <li className="alert-rule-item">
+        <span className={`alert-rule-item__icon ${rule.stateClass}`}>
+          <i className={rule.stateIcon} />
+        </span>
+        <div className="alert-rule-item__body">
+          <div className="alert-rule-item__header">
+            <div className="alert-rule-item__name">
+              <a href={ruleUrl}>{this.renderText(rule.name)}</a>
+            </div>
+            <div className="alert-rule-item__text">
+              <span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
+              <span className="alert-rule-item__time"> for {rule.stateAge}</span>
+            </div>
+          </div>
+          {rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
+        </div>
+
+        <div className="alert-rule-item__actions">
+          <button
+            className="btn btn-small btn-inverse alert-list__btn width-2"
+            title="Pausing an alert rule prevents it from executing"
+            onClick={onTogglePause}
+          >
+            <i className={stateClass} />
+          </button>
+          <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
+            <i className="icon-gf icon-gf-settings" />
+          </a>
+        </div>
+      </li>
+    );
+  }
+}
+
+export default AlertRuleItem;

+ 156 - 0
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(<AlertRuleList {...props} />);
+
+  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');
+    });
+  });
+});

+ 153 - 0
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<Props, any> {
+  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 (
+      <option key={value} value={value}>
+        {text}
+      </option>
+    );
+  };
+
+  render() {
+    const { navModel, alertRules, search } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <div className="page-action-bar">
+            <div className="gf-form gf-form--grow">
+              <label className="gf-form--has-input-icon gf-form--grow">
+                <input
+                  type="text"
+                  className="gf-form-input"
+                  placeholder="Search alerts"
+                  value={search}
+                  onChange={this.onSearchQueryChange}
+                />
+                <i className="gf-form-input-icon fa fa-search" />
+              </label>
+            </div>
+            <div className="gf-form">
+              <label className="gf-form-label">States</label>
+
+              <div className="gf-form-select-wrapper width-13">
+                <select className="gf-form-input" onChange={this.onStateFilterChanged} value={this.getStateFilter()}>
+                  {this.stateFilters.map(this.alertStateFilterOption)}
+                </select>
+              </div>
+            </div>
+            <div className="page-action-bar__spacer" />
+            <a className="btn btn-secondary" onClick={this.onOpenHowTo}>
+              <i className="fa fa-info-circle" /> How to add an alert
+            </a>
+          </div>
+          <section>
+            <ol className="alert-rule-list">
+              {alertRules.map(rule => (
+                <AlertRuleItem
+                  rule={rule}
+                  key={rule.id}
+                  search={search}
+                  onTogglePause={() => this.onTogglePause(rule)}
+                />
+              ))}
+            </ol>
+          </section>
+        </div>
+      </div>
+    );
+  }
+}
+
+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));

+ 2 - 2
public/app/features/alerting/alert_tab_ctrl.ts → 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';
 

+ 0 - 0
public/app/features/alerting/notification_edit_ctrl.ts → public/app/features/alerting/NotificationsEditCtrl.ts


+ 0 - 0
public/app/features/alerting/notifications_list_ctrl.ts → public/app/features/alerting/NotificationsListCtrl.ts


+ 12 - 30
public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap → 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`] = `
 <li
   className="alert-rule-item"
 >
   <span
-    className="alert-rule-item__icon alert-state-ok"
+    className="alert-rule-item__icon state class"
   >
     <i
-      className="icon-gf icon-gf-online"
+      className="icon"
     />
   </span>
   <div
@@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
         className="alert-rule-item__name"
       >
         <a
-          href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
+          href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
         >
           <Highlighter
             highlightClassName="highlight-search-match"
@@ -30,24 +30,15 @@ exports[`AlertRuleList should render 1 rule 1`] = `
                 "",
               ]
             }
-            textToHighlight="Panel Title alert"
-          >
-            <span>
-              <span
-                className=""
-                key="0"
-              >
-                Panel Title alert
-              </span>
-            </span>
-          </Highlighter>
+            textToHighlight="Some rule"
+          />
         </a>
       </div>
       <div
         className="alert-rule-item__text"
       >
         <span
-          className="alert-state-ok"
+          className="state class"
         >
           <Highlighter
             highlightClassName="highlight-search-match"
@@ -56,23 +47,14 @@ exports[`AlertRuleList should render 1 rule 1`] = `
                 "",
               ]
             }
-            textToHighlight="OK"
-          >
-            <span>
-              <span
-                className=""
-                key="0"
-              >
-                OK
-              </span>
-            </span>
-          </Highlighter>
+            textToHighlight="state text"
+          />
         </span>
         <span
           className="alert-rule-item__time"
         >
            for 
-          5 minutes
+          age
         </span>
       </div>
     </div>
@@ -82,7 +64,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
   >
     <button
       className="btn btn-small btn-inverse alert-list__btn width-2"
-      onClick={[Function]}
+      onClick={[MockFunction]}
       title="Pausing an alert rule prevents it from executing"
     >
       <i
@@ -91,7 +73,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
     </button>
     <a
       className="btn btn-small btn-inverse alert-list__btn width-2"
-      href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
+      href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
       title="Edit alert rule"
     >
       <i

+ 256 - 0
public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap

@@ -0,0 +1,256 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render alert rules 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div
+      className="page-action-bar"
+    >
+      <div
+        className="gf-form gf-form--grow"
+      >
+        <label
+          className="gf-form--has-input-icon gf-form--grow"
+        >
+          <input
+            className="gf-form-input"
+            onChange={[Function]}
+            placeholder="Search alerts"
+            type="text"
+            value=""
+          />
+          <i
+            className="gf-form-input-icon fa fa-search"
+          />
+        </label>
+      </div>
+      <div
+        className="gf-form"
+      >
+        <label
+          className="gf-form-label"
+        >
+          States
+        </label>
+        <div
+          className="gf-form-select-wrapper width-13"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="all"
+          >
+            <option
+              key="all"
+              value="all"
+            >
+              All
+            </option>
+            <option
+              key="ok"
+              value="ok"
+            >
+              OK
+            </option>
+            <option
+              key="not_ok"
+              value="not_ok"
+            >
+              Not OK
+            </option>
+            <option
+              key="alerting"
+              value="alerting"
+            >
+              Alerting
+            </option>
+            <option
+              key="no_data"
+              value="no_data"
+            >
+              No Data
+            </option>
+            <option
+              key="paused"
+              value="paused"
+            >
+              Paused
+            </option>
+          </select>
+        </div>
+      </div>
+      <div
+        className="page-action-bar__spacer"
+      />
+      <a
+        className="btn btn-secondary"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-info-circle"
+        />
+         How to add an alert
+      </a>
+    </div>
+    <section>
+      <ol
+        className="alert-rule-list"
+      >
+        <AlertRuleItem
+          key="1"
+          onTogglePause={[Function]}
+          rule={
+            Object {
+              "dashboardId": 7,
+              "dashboardSlug": "alerting-with-testdata",
+              "dashboardUid": "ggHbN42mk",
+              "evalData": Object {},
+              "evalDate": "0001-01-01T00:00:00Z",
+              "executionError": "",
+              "id": 1,
+              "name": "TestData - Always OK",
+              "newStateDate": "2018-09-04T10:01:01+02:00",
+              "panelId": 3,
+              "state": "ok",
+              "url": "/d/ggHbN42mk/alerting-with-testdata",
+            }
+          }
+          search=""
+        />
+        <AlertRuleItem
+          key="3"
+          onTogglePause={[Function]}
+          rule={
+            Object {
+              "dashboardId": 7,
+              "dashboardSlug": "alerting-with-testdata",
+              "dashboardUid": "ggHbN42mk",
+              "evalData": Object {},
+              "evalDate": "0001-01-01T00:00:00Z",
+              "executionError": "error",
+              "id": 3,
+              "name": "TestData - ok",
+              "newStateDate": "2018-09-04T10:01:01+02:00",
+              "panelId": 3,
+              "state": "ok",
+              "url": "/d/ggHbN42mk/alerting-with-testdata",
+            }
+          }
+          search=""
+        />
+      </ol>
+    </section>
+  </div>
+</div>
+`;
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div
+      className="page-action-bar"
+    >
+      <div
+        className="gf-form gf-form--grow"
+      >
+        <label
+          className="gf-form--has-input-icon gf-form--grow"
+        >
+          <input
+            className="gf-form-input"
+            onChange={[Function]}
+            placeholder="Search alerts"
+            type="text"
+            value=""
+          />
+          <i
+            className="gf-form-input-icon fa fa-search"
+          />
+        </label>
+      </div>
+      <div
+        className="gf-form"
+      >
+        <label
+          className="gf-form-label"
+        >
+          States
+        </label>
+        <div
+          className="gf-form-select-wrapper width-13"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="all"
+          >
+            <option
+              key="all"
+              value="all"
+            >
+              All
+            </option>
+            <option
+              key="ok"
+              value="ok"
+            >
+              OK
+            </option>
+            <option
+              key="not_ok"
+              value="not_ok"
+            >
+              Not OK
+            </option>
+            <option
+              key="alerting"
+              value="alerting"
+            >
+              Alerting
+            </option>
+            <option
+              key="no_data"
+              value="no_data"
+            >
+              No Data
+            </option>
+            <option
+              key="paused"
+              value="paused"
+            >
+              Paused
+            </option>
+          </select>
+        </div>
+      </div>
+      <div
+        className="page-action-bar__spacer"
+      />
+      <a
+        className="btn btn-secondary"
+        onClick={[Function]}
+      >
+        <i
+          className="fa fa-info-circle"
+        />
+         How to add an alert
+      </a>
+    </div>
+    <section>
+      <ol
+        className="alert-rule-list"
+      />
+    </section>
+  </div>
+</div>
+`;

+ 0 - 2
public/app/features/alerting/all.ts

@@ -1,2 +0,0 @@
-import './notifications_list_ctrl';
-import './notification_edit_ctrl';

+ 1 - 1
public/app/features/alerting/specs/threshold_mapper.test.ts → 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', () => {

+ 0 - 0
public/app/features/alerting/threshold_mapper.ts → public/app/features/alerting/state/ThresholdMapper.ts


+ 47 - 0
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<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function getAlertRulesAsync(options: { state: string }): ThunkResult<void> {
+  return async dispatch => {
+    const rules = await getBackendSrv().get('/api/alerts', options);
+    dispatch(loadAlertRules(rules));
+  };
+}
+
+export function togglePauseAlertRule(id: number, options: { paused: boolean }): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    await getBackendSrv().post(`/api/alerts/${id}/pause`, options);
+    const stateFilter = getState().location.query.state || 'all';
+    dispatch(getAlertRulesAsync({ state: stateFilter.toString() }));
+  };
+}

+ 0 - 0
public/app/features/alerting/alert_def.ts → public/app/features/alerting/state/alertDef.ts


+ 91 - 0
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);
+  });
+});

+ 50 - 0
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,
+};

+ 94 - 0
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);
+  });
+});

+ 9 - 0
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);
+  });
+};

+ 3 - 2
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';

+ 1 - 1
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) {

+ 1 - 1
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';

+ 1 - 1
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 };

+ 7 - 3
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 (
-    <Provider {...store}>
-      <Component {...props} />
-    </Provider>
+    <ReduxProvider store={reduxStore}>
+      <Provider {...store}>
+        <Component {...props} />
+      </Provider>
+    </ReduxProvider>
   );
 }
 

+ 2 - 2
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';

+ 0 - 66
public/app/stores/AlertListStore/AlertListStore.test.ts

@@ -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');
-  });
-});

+ 0 - 47
public/app/stores/AlertListStore/AlertListStore.ts

@@ -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;
-    },
-  }));

+ 0 - 34
public/app/stores/AlertListStore/AlertRule.ts

@@ -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 = '';
-    }),
-  }));

+ 0 - 13
public/app/stores/AlertListStore/helpers.ts

@@ -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', '');
-}

+ 0 - 12
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: [],

+ 0 - 10
public/app/stores/SearchStore/ResultItem.ts

@@ -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, ''),
-});

+ 0 - 27
public/app/stores/SearchStore/SearchResultSection.ts

@@ -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',
-          })
-        );
-      }
-    },
-  }));

+ 0 - 22
public/app/stores/SearchStore/SearchStore.ts

@@ -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: [],
-          })
-        );
-      }
-    },
-  }));

+ 0 - 6
public/app/stores/ServerStatsStore/ServerStat.ts

@@ -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),
-});

+ 0 - 24
public/app/stores/ServerStatsStore/ServerStatsStore.ts

@@ -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 }));
-    }),
-  }));

+ 23 - 0
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)));
+  }
+}

+ 96 - 0
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;
+}

+ 21 - 0
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 });

+ 32 - 0
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,
+  };
+}

+ 38 - 2
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"