Browse Source

Merge pull request #419 from smiclea/dashboard

Add Dashboard Home Page
Dorin Paslaru 6 years ago
parent
commit
aec14d5e03
61 changed files with 2377 additions and 148 deletions
  1. 4 1
      src/components/App.jsx
  2. 3 0
      src/components/atoms/Button/Button.jsx
  3. 25 2
      src/components/atoms/EndpointLogos/EndpointLogos.jsx
  4. 5 4
      src/components/atoms/EndpointLogos/images/Generic.jsx
  5. 4 0
      src/components/atoms/EndpointLogos/images/aws-32-white.svg
  6. 4 0
      src/components/atoms/EndpointLogos/images/azure-32-white.svg
  7. 5 0
      src/components/atoms/EndpointLogos/images/hyperv-32-white.svg
  8. 3 0
      src/components/atoms/EndpointLogos/images/oci-32-white.svg
  9. 3 0
      src/components/atoms/EndpointLogos/images/opc-32-white.svg
  10. 4 0
      src/components/atoms/EndpointLogos/images/openstack-32-white.svg
  11. 3 0
      src/components/atoms/EndpointLogos/images/oraclevm-32-white.svg
  12. 4 0
      src/components/atoms/EndpointLogos/images/scvmm-32-white.svg
  13. 4 0
      src/components/atoms/EndpointLogos/images/vmware-32-white.svg
  14. 10 1
      src/components/atoms/EndpointLogos/story.jsx
  15. BIN
      src/components/atoms/Fonts/Rubik-ExtraLight.woff
  16. 8 0
      src/components/atoms/Fonts/index.js
  17. 15 9
      src/components/atoms/Logo/Logo.jsx
  18. 0 30
      src/components/atoms/SearchButton/story.jsx
  19. 13 8
      src/components/atoms/StatusImage/StatusImage.jsx
  20. 24 0
      src/components/atoms/StatusImage/images/loading.js
  21. 0 23
      src/components/atoms/StatusImage/images/loading.svg
  22. 6 0
      src/components/atoms/StatusImage/story.jsx
  23. 20 2
      src/components/molecules/DropdownLink/DropdownLink.jsx
  24. 10 6
      src/components/molecules/NotificationDropdown/NotificationDropdown.jsx
  25. 222 0
      src/components/organisms/DashboardContent/DashboardContent.jsx
  26. 228 0
      src/components/organisms/DashboardContent/charts/BarChart/BarChart.jsx
  27. 68 0
      src/components/organisms/DashboardContent/charts/BarChart/NiceScale.js
  28. 6 0
      src/components/organisms/DashboardContent/charts/BarChart/package.json
  29. 208 0
      src/components/organisms/DashboardContent/charts/PieChart/PieChart.jsx
  30. 6 0
      src/components/organisms/DashboardContent/charts/PieChart/package.json
  31. 162 0
      src/components/organisms/DashboardContent/modules/ActivityModule/ActivityModule.jsx
  32. 24 0
      src/components/organisms/DashboardContent/modules/ActivityModule/images/replica.svg
  33. 6 0
      src/components/organisms/DashboardContent/modules/ActivityModule/package.json
  34. 341 0
      src/components/organisms/DashboardContent/modules/ExecutionsModule/ExecutionsModule.jsx
  35. 52 0
      src/components/organisms/DashboardContent/modules/ExecutionsModule/images/empty-background.svg
  36. 6 0
      src/components/organisms/DashboardContent/modules/ExecutionsModule/package.json
  37. 96 0
      src/components/organisms/DashboardContent/modules/InfoCountModule/InfoCountModule.jsx
  38. 6 0
      src/components/organisms/DashboardContent/modules/InfoCountModule/package.json
  39. 206 0
      src/components/organisms/DashboardContent/modules/LicenceModule/LicenceModule.jsx
  40. 6 0
      src/components/organisms/DashboardContent/modules/LicenceModule/package.json
  41. 288 0
      src/components/organisms/DashboardContent/modules/TopEndpointsModule/TopEndpointsModule.jsx
  42. 36 0
      src/components/organisms/DashboardContent/modules/TopEndpointsModule/images/endpoint.svg
  43. 6 0
      src/components/organisms/DashboardContent/modules/TopEndpointsModule/package.json
  44. 6 0
      src/components/organisms/DashboardContent/package.json
  45. 4 4
      src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx
  46. 0 24
      src/components/organisms/DetailsPageHeader/story.jsx
  47. 16 5
      src/components/organisms/Navigation/Navigation.jsx
  48. 16 8
      src/components/organisms/PageHeader/PageHeader.jsx
  49. 154 0
      src/components/pages/DashboardPage/DashboardPage.jsx
  50. 6 0
      src/components/pages/DashboardPage/package.json
  51. 1 1
      src/components/pages/LoginPage/LoginPage.jsx
  52. 6 4
      src/components/styleUtils/StyleProps.js
  53. 1 3
      src/components/templates/MainTemplate/MainTemplate.jsx
  54. 1 0
      src/constants.js
  55. 2 2
      src/sources/LincenceSource.js
  56. 1 1
      src/sources/UserSource.js
  57. 3 3
      src/stores/LicenceStore.js
  58. 4 1
      src/stores/NotificationStore.js
  59. 1 1
      src/stores/UserStore.js
  60. 3 3
      src/utils/ApiCaller.js
  61. 2 2
      src/utils/ObjectUtils.js

+ 4 - 1
src/components/App.jsx

@@ -36,6 +36,8 @@ import UsersPage from './pages/UsersPage'
 import UserDetailsPage from './pages/UserDetailsPage'
 import ProjectsPage from './pages/ProjectsPage'
 import ProjectDetailsPage from './pages/ProjectDetailsPage'
+import DashboardPage from './pages/DashboardPage'
+
 import Tooltip from './atoms/Tooltip/Tooltip'
 
 import { navigationMenu } from '../constants'
@@ -97,8 +99,9 @@ class App extends React.Component<{}, State> {
     return (
       <Wrapper>
         <Switch>
-          <Route path="/" component={LoginPage} exact />
+          <Route path="/" component={DashboardPage} exact />
           <Route path="/login" component={LoginPage} />
+          <Route path="/dashboard" component={DashboardPage} />
           <Route path="/replicas" component={ReplicasPage} />
           <Route path="/replica/:id" component={ReplicaDetailsPage} exact />
           <Route path="/replica/:page/:id" component={ReplicaDetailsPage} />

+ 3 - 0
src/components/atoms/Button/Button.jsx

@@ -22,6 +22,9 @@ import StyleProps from '../../styleUtils/StyleProps'
 
 const backgroundColor = (props) => {
   if (props.hollow) {
+    if (props.transparent) {
+      return 'transparent'
+    }
     return 'white'
   }
   if (props.secondary) {

+ 25 - 2
src/components/atoms/EndpointLogos/EndpointLogos.jsx

@@ -30,6 +30,16 @@ import oci32Image from './images/oci-32.svg'
 import hyperv32Image from './images/hyperv-32.svg'
 import scvmm32Image from './images/scvmm-32.svg'
 
+import aws32WhiteImage from './images/aws-32-white.svg'
+import azure32WhiteImage from './images/azure-32-white.svg'
+import opc32WhiteImage from './images/opc-32-white.svg'
+import openstack32WhiteImage from './images/openstack-32-white.svg'
+import oraclevm32WhiteImage from './images/oraclevm-32-white.svg'
+import vmware32WhiteImage from './images/vmware-32-white.svg'
+import oci32WhiteImage from './images/oci-32-white.svg'
+import hyperv32WhiteImage from './images/hyperv-32-white.svg'
+import scvmm32WhiteImage from './images/scvmm-32-white.svg'
+
 import aws42Image from './images/aws-42.svg'
 import azure42Image from './images/azure-42.svg'
 import opc42Image from './images/opc-42.svg'
@@ -73,6 +83,7 @@ import scvmm128DisabledImage from './images/scvmm-128-disabled.svg'
 const endpointImages = {
   azure: [
     { h: 32, image: azure32Image },
+    { h: 32, image: azure32WhiteImage, white: true },
     { h: 42, image: azure42Image },
     { h: 64, image: azure64Image },
     { h: 128, image: azure128Image },
@@ -80,6 +91,7 @@ const endpointImages = {
   ],
   openstack: [
     { h: 32, image: openstack32Image },
+    { h: 32, image: openstack32WhiteImage, white: true },
     { h: 42, image: openstack42Image },
     { h: 64, image: openstack64Image },
     { h: 128, image: openstack128Image },
@@ -87,6 +99,7 @@ const endpointImages = {
   ],
   opc: [
     { h: 32, image: opc32Image },
+    { h: 32, image: opc32WhiteImage, white: true },
     { h: 42, image: opc42Image },
     { h: 64, image: opc64Image },
     { h: 128, image: opc128Image },
@@ -94,6 +107,7 @@ const endpointImages = {
   ],
   oracle_vm: [
     { h: 32, image: oraclevm32Image },
+    { h: 32, image: oraclevm32WhiteImage, white: true },
     { h: 42, image: oraclevm42Image },
     { h: 64, image: oraclevm64Image },
     { h: 128, image: oraclevm128Image },
@@ -101,6 +115,7 @@ const endpointImages = {
   ],
   vmware_vsphere: [
     { h: 32, image: vmware32Image },
+    { h: 32, image: vmware32WhiteImage, white: true },
     { h: 42, image: vmware42Image },
     { h: 64, image: vmware64Image },
     { h: 128, image: vmware128Image },
@@ -108,6 +123,7 @@ const endpointImages = {
   ],
   aws: [
     { h: 32, image: aws32Image },
+    { h: 32, image: aws32WhiteImage, white: true },
     { h: 42, image: aws42Image },
     { h: 64, image: aws64Image },
     { h: 128, image: aws128Image },
@@ -115,6 +131,7 @@ const endpointImages = {
   ],
   oci: [
     { h: 32, image: oci32Image },
+    { h: 32, image: oci32WhiteImage, white: true },
     { h: 42, image: oci42Image },
     { h: 64, image: oci64Image },
     { h: 128, image: oci128Image },
@@ -122,6 +139,7 @@ const endpointImages = {
   ],
   'hyper-v': [
     { h: 32, image: hyperv32Image },
+    { h: 32, image: hyperv32WhiteImage, white: true },
     { h: 42, image: hyperv42Image },
     { h: 64, image: hyperv64Image },
     { h: 128, image: hyperv128Image },
@@ -129,6 +147,7 @@ const endpointImages = {
   ],
   scvmm: [
     { h: 32, image: scvmm32Image },
+    { h: 32, image: scvmm32WhiteImage, white: true },
     { h: 42, image: scvmm42Image },
     { h: 64, image: scvmm64Image },
     { h: 128, image: scvmm128Image },
@@ -155,6 +174,7 @@ type Props = {
   endpoint?: ?string,
   height: number,
   disabled?: boolean,
+  white?: boolean,
   'data-test-id'?: string,
 }
 @observer
@@ -167,7 +187,8 @@ class EndpointLogos extends React.Component<Props> {
     let imageInfo = null
 
     if (this.props.endpoint && endpointImages[this.props.endpoint]) {
-      imageInfo = endpointImages[this.props.endpoint].find(i => i.h === size.h && (!this.props.disabled || i.disabled === true))
+      imageInfo = endpointImages[this.props.endpoint].find(
+        i => i.h === size.h && (!this.props.disabled || i.disabled === true) && (!this.props.white || i.white === true))
     } else {
       return null
     }
@@ -192,6 +213,7 @@ class EndpointLogos extends React.Component<Props> {
         size={size}
         name={this.props.endpoint || ''}
         disabled={this.props.disabled}
+        white={this.props.white}
       />
     )
   }
@@ -206,7 +228,8 @@ class EndpointLogos extends React.Component<Props> {
     let imageInfo = null
 
     if (this.props.endpoint && endpointImages[this.props.endpoint]) {
-      imageInfo = endpointImages[this.props.endpoint].find(i => i.h === size.h && (!this.props.disabled || i.disabled === true))
+      imageInfo = endpointImages[this.props.endpoint].find(i => i.h === size.h && (!this.props.disabled || i.disabled === true)
+        && (!this.props.white || i.white === true))
     }
 
     return (

+ 5 - 4
src/components/atoms/EndpointLogos/images/Generic.jsx

@@ -40,15 +40,16 @@ const Logo = styled.div`
 
 type Props = {
   name: string,
-  size: {w: number, h: number},
+  size: { w: number, h: number },
   disabled: ?boolean,
+  white: ?boolean,
 }
 class Generic extends React.Component<Props> {
-  render32Generic() {
+  render32Generic(white: ?boolean) {
     return (
       <Wrapper style={{
         fontSize: '14px',
-        color: Palette.grayscale[4],
+        color: white ? 'white' : Palette.grayscale[4],
       }}
       >
         {this.props.name}
@@ -105,7 +106,7 @@ class Generic extends React.Component<Props> {
   render() {
     switch (this.props.size.h) {
       case 32:
-        return this.render32Generic()
+        return this.render32Generic(this.props.white)
       case 42:
         return this.render42Generic()
       case 64:

File diff suppressed because it is too large
+ 4 - 0
src/components/atoms/EndpointLogos/images/aws-32-white.svg


File diff suppressed because it is too large
+ 4 - 0
src/components/atoms/EndpointLogos/images/azure-32-white.svg


File diff suppressed because it is too large
+ 5 - 0
src/components/atoms/EndpointLogos/images/hyperv-32-white.svg


File diff suppressed because it is too large
+ 3 - 0
src/components/atoms/EndpointLogos/images/oci-32-white.svg


File diff suppressed because it is too large
+ 3 - 0
src/components/atoms/EndpointLogos/images/opc-32-white.svg


File diff suppressed because it is too large
+ 4 - 0
src/components/atoms/EndpointLogos/images/openstack-32-white.svg


File diff suppressed because it is too large
+ 3 - 0
src/components/atoms/EndpointLogos/images/oraclevm-32-white.svg


File diff suppressed because it is too large
+ 4 - 0
src/components/atoms/EndpointLogos/images/scvmm-32-white.svg


File diff suppressed because it is too large
+ 4 - 0
src/components/atoms/EndpointLogos/images/vmware-32-white.svg


+ 10 - 1
src/components/atoms/EndpointLogos/story.jsx

@@ -24,13 +24,14 @@ const Wrapper = styled.div`
   flex-wrap: wrap;
   margin-left: -32px;
   margin-top: -32px;
+  ${props => props.background ? `background: ${props.background};` : ''}
 
   > div {
     margin-left: 32px;
     margin-top: 32px;
   }
 `
-const wrap = (endpoint, height, disabled = false) => <EndpointLogos endpoint={endpoint} height={height} disabled={disabled} />
+const wrap = (endpoint, height, disabled = false, white = false) => <EndpointLogos endpoint={endpoint} height={height} disabled={disabled} white={white} />
 let providers = [
   'aws',
   'azure',
@@ -53,6 +54,14 @@ storiesOf('EndpointLogos', module)
       </Wrapper>
     )
   })
+  .add('32px - white', () => {
+    let height = 32
+    return (
+      <Wrapper background="#202134">
+        {providers.map(p => wrap(p, height, false, true))}
+      </Wrapper>
+    )
+  })
   .add('42px', () => {
     let height = 42
     return (

BIN
src/components/atoms/Fonts/Rubik-ExtraLight.woff


+ 8 - 0
src/components/atoms/Fonts/index.js

@@ -20,6 +20,7 @@ import RubikRegular from './Rubik-Regular.woff'
 import RubikItalic from './Rubik-Italic.woff'
 import RubikBold from './Rubik-Bold.woff'
 import RubikLight from './Rubik-Light.woff'
+import RubikExtraLight from './Rubik-ExtraLight.woff'
 import RubikLightItalic from './Rubik-LightItalic.woff'
 import RubikMedium from './Rubik-Medium.woff'
 import RubikMediumItalic from './Rubik-MediumItalic.woff'
@@ -73,6 +74,13 @@ const Fonts = css`
     font-weight: 500;
     font-style: italic;
   }
+
+  @font-face {
+    font-family: 'Rubik';
+    src: url('${RubikExtraLight}') format('woff');
+    font-weight: 200;
+    font-style: normal;
+  }
 `
 
 export default Fonts

+ 15 - 9
src/components/atoms/Logo/Logo.jsx

@@ -16,13 +16,13 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import React from 'react'
 import styled, { css } from 'styled-components'
+import { Link } from 'react-router-dom'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import coriolisLargeImage from './images/coriolis-large.svg'
 import coriolisSmallImage from './images/coriolis-small.svg'
 import coriolisSmallBlackImage from './images/coriolis-small-black.svg'
 
-
 const largeProps = css`
   width: 256px;
   height: 307px;
@@ -40,8 +40,7 @@ const smallblackProps = css`
   height: 48px;
   background: url('${coriolisSmallBlackImage}') center no-repeat;
 `
-
-const Wrapper = styled.a`
+const Wrapper = styled(Link)`
   transition: all ${StyleProps.animations.swift};
 `
 const Coriolis = styled.div`
@@ -58,14 +57,21 @@ type Props = {
   smallblack?: boolean,
   large?: boolean,
   customRef?: (ref: HTMLElement) => void,
+  to?: string,
+  className?: string,
 }
 
-const Logo = (props: Props) => {
-  return (
-    <Wrapper {...props} innerRef={ref => { if (props.customRef) props.customRef(ref) }}>
-      <Coriolis large={props.large} small={props.small} smallblack={props.smallblack} />
-    </Wrapper>
-  )
+class Logo extends React.Component<Props> {
+  render() {
+    let to = this.props.to || ''
+    return (
+      <div style={{ transition: `all ${StyleProps.animations.swift}` }} className={this.props.className} ref={ref => { this.props.customRef && ref && this.props.customRef(ref) }}>
+        <Wrapper to={to} >
+          <Coriolis large={this.props.large} small={this.props.small} smallblack={this.props.smallblack} />
+        </Wrapper>
+      </div>
+    )
+  }
 }
 
 export default Logo

+ 0 - 30
src/components/atoms/SearchButton/story.jsx

@@ -1,30 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-// @flow
-
-import React from 'react'
-import { storiesOf } from '@storybook/react'
-import SearchButton from '.'
-
-storiesOf('SearchButton', module)
-  .add('default', () => (
-    <SearchButton />
-  ))
-  .add('primary', () => (
-    <SearchButton primary />
-  ))
-  .add('filter icon', () => (
-    <SearchButton useFilterIcon />
-  ))

+ 13 - 8
src/components/atoms/StatusImage/StatusImage.jsx

@@ -22,17 +22,18 @@ import Palette from '../../styleUtils/Palette'
 
 import errorImage from './images/error'
 import successImage from './images/success'
-import loadingImage from './images/loading.svg'
+import loadingImage from './images/loading'
 import questionImage from './images/question'
 
 type Props = {
   status?: string,
   loading?: boolean,
   loadingProgress?: number,
+  size?: number,
 }
 const Wrapper = styled.div`
   position: relative;
-  ${StyleProps.exactSize('96px')}
+  ${props => StyleProps.exactSize(`${props.size}px`)}
   background-repeat: no-repeat;
   background-position: center;
 `
@@ -77,7 +78,7 @@ const dashAnimationStyle = css`
   }
 `
 const loadingAnimationStyle = css`
-  background: url(${loadingImage}) center no-repeat;
+  /* background: url(${loadingImage}) center no-repeat; */
   animation: rotate 1s linear infinite;
   @keyframes rotate {
     0% {transform: rotate(0deg);}
@@ -85,7 +86,7 @@ const loadingAnimationStyle = css`
   }
 `
 const Image = styled.div`
-  ${StyleProps.exactSize('96px')}
+  ${props => StyleProps.exactSize(`${props.size}px`)}
   ${props => props.cssStyle}
 `
 const Images = {
@@ -99,7 +100,7 @@ const Images = {
   },
   RUNNING: {
     style: loadingAnimationStyle,
-    image: '',
+    image: loadingImage,
   },
   QUESTION: {
     image: questionImage,
@@ -157,14 +158,18 @@ class StatusImage extends React.Component<Props> {
         status = 'PROGRESS'
       }
     }
-
+    let image = status !== 'PROGRESS' ? Images[status].image : null
+    if (image instanceof Function) {
+      image = image(this.props.size || 96)
+    }
     return (
-      <Wrapper>
+      <Wrapper size={this.props.size || 96}>
         {status !== 'PROGRESS' ? (
           <Image
             data-test-id="statusImage-image"
-            dangerouslySetInnerHTML={{ __html: Images[status].image }}
+            dangerouslySetInnerHTML={{ __html: image }}
             cssStyle={Images[status].style}
+            size={this.props.size || 96}
           />
         ) : null}
         {status === 'PROGRESS' ? this.renderProgressImage() : null}

+ 24 - 0
src/components/atoms/StatusImage/images/loading.js

@@ -0,0 +1,24 @@
+// @flow
+
+export default (size: number) => `<?xml version="1.0" encoding="UTF-8"?>
+<svg width="96px" height="96px" viewBox="0 0 ${96 * (96 / size)} ${96 * (96 / size)}" version="1.1" xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <g stroke-width="2">
+      <circle
+            r="47"
+            cx="48"
+            cy="48"
+            fill="transparent"
+            stroke="#C8CCD7"
+          />
+        <circle
+        r="47"
+            cx="48"
+            cy="48"
+            fill="transparent"
+            stroke="#0044CB"
+            stroke-dasharray="300 1000"
+            stroke-dashoffset="${300 - (25 * 3)}"
+        />
+    </g>
+</svg>`

+ 0 - 23
src/components/atoms/StatusImage/images/loading.svg

@@ -1,23 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="96px" height="96px" viewBox="0 0 96 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
-
-    <desc>Created with Sketch.</desc>
-    <defs>
-        <path d="M48,96 C21.490332,96 0,74.509668 0,48 C0,21.490332 21.490332,0 48,0 C74.509668,0 96,21.490332 96,48 C96,74.509668 74.509668,96 48,96 Z M48,94 C73.4050985,94 94,73.4050985 94,48 C94,22.5949015 73.4050985,2 48,2 C22.5949015,2 2,22.5949015 2,48 C2,73.4050985 22.5949015,94 48,94 Z" id="path-1"></path>
-    </defs>
-    <g id="Symbols" stroke="none" stroke-width="2" fill="none" fill-rule="evenodd">
-        <g id="Icon/Progress/96">
-            <g id="Group">
-                <mask id="mask-2" fill="white">
-                    <use xlink:href="#path-1"></use>
-                </mask>
-                <g id="Mask"></g>
-                <g id="Group-2" mask="url(#mask-2)">
-                    <circle id="Oval-2-Copy" fill="#C8CCD7" cx="48" cy="48" r="48"></circle>
-                    <path d="M96,48 C96,21.490332 74.509668,0 48,0 L48,48 L96,48 Z" id="Combined-Shape" fill="#0044CA"></path>
-                </g>
-            </g>
-        </g>
-    </g>
-</svg>

+ 6 - 0
src/components/atoms/StatusImage/story.jsx

@@ -44,6 +44,12 @@ storiesOf('StatusImage', module)
   .add('running', () => (
     <StatusImage status="RUNNING" />
   ))
+  .add('running - custom size', () => (
+    <StatusImage
+      status="RUNNING"
+      size={48}
+    />
+  ))
   .add('loading progress', () => (
     <StatusImage loading loadingProgress={45} />
   ))

+ 20 - 2
src/components/molecules/DropdownLink/DropdownLink.jsx

@@ -19,6 +19,7 @@ import { observer } from 'mobx-react'
 import styled, { css } from 'styled-components'
 import ReactDOM from 'react-dom'
 import autobind from 'autobind-decorator'
+import DomUtils from '../../../utils/DomUtils'
 
 import SearchInput from '../../molecules/SearchInput'
 
@@ -142,7 +143,6 @@ type Props = {
   style?: { [mixed]: any },
   labelStyle?: any,
   getLabel?: () => string,
-  required?: boolean,
 }
 type State = {
   showDropdownList: boolean,
@@ -160,6 +160,7 @@ class DropdownLink extends React.Component<Props, State> {
     searchText: '',
   }
 
+  scrollableParent: HTMLElement
   itemMouseDown: boolean
   labelRef: HTMLElement
   listItemsRef: HTMLElement
@@ -170,6 +171,11 @@ class DropdownLink extends React.Component<Props, State> {
 
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
+    if (this.arrowRef) {
+      this.scrollableParent = DomUtils.getScrollableParent(this.arrowRef)
+      this.scrollableParent.addEventListener('scroll', this.handleScroll)
+      window.addEventListener('resize', this.handleScroll)
+    }
     this.setLabelWidth()
   }
 
@@ -211,6 +217,17 @@ class DropdownLink extends React.Component<Props, State> {
     )
   }
 
+  @autobind
+  handleScroll() {
+    if (this.arrowRef) {
+      if (DomUtils.isElementInViewport(this.arrowRef, this.scrollableParent)) {
+        this.updateListPosition()
+      } else if (this.state.showDropdownList) {
+        this.setState({ showDropdownList: false })
+      }
+    }
+  }
+
   @autobind
   handlePageClick() {
     if (!this.itemMouseDown) {
@@ -224,6 +241,7 @@ class DropdownLink extends React.Component<Props, State> {
     }
 
     this.setState({ showDropdownList: !this.state.showDropdownList }, () => {
+      this.updateListPosition()
       this.scrollIntoView()
     })
   }
@@ -265,7 +283,7 @@ class DropdownLink extends React.Component<Props, State> {
     let arrowWidth = this.arrowRef.offsetWidth
     let arrowHeight = this.arrowRef.offsetHeight
     let tipHeight = this.tipRef.offsetHeight
-    const tipOffset = 7
+    const tipOffset = 9
     let arrowOffset = this.arrowRef.getBoundingClientRect()
 
     // If a modal is opened, body scroll is removed and body top is set to replicate scroll position

+ 10 - 6
src/components/molecules/NotificationDropdown/NotificationDropdown.jsx

@@ -116,16 +116,16 @@ const ListItem = styled(Link)`
     border-bottom-right-radius: ${StyleProps.borderRadius};
   }
 `
-const InfoColumn = styled.div`
+export const InfoColumn = styled.div`
   display: flex;
   flex-direction: column;
 `
-const BadgeColumn = styled.div`
+export const BadgeColumn = styled.div`
   display: flex;
   align-items: center;
   margin: 0 8px;
 `
-const MainItemInfo = styled.div`
+export const MainItemInfo = styled.div`
   display: flex;
   align-items: center;
   margin-right: -8px;
@@ -134,7 +134,7 @@ const MainItemInfo = styled.div`
     margin-right: 8px;
   }
 `
-const ItemReplicaBadge = styled.div`
+export const ItemReplicaBadge = styled.div`
   background: 'white';
   color: #7F8795;
   font-size: 9px;
@@ -147,8 +147,12 @@ const ItemReplicaBadge = styled.div`
   border-radius: 2px;
   border: 1px solid #7F8795;
 `
-const ItemTitle = styled.div``
-const ItemDescription = styled.div`
+export const ItemTitle = styled.div`
+  ${props => props.nowrap ? 'white-space: nowrap;' : ''}
+  overflow: hidden;
+  text-overflow: ellipsis;
+`
+export const ItemDescription = styled.div`
   color: ${Palette.grayscale[5]};
   font-size: 10px;
   margin-top: 8px;

+ 222 - 0
src/components/organisms/DashboardContent/DashboardContent.jsx

@@ -0,0 +1,222 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import autobind from 'autobind-decorator'
+
+import InfoCountModule from './modules/InfoCountModule'
+import LicenceModule from './modules/LicenceModule'
+import ActivityModule from './modules/ActivityModule'
+import TopEndpointsModule from './modules/TopEndpointsModule'
+import ExecutionsModule from './modules/ExecutionsModule'
+
+import Palette from '../../styleUtils/Palette'
+
+import type { MainItem } from '../../../types/MainItem'
+import type { Endpoint } from '../../../types/Endpoint'
+import type { Project } from '../../../types/Project'
+import type { User } from '../../../types/User'
+import type { Licence } from '../../../types/Licence'
+import type { NotificationItemData } from '../../../types/NotificationItem'
+
+const MIDDLE_WIDTHS = ['264px', '264px', '264px']
+
+const Wrapper = styled.div`
+  margin-bottom: 64px;
+`
+const RowLayout = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 40px;
+  margin-left: -32px;
+  & > div {
+    margin-top: 40px;
+    margin-left: 32px;
+  }
+`
+const MiddleMobileLayout = styled.div`
+  margin: 40px 0;
+`
+
+type Props = {
+  replicas: MainItem[],
+  migrations: MainItem[],
+  endpoints: Endpoint[],
+  projects: Project[],
+  replicasLoading: boolean,
+  migrationsLoading: boolean,
+  endpointsLoading: boolean,
+  usersLoading: boolean,
+  projectsLoading: boolean,
+  licenceLoading: boolean,
+  notificationItemsLoading: boolean,
+  users: User[],
+  licence: ?Licence,
+  notificationItems: NotificationItemData[],
+  isAdmin: boolean,
+  onNewReplicaClick: () => void,
+  onNewEndpointClick: () => void,
+}
+type State = {
+  useMobileLayout: boolean,
+  useLargeActivity: boolean,
+}
+@observer
+class DashboardContent extends React.Component<Props, State> {
+  state = {
+    useMobileLayout: false,
+    useLargeActivity: false,
+  }
+
+  componentWillMount() {
+    this.handleResize()
+    window.addEventListener('resize', this.handleResize)
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('resize', this.handleResize, false)
+  }
+
+  @autobind
+  handleResize() {
+    if (window.innerWidth < 1120 && !this.state.useMobileLayout) {
+      this.setState({ useMobileLayout: true })
+    } else if (window.innerWidth >= 1120 && this.state.useMobileLayout) {
+      this.setState({ useMobileLayout: false })
+    }
+    if (window.innerWidth >= 2100 && !this.state.useLargeActivity) {
+      this.setState({ useLargeActivity: true })
+    } else if (window.innerWidth < 2100 && this.state.useLargeActivity) {
+      this.setState({ useLargeActivity: false })
+    }
+  }
+
+  renderMiddleModules() {
+    let modules = [
+      <ActivityModule
+        large={this.state.useMobileLayout || this.state.useLargeActivity}
+        notificationItems={this.props.notificationItems}
+        loading={this.props.notificationItemsLoading}
+        style={this.state.useMobileLayout ? null : {
+          minWidth: MIDDLE_WIDTHS[1],
+          width: MIDDLE_WIDTHS[1],
+        }}
+        onNewClick={this.props.onNewReplicaClick}
+      />,
+      <TopEndpointsModule
+        replicas={this.props.replicas}
+        migrations={this.props.migrations}
+        endpoints={this.props.endpoints}
+        loading={this.props.replicasLoading || this.props.migrationsLoading || this.props.endpointsLoading}
+        style={{
+          minWidth: MIDDLE_WIDTHS[2],
+          width: MIDDLE_WIDTHS[2],
+        }}
+        onNewClick={this.props.onNewEndpointClick}
+      />,
+      <LicenceModule
+        licence={this.props.licence}
+        loading={this.props.licenceLoading}
+        style={{
+          minWidth: MIDDLE_WIDTHS[0],
+          width: MIDDLE_WIDTHS[0],
+        }}
+      />,
+    ]
+
+    if (this.state.useMobileLayout) {
+      return (
+        <MiddleMobileLayout>
+          {modules[0]}
+          <RowLayout>
+            {modules[1]}
+            {modules[2]}
+          </RowLayout>
+        </MiddleMobileLayout>
+      )
+    }
+
+    return (
+      <RowLayout>
+        {modules[0]}
+        {modules[1]}
+        {modules[2]}
+      </RowLayout>
+    )
+  }
+
+  render() {
+    let infoCountData = [
+      {
+        label: 'Replicas',
+        value: this.props.replicas.length,
+        color: Palette.alert,
+        link: '/replicas',
+        loading: this.props.replicasLoading,
+      },
+      {
+        label: 'Migrations',
+        value: this.props.migrations.length,
+        color: Palette.primary,
+        link: '/migrations',
+        loading: this.props.migrationsLoading,
+      },
+      {
+        label: 'Endpoints',
+        value: this.props.endpoints.length,
+        color: Palette.black,
+        link: '/endpoints',
+        loading: this.props.endpointsLoading,
+      },
+    ]
+
+    if (this.props.isAdmin) {
+      infoCountData = infoCountData.concat([
+        {
+          label: 'Users',
+          value: this.props.users.length,
+          color: Palette.grayscale[3],
+          link: '/users',
+          loading: this.props.usersLoading,
+        },
+        {
+          label: 'Projects',
+          value: this.props.projects.length,
+          color: Palette.grayscale[3],
+          link: '/projects',
+          loading: this.props.projectsLoading,
+        },
+      ])
+    }
+
+    return (
+      <Wrapper>
+        <InfoCountModule
+          data={infoCountData}
+        />
+        {this.renderMiddleModules()}
+        <ExecutionsModule
+          replicas={this.props.replicas}
+          loading={this.props.replicasLoading}
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default DashboardContent

+ 228 - 0
src/components/organisms/DashboardContent/charts/BarChart/BarChart.jsx

@@ -0,0 +1,228 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+import NiceScale from './NiceScale'
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+`
+const YAxis = styled.div`
+  height: calc(100% - 24px);
+  position: absolute;
+  bottom: 24px;
+  left: 16px;
+`
+const YTick = styled.div`
+  position: absolute;
+  top: ${props => 100 - props.bottom}%;
+  font-size: 9px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  width: 24px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-align: right;
+`
+const GridLines = styled.div`
+  width: calc(100% - 64px);
+  height: calc(100% - 24px);
+  position: absolute;
+  bottom: 19px;
+  left: 48px;
+`
+const GridLine = styled.div`
+  position: absolute;
+  bottom: ${props => props.bottom}%;
+  height: 1px;
+  width: 100%;
+  background: white;
+`
+const Bars = styled.div`
+  position: absolute;
+  display: flex;
+  height: calc(100% - 6px);
+  width: calc(100% - 64px);
+  justify-content: space-around;
+  left: 48px;
+  bottom: 2px;
+`
+const Bar = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`
+const StackedBars = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  justify-content: flex-end;
+`
+const StackedBar = styled.div`
+  width: 16px;
+  height: ${props => props.height}%;
+  background: ${props => props.background};
+  &:first-child {
+    border-top-left-radius: 3px;
+    border-top-right-radius: 3px;
+  }
+`
+const BarLabel = styled.div`
+  font-size: 9px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  margin-top: 8px;
+`
+type DataItem = {
+  label: string,
+  values: number[],
+  data?: any,
+}
+type Props = {
+  style?: any,
+  // eslint-disable-next-line react/no-unused-prop-types
+  data: DataItem[],
+  // eslint-disable-next-line react/no-unused-prop-types
+  yNumTicks: number,
+  colors?: string[],
+  onBarMouseEnter?: (position: { x: number, y: number }, item: DataItem) => void,
+  onBarMouseLeave?: () => void,
+}
+
+@observer
+class BarChart extends React.Component<Props> {
+  barsRef: HTMLElement
+
+  ticks: { value: number }[]
+  range: number = 1
+
+  componentWillMount() {
+    this.calculateYTicks(this.props)
+  }
+
+  componentWillReceiveProps(props: Props) {
+    this.calculateYTicks(props)
+  }
+
+  calculateYTicks(props: Props) {
+    this.range = props.data.reduce((max, item) => Math.max(max, item.values.reduce((sum, value) => sum + value, 0)), 1)
+    let niceScale = new NiceScale(0, this.range, props.yNumTicks)
+    this.ticks = []
+    let numTicks = Math.floor(this.range / niceScale.tickSpacing) + 1
+    for (let i = 0; i < numTicks; i += 1) {
+      this.ticks.push({ value: i * niceScale.tickSpacing })
+    }
+  }
+
+  calculatePosition(evt: MouseEvent): { x: number, y: number } {
+    let targetMouse: any = evt.currentTarget
+    let target: HTMLElement = targetMouse.parentElement
+    let height = 0
+    target.childNodes.forEach(node => {
+      let element: any = node
+      height += element.offsetHeight
+    })
+    return {
+      x: target.offsetLeft + 48,
+      y: height + 65,
+    }
+  }
+
+  renderYAxis() {
+    return (
+      <YAxis>
+        {this.ticks.map(tick => (
+          <YTick key={tick.value} bottom={(tick.value / this.range) * 100}>{tick.value}</YTick>
+        ))}
+      </YAxis>
+    )
+  }
+
+  renderGridLines() {
+    let gridLines = []
+    this.ticks.forEach((tick, i) => {
+      gridLines.push({ value: tick.value })
+      if (i === this.ticks.length - 1) {
+        return
+      }
+      gridLines.push({ value: (this.ticks[i + 1].value + tick.value) / 2 })
+    })
+    return (
+      <GridLines>
+        {gridLines.map(gridline => (
+          <GridLine key={gridline.value} bottom={(gridline.value / this.range) * 100} />
+        ))}
+      </GridLines>
+    )
+  }
+
+  renderBars() {
+    let availableWidth = window.innerWidth
+    if (this.barsRef) {
+      availableWidth = this.barsRef.offsetWidth
+    }
+    let items = this.props.data
+    if ((30 * items.length) > availableWidth) {
+      items = items.filter((_, i) => i % 2)
+    }
+
+    return (
+      <Bars innerRef={ref => { this.barsRef = ref }}>
+        {items.map(item => (
+          <Bar key={item.label}>
+            <StackedBars>
+              {[...item.values].reverse().map((value, i) => {
+                let height = (value / this.range) * 100
+                return height > 0 ? (
+                  <StackedBar
+                    key={`${item.label}-${i}`}
+                    background={this.props.colors ? this.props.colors[i % this.props.colors.length] : '#0044CA'}
+                    height={height}
+                    onMouseEnter={evt => {
+                      let onMouseEnter = this.props.onBarMouseEnter
+                      if (!onMouseEnter) {
+                        return
+                      }
+                      onMouseEnter(this.calculatePosition(evt), item)
+                    }}
+                    onMouseLeave={() => { this.props.onBarMouseLeave && this.props.onBarMouseLeave() }}
+                  />
+                ) : null
+              })}
+            </StackedBars>
+            <BarLabel>{item.label}</BarLabel>
+          </Bar>
+        ))}
+      </Bars>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style}>
+        {this.renderYAxis()}
+        {this.renderGridLines()}
+        {this.renderBars()}
+      </Wrapper>
+    )
+  }
+}
+
+export default BarChart

+ 68 - 0
src/components/organisms/DashboardContent/charts/BarChart/NiceScale.js

@@ -0,0 +1,68 @@
+// @flow
+
+class NiceScale {
+  minPoint: number
+  maxPoint: number
+  maxTicks: number = 10
+  tickSpacing: number
+  range: number
+  niceMinimum: number
+  niceMaximum: number
+
+  constructor(min: number, max: number, maxTicks: number = 10) {
+    this.minPoint = min
+    this.maxPoint = max
+    this.maxTicks = maxTicks
+    this.calculate()
+  }
+
+  calculate() {
+    this.range = this.niceNum(this.maxPoint - this.minPoint, false)
+    this.tickSpacing = this.niceNum(this.range / (this.maxTicks - 1), true)
+    this.niceMinimum = Math.floor(this.minPoint / this.tickSpacing) * this.tickSpacing
+    this.niceMaximum = Math.floor(this.maxPoint / this.tickSpacing) * this.tickSpacing
+  }
+
+  niceNum(localRange: number, round: boolean) {
+    let exponent /** exponent of localRange */
+    let fraction /** fractional part of localRange */
+    let niceFraction /** nice, rounded fraction */
+
+    exponent = Math.floor(Math.log10(localRange))
+    fraction = localRange / (10 ** exponent)
+    if (round) {
+      if (fraction < 1.5) {
+        niceFraction = 1
+      } else if (fraction < 3) {
+        niceFraction = 2
+      } else if (fraction < 7) {
+        niceFraction = 5
+      } else {
+        niceFraction = 10
+      }
+    } else if (fraction <= 1) {
+      niceFraction = 1
+    } else if (fraction <= 2) {
+      niceFraction = 2
+    } else if (fraction <= 5) {
+      niceFraction = 5
+    } else {
+      niceFraction = 10
+    }
+
+    return niceFraction * (10 ** exponent)
+  }
+
+  setMinMaxPoints(localMinPoint: number, localMaxPoint: number) {
+    this.minPoint = localMinPoint
+    this.maxPoint = localMaxPoint
+    this.calculate()
+  }
+
+  setMaxTicks(localMaxTicks: number) {
+    this.maxTicks = localMaxTicks
+    this.calculate()
+  }
+}
+
+export default NiceScale

+ 6 - 0
src/components/organisms/DashboardContent/charts/BarChart/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "BarChart",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./BarChart.jsx"
+}

+ 208 - 0
src/components/organisms/DashboardContent/charts/PieChart/PieChart.jsx

@@ -0,0 +1,208 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import autobind from 'autobind-decorator'
+
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+const Wrapper = styled.div`
+  position: relative;
+  display: flex;
+`
+const OuterShadow = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  ${props => StyleProps.exactSize(`${props.size}px`)}
+  border-radius: 50%;
+  ${StyleProps.boxShadow}
+  pointer-events: none;
+`
+const InnerShadow = styled.div`
+  position: absolute;
+  top: calc(50% - ${props => props.size}px);
+  left: calc(50% - ${props => props.size}px);
+  ${props => StyleProps.exactSize(`${props.size * 2}px`)}
+  border-radius: 50%;
+  box-shadow: inset rgba(0, 0, 0, 0.1) 0 0 6px 2px;
+  pointer-events: none;
+`
+const Canvas = styled.canvas``
+
+export type DataItem = { value: number, [string]: any }
+type Props = {
+  size: number,
+  data: any[],
+  holeStyle?: {
+    radius: number,
+    color: string,
+  },
+  colors: string[],
+  onMouseOver?: (item: DataItem, positionX: number, positionY: number) => void,
+  onMouseLeave?: () => void,
+  customRef?: (ref: HTMLElement) => void,
+}
+
+@observer
+class PieChart extends React.Component<Props> {
+  canvas: ?HTMLCanvasElement
+  angles: number[] = []
+  topData: DataItem[] = []
+  sum: number = 0
+
+  componentDidMount() {
+    this.drawChart()
+    let canvas = this.canvas
+    if (!canvas) {
+      return
+    }
+    canvas.addEventListener('mousemove', this.handleMouseMove)
+    canvas.addEventListener('mouseleave', this.handleMouseLeave)
+  }
+
+  componentWillReceiveProps() {
+    this.drawChart()
+  }
+
+  componentDidUpdate() {
+    this.drawChart()
+  }
+
+  componentWillUnmount() {
+    let canvas = this.canvas
+    if (!canvas) {
+      return
+    }
+    canvas.removeEventListener('mousemove', this.handleMouseMove)
+    canvas.removeEventListener('mouseleave', this.handleMouseLeave)
+  }
+
+  @autobind
+  handleMouseMove(evt: MouseEvent) {
+    let canvas = this.canvas
+    let onMouseOver = this.props.onMouseOver
+    if (!canvas || !onMouseOver) {
+      return
+    }
+    let mouseX = evt.offsetX
+    let mouseY = evt.offsetY
+    let item = this.detectHit(mouseX * 2, mouseY * 2)
+    if (item) {
+      onMouseOver(item, mouseX, mouseY)
+    } else if (this.props.onMouseLeave) {
+      this.props.onMouseLeave()
+    }
+  }
+
+  @autobind
+  handleMouseLeave() {
+    if (this.props.onMouseLeave) {
+      this.props.onMouseLeave()
+    }
+  }
+
+  drawChart() {
+    let canvas = this.canvas
+    if (!canvas) {
+      return
+    }
+    canvas.style.width = `${this.props.size}px`
+    canvas.style.height = `${this.props.size}px`
+
+    this.topData = this.props.data.sort((a, b) => b.value - a.value).slice(0, 6)
+    this.sum = this.topData.reduce((total, item) => total + item.value, 0)
+    if (this.sum === 0) {
+      this.angles = this.topData.map(() => Math.PI * ((1 / this.topData.length) * 2))
+    } else {
+      this.angles = this.topData.map(item => Math.PI * ((item.value / this.sum) * 2))
+    }
+    let halfSize = this.props.size / 2
+    let ctx = canvas.getContext('2d')
+    ctx.setTransform(1, 0, 0, 1, 0, 0)
+    ctx.clearRect(0, 0, this.props.size * 2, this.props.size * 2)
+    ctx.scale(2, 2)
+    let beginAngle = Math.PI
+    let endAngle = Math.PI
+    for (let i = 0; i < this.angles.length; i += 1) {
+      beginAngle = endAngle
+      endAngle += this.angles[i]
+
+      ctx.beginPath()
+      ctx.fillStyle = this.props.colors[i % this.props.colors.length]
+      ctx.moveTo(halfSize, halfSize)
+      ctx.arc(halfSize, halfSize, halfSize, beginAngle, endAngle)
+      ctx.fill()
+    }
+    let holeStyle = this.props.holeStyle
+    if (!holeStyle) {
+      return
+    }
+    ctx.beginPath()
+    ctx.fillStyle = holeStyle.color
+    ctx.moveTo(halfSize, halfSize)
+    ctx.arc(halfSize, halfSize, holeStyle.radius, 0, 2 * Math.PI)
+    ctx.fill()
+  }
+
+  detectHit(x: number, y: number): ?any {
+    let canvas = this.canvas
+    if (!canvas) {
+      return null
+    }
+
+    let halfSize = this.props.size / 2
+    let ctx = canvas.getContext('2d')
+    let holeStyle = this.props.holeStyle
+    if (holeStyle) {
+      ctx.beginPath()
+      ctx.moveTo(halfSize, halfSize)
+      ctx.arc(halfSize, halfSize, holeStyle.radius, 0, 2 * Math.PI)
+      if (ctx.isPointInPath(x, y)) {
+        return null
+      }
+    }
+
+    let beginAngle = Math.PI
+    let endAngle = Math.PI
+    for (let i = 0; i < this.angles.length; i += 1) {
+      beginAngle = endAngle
+      endAngle += this.angles[i]
+
+      ctx.beginPath()
+      ctx.moveTo(halfSize, halfSize)
+      ctx.arc(halfSize, halfSize, halfSize, beginAngle, endAngle)
+      if (ctx.isPointInPath(x, y)) {
+        return this.topData[i]
+      }
+    }
+    return null
+  }
+
+  render() {
+    return (
+      <Wrapper innerRef={ref => { this.props.customRef && this.props.customRef(ref) }}>
+        <Canvas width={this.props.size * 2} height={this.props.size * 2} innerRef={ref => { this.canvas = ref }} />
+        <OuterShadow size={this.props.size} />
+        {this.props.holeStyle ? <InnerShadow size={this.props.holeStyle.radius} /> : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default PieChart

+ 6 - 0
src/components/organisms/DashboardContent/charts/PieChart/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "PieChart",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./PieChart.jsx"
+}

+ 162 - 0
src/components/organisms/DashboardContent/modules/ActivityModule/ActivityModule.jsx

@@ -0,0 +1,162 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import { Link } from 'react-router-dom'
+
+import StatusIcon from '../../../../atoms/StatusIcon'
+import StatusImage from '../../../../atoms/StatusImage'
+import Button from '../../../../atoms/Button'
+import { InfoColumn, MainItemInfo, ItemReplicaBadge, ItemTitle, ItemDescription } from '../../../../molecules/NotificationDropdown'
+
+import Palette from '../../../../styleUtils/Palette'
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+import type { NotificationItemData } from '../../../../../types/NotificationItem'
+
+import replicaImage from './images/replica.svg'
+
+const Wrapper = styled.div`
+  flex-grow: 1;
+`
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+  margin-bottom: 12px;
+`
+const Module = styled.div`
+  background: ${Palette.grayscale[0]};
+  display: flex;
+  overflow: hidden;
+  border-radius: ${StyleProps.borderRadius};
+  height: 273px;
+`
+const LoadingWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow: hidden;
+`
+const List = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  flex-wrap: wrap;
+`
+const ListItem = styled(Link)`
+  padding: 8px 16px 8px 16px;
+  cursor: pointer;
+  text-decoration: none;
+  color: inherit;
+  display: block;
+  transition: all ${StyleProps.animations.swift};
+
+  &:hover {
+    background: ${Palette.grayscale[1]};
+  }
+`
+const NoItems = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+`
+const ReplicaImage = styled.div`
+  ${StyleProps.exactSize('148px')}
+  background: url('${replicaImage}') center no-repeat;
+`
+const Message = styled.div`
+  text-align: center;
+  margin-bottom: 32px;
+`
+
+type Props = {
+  notificationItems: NotificationItemData[],
+  style: any,
+  loading: boolean,
+  large: boolean,
+  onNewClick: () => void,
+}
+@observer
+class ActivityModule extends React.Component<Props> {
+  renderList() {
+    return (
+      <List>
+        {this.props.notificationItems.filter((_, i) => i < (this.props.large ? 10 : 5)).map((item, i) => {
+          let executionsHref = item.status === 'RUNNING' ? item.type === 'replica' ? '/executions' : item.type === 'migration' ? '/tasks' : '' : ''
+
+          return (
+            <ListItem
+              key={item.id}
+              to={`/${item.type}${executionsHref}/${item.id}`}
+              style={{
+                width: `calc(${this.props.large ? 50 : 100}% - 32px)`,
+                paddingTop: (i === 0 || i === 5) ? '16px' : '8px',
+              }}
+            >
+              <InfoColumn>
+                <MainItemInfo>
+                  <StatusIcon status={item.status} hollow />
+                  <ItemReplicaBadge
+                    type={item.type}
+                  >{item.type === 'replica' ? 'RE' : 'MI'}</ItemReplicaBadge>
+                  <ItemTitle nowrap>{item.name}</ItemTitle>
+                </MainItemInfo>
+                <ItemDescription>{item.description}</ItemDescription>
+              </InfoColumn>
+            </ListItem>
+          )
+        })}
+      </List>
+    )
+  }
+
+  renderNoItems() {
+    return (
+      <NoItems>
+        <ReplicaImage />
+        <Message>There is no recent activity<br />in this project.</Message>
+        <Button hollow primary transparent onClick={this.props.onNewClick}>New Replica / Migration</Button>
+      </NoItems>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage status="RUNNING" />
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style}>
+        <Title>Recent Activity</Title>
+        <Module>
+          {this.props.notificationItems.length === 0 && this.props.loading ? this.renderLoading() : this.props.notificationItems.length ?
+            this.renderList() : this.renderNoItems()}
+        </Module>
+      </Wrapper>
+    )
+  }
+}
+
+export default ActivityModule

File diff suppressed because it is too large
+ 24 - 0
src/components/organisms/DashboardContent/modules/ActivityModule/images/replica.svg


+ 6 - 0
src/components/organisms/DashboardContent/modules/ActivityModule/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "ActivityModule",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./ActivityModule.jsx"
+}

+ 341 - 0
src/components/organisms/DashboardContent/modules/ExecutionsModule/ExecutionsModule.jsx

@@ -0,0 +1,341 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import moment from 'moment'
+
+import StatusImage from '../../../../atoms/StatusImage'
+import DropdownLink from '../../../../molecules/DropdownLink'
+import BarChart from '../../charts/BarChart'
+
+import Palette from '../../../../styleUtils/Palette'
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+import type { MainItem } from '../../../../../types/MainItem'
+import type { Execution } from '../../../../../types/Execution'
+
+import emptyBackgroundImage from './images/empty-background.svg'
+
+const INTERVALS = [
+  { label: 'Last {x} days', value: '30-days' },
+  { label: 'Last 12 months', value: '1-years' },
+]
+
+const Wrapper = styled.div``
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+  margin-bottom: 12px;
+`
+const Module = styled.div`
+  position: relative;
+  display: flex;
+  background: ${Palette.grayscale[0]};
+  border-radius: ${StyleProps.borderRadius};
+  height: 240px;
+`
+const ChartWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  width: 100%;
+`
+const BarChartWrapper = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  justify-content: center;
+  align-items: center;
+`
+const DropdownWrapper = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  margin: 16px;
+`
+const Tooltip = styled.div`
+  position: absolute;
+  bottom: ${props => props.position.y}px;
+  left: ${props => props.position.x}px;
+  background: ${Palette.black};
+  padding: 8px 16px 16px 16px;
+  border-radius: ${StyleProps.borderRadius};
+  color: white;
+  ${StyleProps.exactWidth('174px')}
+  box-shadow: rgba(0,0,0,0.1) 0 0 6px 1px;
+`
+const TooltipHeader = styled.div`
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+  text-align: center;
+  border-bottom: 1px solid;
+  padding-bottom: 4px;
+`
+const TooltipBody = styled.div`
+  font-size: 12px;
+`
+const TooltipRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  margin-top: 16px;
+`
+const TooltipRowLabel = styled.div``
+const TooltipTip = styled.div`
+  position: absolute;
+  width: 16px;
+  height: 16px;
+  bottom: -8px;
+  background: ${Palette.black};
+  left: calc(50% - 16px);
+  transform: rotate(45deg);
+`
+const NoData = styled.div`
+  padding: 0 16px;
+  position: relative;
+`
+const NoDataMessage = styled.div`
+  position: absolute;
+  font-size: 17px;
+  color: ${Palette.grayscale[4]};
+  display: flex;
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+  justify-content: center;
+  align-items: center;
+  text-shadow: rgba(255,255,255,1) 0px 0px 20px;
+`
+const EmptyBackgroundImage = styled.div`
+  width: 100%;
+  height: 146px;
+  background: url('${emptyBackgroundImage}');
+`
+
+type Props = {
+  // eslint-disable-next-line react/no-unused-prop-types
+  replicas: MainItem[],
+  loading: boolean,
+}
+type GroupedData = {
+  label: string,
+  values: number[],
+  data?: string,
+}
+type TooltipData = {
+  title: string,
+  success: number,
+  failed: number,
+}
+type State = {
+  selectedPeriod: string,
+  groupedData: GroupedData[],
+  tooltipPosition: { x: number, y: number },
+  tooltipData: ?TooltipData,
+}
+const COLORS = ['#0044CA', '#2D74FF']
+
+@observer
+class ExecutionsModule extends React.Component<Props, State> {
+  state = {
+    selectedPeriod: INTERVALS[0].value,
+    groupedData: [],
+    tooltipData: null,
+    tooltipPosition: { x: 0, y: 0 },
+  }
+
+  componentWillMount() {
+    this.groupExecutions(this.props)
+  }
+
+  componentWillReceiveProps(props: Props) {
+    this.groupExecutions(props)
+  }
+
+  groupExecutions(props: Props) {
+    let executions: Execution[] = []
+    let replicas = props.replicas
+    replicas.forEach(replica => {
+      executions = [...executions, ...replica.executions]
+    })
+    let periodUnit = this.state.selectedPeriod.split('-')[1]
+    let periodValue = Number(this.state.selectedPeriod.split('-')[0])
+    let oldestDate: Date = moment().subtract(periodValue, periodUnit).toDate()
+    executions = executions.filter(e => new Date(e.updated_at || e.created_at).getTime() >= oldestDate.getTime())
+    executions.sort((a, b) => new Date(a.updated_at || a.created_at).getTime() -
+      new Date(b.updated_at || b.created_at).getTime())
+    this.groupByPeriod(executions, periodUnit)
+  }
+
+  groupByPeriod(executions: Execution[], periodUnit: string) {
+    let groupedData: GroupedData[] = []
+    let periods: { [period: string]: { success: number, failed: number } } = {}
+    executions.forEach(e => {
+      let date = moment(new Date(e.updated_at || e.created_at))
+      let period: string = periodUnit === 'days' ? date.format('DD-MMM-YYYY_DD MMMM') : date.format('MMM-YYYY_MMMM YYYY')
+      if (!periods[period]) {
+        periods[period] = { success: 0, failed: 0 }
+      }
+      if (e.status === 'COMPLETED') {
+        periods[period].success += 1
+      } else if (e.status === 'ERROR') {
+        periods[period].failed += 1
+      }
+    })
+    Object.keys(periods).forEach(period => {
+      if (!periods[period].success && !periods[period].failed) {
+        return
+      }
+      let label = period.split('_')[0]
+      let title = period.split('_')[1]
+      groupedData.push({
+        label: periodUnit === 'days' ? `${label.split('-')[0]} ${label.split('-')[1]}` : label.split('-')[0],
+        values: [periods[period].failed, periods[period].success],
+        data: title,
+      })
+    })
+    this.setState({ groupedData })
+  }
+
+  handleDropdownChange(selectedPeriod: string) {
+    this.setState({ selectedPeriod }, () => {
+      this.groupExecutions(this.props)
+    })
+  }
+
+  handleBarMouseEnter(position: { x: number, y: number }, item: GroupedData) {
+    this.setState({
+      tooltipPosition: { x: position.x - 86, y: position.y },
+      tooltipData: {
+        failed: item.values[0],
+        success: item.values[1],
+        title: item.data || '-',
+      },
+    })
+  }
+
+  handleBarMouseLeave() {
+    this.setState({ tooltipData: null })
+  }
+
+  renderDropdown() {
+    let items = INTERVALS.map(interval => {
+      return {
+        value: interval.value,
+        label: interval.label.replace('{x}', interval.value.split('-')[0]),
+      }
+    })
+    let selectedItem = INTERVALS.find(i => i.value === this.state.selectedPeriod)
+    return (
+      <DropdownWrapper>
+        <DropdownLink
+          items={items}
+          selectedItem={selectedItem && selectedItem.value}
+          onChange={item => { this.handleDropdownChange(item.value) }}
+        />
+      </DropdownWrapper>
+    )
+  }
+
+  renderTooltip() {
+    let data = this.state.tooltipData
+    if (!data) {
+      return null
+    }
+    return (
+      <Tooltip position={this.state.tooltipPosition}>
+        <TooltipHeader>{data.title}</TooltipHeader>
+        <TooltipBody>
+          <TooltipRow>
+            <TooltipRowLabel>Total Executions</TooltipRowLabel>
+            <TooltipRowLabel>{data.success + data.failed}</TooltipRowLabel>
+          </TooltipRow>
+          <TooltipRow>
+            <TooltipRowLabel>Successful</TooltipRowLabel>
+            <TooltipRowLabel>{data.success}</TooltipRowLabel>
+          </TooltipRow>
+          <TooltipRow>
+            <TooltipRowLabel>Failed</TooltipRowLabel>
+            <TooltipRowLabel>{data.failed}</TooltipRowLabel>
+          </TooltipRow>
+        </TooltipBody>
+        <TooltipTip />
+      </Tooltip>
+    )
+  }
+
+  renderBarChart() {
+    return (
+      <BarChartWrapper>
+        <BarChart
+          style={{ height: '164px' }}
+          yNumTicks={3}
+          data={this.state.groupedData}
+          colors={COLORS}
+          onBarMouseEnter={(position, item) => { this.handleBarMouseEnter(position, item) }}
+          onBarMouseLeave={() => { this.handleBarMouseLeave() }}
+        />
+        {this.renderTooltip()}
+      </BarChartWrapper>
+    )
+  }
+
+  renderChart() {
+    return (
+      <ChartWrapper>
+        {this.renderDropdown()}
+        {this.state.groupedData.length ? this.renderBarChart() : this.renderNoData()}
+      </ChartWrapper>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage status="RUNNING" />
+      </LoadingWrapper>
+    )
+  }
+
+  renderNoData() {
+    return (
+      <NoData>
+        <EmptyBackgroundImage />
+        <NoDataMessage>No recent activity in this project</NoDataMessage>
+      </NoData>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <Title>Replica Executions</Title>
+        <Module>
+          {this.props.replicas.length === 0 && this.props.loading ? this.renderLoading() : this.renderChart()}
+        </Module>
+      </Wrapper>
+    )
+  }
+}
+
+export default ExecutionsModule

+ 52 - 0
src/components/organisms/DashboardContent/modules/ExecutionsModule/images/empty-background.svg

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="879px" height="146px" viewBox="0 0 879 146" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 57.1 (83088) - https://sketch.com -->
+    <title>Group 6</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Migrations" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.183640253">
+        <g id="01-Dashboard/Start" transform="translate(-441.000000, -770.000000)">
+            <g id="Group-6" transform="translate(441.000000, 770.000000)">
+                <g id="Lines-Copy" transform="translate(0.000000, 0.000000)" fill="#FFFFFF">
+                    <rect id="Graph-Lines" x="-4.54747351e-13" y="145" width="879" height="1"></rect>
+                    <rect id="Graph-Lines" x="-4.54747351e-13" y="88" width="879" height="1"></rect>
+                    <rect id="Graph-Lines" x="-4.54747351e-13" y="116" width="879" height="1"></rect>
+                    <rect id="Graph-Lines" x="-4.54747351e-13" y="29" width="879" height="1"></rect>
+                    <rect id="Graph-Lines" x="-4.54747351e-13" y="0" width="879" height="1"></rect>
+                </g>
+                <g id="Graph-Copy-17" transform="translate(16.000000, 21.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99796811 C0,0.894520794 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.89211043 16,1.99796811 L16,124 L0,124 L0,1.99796811 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-18" transform="translate(96.000000, 52.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,2.00713962 C0,0.898627015 0.894513756,1.8189894e-12 1.99406028,1.8189894e-12 L14.0059397,1.8189894e-12 C15.1072288,1.8189894e-12 16,0.89063056 16,2.00713962 L16,93 L0,93 L0,2.00713962 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-19" transform="translate(176.000000, 41.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,2.00767737 C0,0.898867775 0.894513756,0 1.99406028,0 L14.0059397,0 C15.1072288,0 16,0.899085999 16,2.00767737 L16,104 L0,104 L0,2.00767737 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-20" transform="translate(256.000000, 3.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,2.00735664 C0,0.898724182 0.894513756,0 1.99406028,0 L14.0059397,0 C15.1072288,0 16,0.8882895 16,2.00735664 L16,142 L0,142 L0,2.00735664 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-21" transform="translate(336.000000, 37.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99010877 C0,0.891002048 0.894513756,0 1.99406028,0 L14.0059397,0 C15.1072288,0 16,0.901227258 16,1.99010877 L16,108 L0,108 L0,1.99010877 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-22" transform="translate(416.000000, 21.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99796811 C0,0.894520794 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.89211043 16,1.99796811 L16,124 L0,124 L0,1.99796811 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-23" transform="translate(496.000000, 21.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99796811 C0,0.894520794 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.89211043 16,1.99796811 L16,124 L0,124 L0,1.99796811 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-24" transform="translate(576.000000, 54.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,2.00671997 C0,0.898439133 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.900658965 16,2.00671997 L16,91 L0,91 L0,2.00671997 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-25" transform="translate(656.000000, 70.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,2.00606883 C0,0.898147606 0.894513756,0 1.99406028,0 L14.0059397,0 C15.1072288,0 16,0.89453347 16,2.00606883 L16,75 L0,75 L0,2.00606883 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-26" transform="translate(736.000000, 21.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99796811 C0,0.894520794 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.89211043 16,1.99796811 L16,124 L0,124 L0,1.99796811 Z" id="Income"></path>
+                </g>
+                <g id="Graph-Copy-27" transform="translate(816.000000, 33.000000)" fill="#777A8B" opacity="0.36546689">
+                    <path d="M0,1.99150398 C0,0.891626704 0.894513756,3.63797881e-12 1.99406028,3.63797881e-12 L14.0059397,3.63797881e-12 C15.1072288,3.63797881e-12 16,0.901537657 16,1.99150398 L16,112 L0,112 L0,1.99150398 Z" id="Income"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 6 - 0
src/components/organisms/DashboardContent/modules/ExecutionsModule/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "ExecutionsModule",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./ExecutionsModule.jsx"
+}

+ 96 - 0
src/components/organisms/DashboardContent/modules/InfoCountModule/InfoCountModule.jsx

@@ -0,0 +1,96 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import { Link } from 'react-router-dom'
+
+import StatusImage from '../../../../atoms/StatusImage'
+
+import Palette from '../../../../styleUtils/Palette'
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+const Wrapper = styled.div`
+  background: ${Palette.grayscale[0]};
+  display: flex;
+  overflow: auto;
+  border-radius: ${StyleProps.borderRadius};
+`
+const CountBlock = styled.div`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0;
+  padding: 0 16px;
+  border-left: 1px solid white;
+  height: 96px;
+  justify-content: center;
+  &:first-child {
+    border-left: 1px solid ${Palette.grayscale[0]};
+  }
+
+  @media (max-width: 832px) {
+    align-items: flex-start;
+  }
+`
+const LoadingWrapper = styled.div`
+  overflow: hidden;
+  margin-bottom: 16px;
+`
+const CountBlockValue = styled(Link)`
+  font-size: 53px;
+  font-weight: ${StyleProps.fontWeights.extraLight};
+  text-decoration: none;
+  color: inherit;
+`
+const CountBlockLabel = styled(Link)`
+  font-size: 12px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  text-transform: uppercase;
+  color: ${props => props.color};
+  text-decoration: none;
+`
+type Props = {
+  data: {
+    label: string,
+    value: number,
+    color: string,
+    link: string,
+    loading: boolean,
+  }[],
+}
+@observer
+class InfoCountModule extends React.Component<Props> {
+  render() {
+    return (
+      <Wrapper>
+        {this.props.data.map(item => (
+          <CountBlock key={item.label}>
+            {
+              !item.value && item.loading ? <LoadingWrapper><StatusImage status="RUNNING" size={48} /></LoadingWrapper>
+                : <CountBlockValue to={item.link}>{item.value}</CountBlockValue>
+            }
+            <CountBlockLabel color={item.color} to={item.link}>{item.label}</CountBlockLabel>
+          </CountBlock>
+        ))}
+      </Wrapper>
+    )
+  }
+}
+
+export default InfoCountModule

+ 6 - 0
src/components/organisms/DashboardContent/modules/InfoCountModule/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "InfoCountModule",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./InfoCountModule.jsx"
+}

+ 206 - 0
src/components/organisms/DashboardContent/modules/LicenceModule/LicenceModule.jsx

@@ -0,0 +1,206 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import moment from 'moment'
+
+import StatusImage from '../../../../atoms/StatusImage'
+
+import Palette from '../../../../styleUtils/Palette'
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+import type { Licence } from '../../../../../types/Licence'
+
+const Wrapper = styled.div`
+  flex-grow: 1;
+`
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+  margin-bottom: 12px;
+`
+const Module = styled.div`
+  background: ${Palette.grayscale[0]};
+  display: flex;
+  overflow: auto;
+  border-radius: ${StyleProps.borderRadius};
+  padding: 24px 16px 16px 16px;
+  height: 232px;
+`
+const LicenceInfo = styled.div``
+const NoLicence = styled.div``
+const TopInfo = styled.div`
+  display: flex;
+`
+const TopInfoText = styled.div`
+  flex-grow: 1;
+  margin-top: 8px;
+`
+const TopInfoDate = styled.div`
+  ${StyleProps.exactWidth('76px')}
+  ${StyleProps.exactHeight('80px')}
+  display: flex;
+  flex-direction: column;
+  margin-left: 24px;
+  ${StyleProps.boxShadow}
+  border-radius: ${StyleProps.borderRadius};
+  overflow: hidden;
+`
+const TopInfoDateTop = styled.div`
+  width: 100%;
+  height: 27px;
+  background: linear-gradient(#007AE7, #0044CA);
+  color: white;
+  text-align: center;
+  line-height: 27px;
+`
+const TopInfoDateBottom = styled.div`
+  background: white;
+  flex-grow: 1;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: ${Palette.primary};
+  font-size: 37px;
+  font-weight: ${StyleProps.fontWeights.extraLight};
+`
+const Charts = styled.div`
+  margin-top: 32px;
+`
+const Chart = styled.div`
+  margin-top: 32px;
+  &:first-child {
+    margin-top: 0;
+  }
+`
+const ChartHeader = styled.div`
+  display: flex;
+  justify-content: space-between;
+`
+const ChartHeaderCurrent = styled.div``
+const ChartHeaderTotal = styled.div`
+  color: ${Palette.grayscale[4]};
+`
+const ChartBodyWrapper = styled.div`
+  height: 8px;
+  background: ${Palette.grayscale[2]};
+  border-radius: ${StyleProps.borderRadius};
+  margin-top: 4px;
+  overflow: hidden;
+`
+const ChartBody = styled.div`
+  width: ${props => props.width}%;
+  background: ${props => props.color};
+  height: 100%;
+`
+const LoadingWrapper = styled.div`
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+`
+
+type Props = {
+  licence: ?Licence,
+  loading: boolean,
+  style: any,
+}
+@observer
+class LicenceModule extends React.Component<Props> {
+  renderLicenceStatusText(info: Licence): React.Node {
+    let currentPeriod = moment(info.currentPeriodEnd)
+    let days = currentPeriod.diff(new Date(), 'days')
+    let graphData = [{
+      color: Palette.alert,
+      current: info.performedReplicas,
+      total: info.totalReplicas,
+      label: 'Replicas',
+    }, {
+      color: Palette.primary,
+      current: info.performedMigrations,
+      total: info.totalMigations,
+      label: 'Migrations',
+    }]
+    return (
+      <LicenceInfo>
+        <TopInfo>
+          <TopInfoText>
+            Coriolis® Licence is active until&nbsp;
+            {currentPeriod.format('DD MMM YYYY')}
+            &nbsp;({days} days from now).
+          </TopInfoText>
+          <TopInfoDate>
+            <TopInfoDateTop>{currentPeriod.format('MMM')} &#39;{currentPeriod.format('YY')}</TopInfoDateTop>
+            <TopInfoDateBottom>{currentPeriod.format('DD')}</TopInfoDateBottom>
+          </TopInfoDate>
+        </TopInfo>
+        <Charts>
+          {graphData.map(data => {
+            return (
+              <Chart key={data.label}>
+                <ChartHeader>
+                  <ChartHeaderCurrent>{data.current} {data.label}</ChartHeaderCurrent>
+                  <ChartHeaderTotal>{data.total}</ChartHeaderTotal>
+                </ChartHeader>
+                <ChartBodyWrapper>
+                  <ChartBody color={data.color} width={(data.current / data.total) * 100} />
+                </ChartBodyWrapper>
+              </Chart>
+            )
+          })}
+        </Charts>
+      </LicenceInfo>
+    )
+  }
+
+  renderNoLicence() {
+    return (
+      <NoLicence>Please contact Cloudbase Solutions with your Appliance ID in order to obtain a Coriolis® licence.</NoLicence>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage status="RUNNING" />
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    let licence = this.props.licence
+    let days: ?number = null
+    if (licence) {
+      let currentPeriod = moment(licence.currentPeriodEnd)
+      days = currentPeriod.diff(new Date(), 'days')
+    }
+    return licence || this.props.loading ? (
+      <Wrapper style={this.props.style}>
+        <Title>Licence</Title>
+        <Module>
+          {licence ? days && days > 0 ? this.renderLicenceStatusText(licence) :
+            this.renderNoLicence() : this.props.loading ? this.renderLoading() : null}
+        </Module>
+      </Wrapper>
+    ) : null
+  }
+}
+
+export default LicenceModule

+ 6 - 0
src/components/organisms/DashboardContent/modules/LicenceModule/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "LicenceModule",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./LicenceModule.jsx"
+}

+ 288 - 0
src/components/organisms/DashboardContent/modules/TopEndpointsModule/TopEndpointsModule.jsx

@@ -0,0 +1,288 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+import { Link } from 'react-router-dom'
+
+import Button from '../../../../atoms/Button'
+import StatusImage from '../../../../atoms/StatusImage'
+import EndpointLogos from '../../../../atoms/EndpointLogos'
+import PieChart from '../../charts/PieChart'
+
+import Palette from '../../../../styleUtils/Palette'
+import StyleProps from '../../../../styleUtils/StyleProps'
+
+import type { MainItem } from '../../../../../types/MainItem'
+import type { Endpoint } from '../../../../../types/Endpoint'
+
+import endpointImage from './images/endpoint.svg'
+
+const Wrapper = styled.div`
+  flex-grow: 1;
+`
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: ${StyleProps.fontWeights.light};
+  margin-bottom: 12px;
+`
+const Module = styled.div`
+  background: ${Palette.grayscale[0]};
+  border-radius: ${StyleProps.borderRadius};
+  height: 224px;
+  padding: 32px 16px 16px 16px;
+`
+const ChartWrapper = styled.div`
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  height: 100%;
+`
+const LoadingWrapper = styled.div`
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+`
+const Tooltip = styled.div`
+  position: absolute;
+  width: 208px;
+  overflow: hidden;
+  border-radius: ${StyleProps.borderRadius};
+  box-shadow: rgba(0,0,0,0.1) 0 0 6px 1px;
+`
+const TooltipHeader = styled.div`
+  background: ${Palette.grayscale[3]};
+  height: 24px;
+  display: flex;
+  align-items: center;
+  color: white;
+  padding: 0 14px;
+`
+const TooltipBody = styled.div`
+  background: ${Palette.black};
+  height: 54px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+`
+const TooltipRows = styled.div`
+  color: white;
+  font-size: 10px;
+`
+const TooltipRow = styled.div``
+const Legend = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  margin-left: -8px;
+  width: 100%;
+`
+const LegendItem = styled.div`
+  display: flex;
+  margin-top: 24px;
+  margin-left: 8px;
+  width: calc(33% - 8px);
+`
+const LegendBullet = styled.div`
+  ${StyleProps.exactSize('8px')}
+  border: 2px solid ${props => props.color};
+  border-radius: 50%;
+`
+const LegendLabel = styled(Link)`
+  display: block;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  margin-left: 6px;
+  text-decoration: none;
+  color: inherit;
+`
+const NoItems = styled.div`
+  margin-top: -32px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+`
+const EndpointImage = styled.div`
+  ${StyleProps.exactSize('148px')}
+  background: url('${endpointImage}') center no-repeat;
+`
+const Message = styled.div`
+  text-align: center;
+  margin-bottom: 32px;
+`
+
+type GroupedEndpoint = {
+  endpoint: Endpoint,
+  replicasCount: number,
+  migrationsCount: number,
+  value: number,
+}
+type Props = {
+  // eslint-disable-next-line react/no-unused-prop-types
+  replicas: MainItem[],
+  // eslint-disable-next-line react/no-unused-prop-types
+  migrations: MainItem[],
+  // eslint-disable-next-line react/no-unused-prop-types
+  endpoints: Endpoint[],
+  style: any,
+  loading: boolean,
+  onNewClick: () => void,
+}
+type State = {
+  tooltipPosition: { x: number, y: number },
+  groupedEndpoint: ?GroupedEndpoint,
+  groupedEndpoints: GroupedEndpoint[],
+}
+const COLORS = ['#280E4C', '#FF2D55', '#FDC02F', '#0044CA', '#39DA55', '#A4AAB5']
+@observer
+class TopEndpointsModule extends React.Component<Props, State> {
+  state = {
+    tooltipPosition: { x: 0, y: 0 },
+    groupedEndpoint: null,
+    groupedEndpoints: [],
+  }
+
+  chartRef: HTMLElement
+
+  componentWillMount() {
+    this.calculateGroupedEndpoints(this.props)
+  }
+
+  componentWillReceiveProps(props: Props) {
+    this.calculateGroupedEndpoints(props)
+  }
+
+  calculateGroupedEndpoints(props: Props) {
+    let groupedEndpoints: GroupedEndpoint[] = []
+    let count = (mainItems: MainItem[], endpointId: string) => mainItems.filter(r =>
+      r.destination_endpoint_id === endpointId || r.origin_endpoint_id === endpointId).length
+
+    props.endpoints.forEach(endpoint => {
+      let replicasCount = count(props.replicas, endpoint.id)
+      let migrationsCount = count(props.migrations, endpoint.id)
+      groupedEndpoints.push({ endpoint, replicasCount, migrationsCount, value: replicasCount + migrationsCount })
+    })
+    this.setState({ groupedEndpoints })
+  }
+
+  handleMouseOver(item: GroupedEndpoint, x: number, y: number) {
+    let canvasCoord = { x, y }
+    let chartBaseCoord = { x: this.chartRef.offsetLeft, y: this.chartRef.offsetTop }
+    let offset = { x: x > 70 ? -224 : 16, y: -32 }
+    let tooltipPosition = { x: canvasCoord.x + chartBaseCoord.x + offset.x, y: canvasCoord.y + chartBaseCoord.y + offset.y }
+    this.setState({
+      groupedEndpoint: item,
+      tooltipPosition,
+    })
+  }
+
+  handleMouseLeave() {
+    this.setState({ groupedEndpoint: null })
+  }
+
+  renderLegend() {
+    let topData = this.state.groupedEndpoints.sort((a, b) => b.value - a.value).slice(0, 6)
+    return (
+      <Legend>
+        {topData.map((item, i) => (
+          <LegendItem key={item.endpoint.id}>
+            <LegendBullet color={COLORS[i % COLORS.length]} />
+            <LegendLabel to={`/endpoint/${item.endpoint.id}`}>{item.endpoint.name}</LegendLabel>
+          </LegendItem>
+        ))}
+      </Legend>
+    )
+  }
+
+  renderTooltip() {
+    let groupedEndpoint = this.state.groupedEndpoint
+    if (!groupedEndpoint) {
+      return null
+    }
+
+    return (
+      <Tooltip style={{ top: this.state.tooltipPosition.y, left: this.state.tooltipPosition.x }}>
+        <TooltipHeader>
+          {groupedEndpoint.endpoint.name}
+        </TooltipHeader>
+        <TooltipBody>
+          <EndpointLogos white endpoint={groupedEndpoint.endpoint.type} height={32} />
+          <TooltipRows>
+            <TooltipRow>{groupedEndpoint.replicasCount} Replicas</TooltipRow>
+            <TooltipRow>{groupedEndpoint.migrationsCount} Migrations</TooltipRow>
+            <TooltipRow>{groupedEndpoint.value} Total</TooltipRow>
+          </TooltipRows>
+        </TooltipBody>
+      </Tooltip>
+    )
+  }
+
+  renderChart() {
+    return (
+      <ChartWrapper>
+        <PieChart
+          customRef={ref => { this.chartRef = ref }}
+          size={144}
+          data={this.state.groupedEndpoints}
+          colors={COLORS}
+          holeStyle={{ radius: 57, color: Palette.grayscale[0] }}
+          onMouseOver={(item, x, y) => { this.handleMouseOver(item, x, y) }}
+          onMouseLeave={() => { this.handleMouseLeave() }}
+        />
+        {this.renderLegend()}
+        {this.renderTooltip()}
+      </ChartWrapper>
+    )
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage status="RUNNING" />
+      </LoadingWrapper>
+    )
+  }
+
+  renderNoData() {
+    return (
+      <NoItems>
+        <EndpointImage />
+        <Message>There are no Cloud Endpoints<br />in this project.</Message>
+        <Button hollow primary transparent onClick={this.props.onNewClick}>New Endpoint</Button>
+      </NoItems>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style}>
+        <Title>Top Endpoints</Title>
+        <Module>
+          {this.props.loading && this.props.endpoints.length === 0 ? this.renderLoading() : this.props.endpoints.length ? this.renderChart() : this.renderNoData()}
+        </Module>
+      </Wrapper>
+    )
+  }
+}
+
+export default TopEndpointsModule

+ 36 - 0
src/components/organisms/DashboardContent/modules/TopEndpointsModule/images/endpoint.svg

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="106px" height="106px" viewBox="0 0 106 106" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 57.1 (83088) - https://sketch.com -->
+    <title>Group</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M40,80 C62.09139,80 80,62.09139 80,40 C80,17.90861 62.09139,0 40,0 C17.90861,0 0,17.90861 0,40 C0,62.09139 17.90861,80 40,80 Z" id="path-1"></path>
+        <filter x="-25.6%" y="-23.1%" width="151.2%" height="151.2%" filterUnits="objectBoundingBox" id="filter-2">
+            <feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
+            <feGaussianBlur stdDeviation="6.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
+            <feColorMatrix values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.0753114073 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
+        </filter>
+    </defs>
+    <g id="Migrations" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="01-Dashboard/Start" transform="translate(-835.000000, -390.000000)">
+            <g id="Group-7" transform="translate(400.000000, 320.000000)">
+                <g id="Widget-Empty" transform="translate(336.000000, 1.000000)">
+                    <g id="Group" transform="translate(112.000000, 80.000000)">
+                        <g id="Pat-Benetar">
+                            <use fill="black" fill-opacity="1" filter="url(#filter-2)" xlink:href="#path-1"></use>
+                            <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
+                        </g>
+                        <g id="Icon/Endpoint/Item-80" stroke="#0044CA" stroke-linecap="round" stroke-linejoin="round">
+                            <path d="M34.25,17.5 C26.82215,17.5 20.87435,23.6937517 20.5581,31.4389715 C15.1784,32.9855913 11.25,38.1017999 11.25,44.1670707 C11.25,51.4941214 16.94825,57.5 23.9,57.5 L58.4,57.5 C64.08675,57.5 68.75,52.5849974 68.75,46.5912397 C68.75,41.8641102 65.76,37.8545347 61.7419,36.43882 C61.4981,29.3323687 55.9988,23.5604224 49.2,23.5604224 C48.00515,23.5604224 46.8931,23.870716 45.78565,24.2052514 C43.3143,20.2417351 39.09955,17.5 34.25,17.5 L34.25,17.5 Z" id="Path" stroke-width="1.5" transform="translate(40.000000, 37.500000) scale(-1, 1) translate(-40.000000, -37.500000) "></path>
+                            <g id="Group" stroke-width="1" fill-rule="evenodd" transform="translate(26.200000, 36.928571)">
+                                <path d="M9.55284091,5.66502463 L19.613299,5.66502463" id="Stroke-7" stroke-width="1.5"></path>
+                                <path d="M11.9418679,8.59344737 C11.1340476,9.89591322 9.76275019,10.7524631 8.20741226,10.7524631 L4.70790194,10.7524631 C2.22335533,10.7524631 0.209090909,8.56624181 0.209090909,5.86864119 L0.209090909,5.46183316 C0.209090909,2.76423253 2.22335533,0.577586207 4.70790194,0.577586207 L8.20741226,0.577586207 C9.76157546,0.577586207 11.1313066,1.43243569 11.9395184,2.7332012" id="Stroke-9" stroke-width="1.5"></path>
+                                <path d="M29.0897727,8.59344737 C28.2819525,9.89591322 26.910655,10.7524631 25.3553171,10.7524631 L21.8558068,10.7524631 C19.3712602,10.7524631 17.3569957,8.56624181 17.3569957,5.86864119 L17.3569957,5.46183316 C17.3569957,2.76423253 19.3712602,0.577586207 21.8558068,0.577586207 L25.3553171,0.577586207 C26.9094803,0.577586207 28.2792114,1.43243569 29.0874233,2.7332012" id="Stroke-9-Copy" stroke-width="1.5" transform="translate(23.223384, 5.665025) scale(-1, 1) translate(-23.223384, -5.665025) "></path>
+                            </g>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 6 - 0
src/components/organisms/DashboardContent/modules/TopEndpointsModule/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "TopEndpointsModule",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./TopEndpointsModule.jsx"
+}

+ 6 - 0
src/components/organisms/DashboardContent/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "DashboardContent",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./DashboardContent.jsx"
+}

+ 4 - 4
src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx

@@ -79,7 +79,7 @@ class DetailsPageHeader extends React.Component<Props, State> {
       return
     }
     this.stopPolling = false
-    this.pollData()
+    this.pollData(true)
   }
 
   componentWillUnmount() {
@@ -101,12 +101,12 @@ class DetailsPageHeader extends React.Component<Props, State> {
     }
   }
 
-  async pollData() {
+  async pollData(showLoading?: boolean) {
     if (this.stopPolling) {
       return
     }
 
-    await notificationStore.loadData()
+    await notificationStore.loadData(showLoading)
     this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
   }
 
@@ -115,7 +115,7 @@ class DetailsPageHeader extends React.Component<Props, State> {
       <Wrapper>
         <Menu>
           <NavigationMini />
-          <Logo to="/replicas" />
+          <Logo to="/" />
         </Menu>
         <User>
           <NotificationDropdown

+ 0 - 24
src/components/organisms/DetailsPageHeader/story.jsx

@@ -1,24 +0,0 @@
-/*
-Copyright (C) 2017  Cloudbase Solutions SRL
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU Affero General Public License for more details.
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
-
-import React from 'react'
-import { storiesOf } from '@storybook/react'
-import DetailsPageHeader from '.'
-
-storiesOf('DetailsPageHeader', module)
-  .add('default', () => (
-    <div style={{ width: '800px' }}>
-      <DetailsPageHeader notificationStore={{}} user={{ name: 'Name', email: 'email@email.com' }} />
-    </div>
-  ))

+ 16 - 5
src/components/organisms/Navigation/Navigation.jsx

@@ -24,6 +24,8 @@ import Logo from '../../atoms/Logo'
 import userStore from '../../../stores/UserStore'
 import configLoader from '../../../utils/Config'
 
+import StyleProps from '../../styleUtils/StyleProps'
+
 import { navigationMenu } from '../../../constants'
 import backgroundImage from './images/star-bg.jpg'
 import cbsImage from './images/cbsl-logo.svg'
@@ -69,7 +71,16 @@ const LogoStyled = styled(Logo)`
   display: flex;
 `
 
-const TinyLogo = styled.a`
+const WrappedLink = (props: any) => (
+  <div
+    style={{ transition: `all ${StyleProps.animations.swift}` }}
+    className={props.className}
+    ref={r => { props.customRef && props.customRef(r) }}
+  >
+    <Link to={props.to} style={{ display: 'flex', width: '100%' }} />
+  </div>
+)
+const TinyLogo = styled(WrappedLink)`
   position: absolute;
   top: 0;
   opacity: ${props => isCollapsed(props) ? 1 : 0};
@@ -240,7 +251,7 @@ class Navigation extends React.Component<Props> {
   get filteredMenu() {
     const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     const isDisabled = (page: string) => configLoader.config ? configLoader.config.disabledPages.find(p => p === page) : false
-    return navigationMenu.filter(i => !isDisabled(i.value) && (!i.requiresAdmin || isAdmin))
+    return navigationMenu.filter(i => !i.hidden && !isDisabled(i.value) && (!i.requiresAdmin || isAdmin))
   }
 
   componentDidMount() {
@@ -405,13 +416,13 @@ class Navigation extends React.Component<Props> {
             <LogoStyled
               small
               collapsed={this.props.collapsed}
-              href={navigationMenu[0].value}
+              to={navigationMenu[0].value}
               customRef={ref => { this.coriolisLogo = ref }}
             />
             <TinyLogo
               collapsed={this.props.collapsed}
-              innerRef={ref => { this.coriolisLogoSmall = ref }}
-              href={navigationMenu[0].value}
+              customRef={ref => { this.coriolisLogoSmall = ref }}
+              to={navigationMenu[0].value}
             />
           </LogoWrapper>
         )}

+ 16 - 8
src/components/organisms/PageHeader/PageHeader.jsx

@@ -22,7 +22,6 @@ import type { User } from '../../../types/User'
 import type { Project } from '../../../types/Project'
 import Dropdown from '../../molecules/Dropdown'
 import NewItemDropdown from '../../molecules/NewItemDropdown'
-import type { ItemType } from '../../molecules/NewItemDropdown'
 import NotificationDropdown from '../../molecules/NotificationDropdown'
 import UserDropdown from '../../molecules/UserDropdown'
 import Modal from '../../molecules/Modal'
@@ -41,8 +40,9 @@ import StyleProps from '../../styleUtils/StyleProps'
 
 const Wrapper = styled.div`
   display: flex;
-  margin: 48px 0;
+  margin: 32px 0 48px 0;
   align-items: center;
+  flex-wrap: wrap;
 `
 const Title = styled.div`
   color: ${Palette.black};
@@ -52,9 +52,13 @@ const Title = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  margin-top: 16px;
+  margin-right: 16px;
 `
 const Controls = styled.div`
   display: flex;
+  margin-top: 16px;
+  margin-left: -16px;
 
   & > div {
     margin-left: 16px;
@@ -66,6 +70,7 @@ type Props = {
   onProjectChange?: (project: Project) => void,
   onModalOpen?: () => void,
   onModalClose?: () => void,
+  componentRef?: (ref: any) => void
 }
 type State = {
   showChooseProviderModal: boolean,
@@ -91,7 +96,10 @@ class PageHeader extends React.Component<Props, State> {
 
   componentWillMount() {
     this.stopPolling = false
-    this.pollData()
+    this.pollData(true)
+    if (this.props.componentRef) {
+      this.props.componentRef(this)
+    }
   }
 
   componentWillUnmount() {
@@ -123,8 +131,8 @@ class PageHeader extends React.Component<Props, State> {
     }
   }
 
-  handleNewItem(item: ItemType) {
-    switch (item.value) {
+  handleNewItem(item: ?string) {
+    switch (item) {
       case 'endpoint':
         providerStore.loadProviders()
         if (this.props.onModalOpen) {
@@ -219,7 +227,7 @@ class PageHeader extends React.Component<Props, State> {
     this.setState({ showProjectModal: false })
   }
 
-  async pollData() {
+  async pollData(showLoading?: boolean) {
     if (
       this.stopPolling ||
       this.state.showChooseProviderModal ||
@@ -231,7 +239,7 @@ class PageHeader extends React.Component<Props, State> {
       return
     }
 
-    await notificationStore.loadData()
+    await notificationStore.loadData(showLoading)
     this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
   }
 
@@ -247,7 +255,7 @@ class PageHeader extends React.Component<Props, State> {
             noItemsMessage="Loading..."
             labelField="name"
           />
-          <NewItemDropdown onChange={item => { this.handleNewItem(item) }} />
+          <NewItemDropdown onChange={item => { this.handleNewItem(item.value) }} />
           <NotificationDropdown
             items={notificationStore.notificationItems}
             onClose={() => this.handleNotificationsClose()}

+ 154 - 0
src/components/pages/DashboardPage/DashboardPage.jsx

@@ -0,0 +1,154 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import replicaStore from '../../../stores/ReplicaStore'
+import migrationStore from '../../../stores/MigrationStore'
+import endpointStore from '../../../stores/EndpointStore'
+import userStore from '../../../stores/UserStore'
+import projectStore from '../../../stores/ProjectStore'
+import licenceStore from '../../../stores/LicenceStore'
+import notificationStore from '../../../stores/NotificationStore'
+
+import MainTemplate from '../../templates/MainTemplate'
+import Navigation from '../../organisms/Navigation'
+import PageHeader from '../../organisms/PageHeader'
+import DashboardContent from '../../organisms/DashboardContent'
+
+import Utils from '../../../utils/ObjectUtils'
+import configLoader from '../../../utils/Config'
+
+const Wrapper = styled.div``
+
+type State = {
+  modalIsOpen: boolean,
+}
+@observer
+class ProjectsPage extends React.Component<{ history: any }, State> {
+  state = {
+    modalIsOpen: false,
+  }
+  pageHeaderRef: PageHeader
+  pollTimeout: TimeoutID
+  stopPolling: boolean
+
+  componentDidMount() {
+    document.title = 'Dashboard'
+
+    this.stopPolling = false
+    this.pollData(true)
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
+  }
+
+  async handleProjectChange() {
+    this.stopPolling = true
+    clearTimeout(this.pollTimeout)
+    await this.loadData(true)
+    this.pollData(false)
+  }
+
+  handleModalOpen() {
+    this.setState({ modalIsOpen: true })
+  }
+
+  handleModalClose() {
+    this.setState({ modalIsOpen: false }, () => {
+      this.pollData(false)
+    })
+  }
+
+  handleNewEndpointClick() {
+    this.pageHeaderRef.handleNewItem('endpoint')
+  }
+
+  async pollData(showLoading: boolean) {
+    if (this.state.modalIsOpen || this.stopPolling) {
+      return
+    }
+
+    await this.loadData(showLoading)
+    this.pollTimeout = setTimeout(() => { this.pollData(false) }, configLoader.config.requestPollTimeout)
+  }
+
+  async loadData(showLoading: boolean) {
+    this.loadAdminData(showLoading)
+
+    await Promise.all([
+      replicaStore.getReplicas({ skipLog: true, showLoading }),
+      migrationStore.getMigrations({ skipLog: true, showLoading }),
+      endpointStore.getEndpoints({ skipLog: true, showLoading }),
+      projectStore.getProjects({ skipLog: true, showLoading }),
+      licenceStore.loadLicenceInfo({ skipLog: true, showLoading }),
+    ])
+  }
+
+  async loadAdminData(showLoading: boolean) {
+    await Utils.waitFor(() => Boolean(userStore.loggedUser && userStore.loggedUser.isAdmin), 3000, 100)
+    if (userStore.loggedUser && userStore.loggedUser.isAdmin) {
+      await userStore.getAllUsers({ skipLog: true, showLoading })
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <MainTemplate
+          navigationComponent={<Navigation currentPage="dashboard" />}
+          listNoMargin
+          listComponent={(
+            <DashboardContent
+              replicas={replicaStore.replicas}
+              migrations={migrationStore.migrations}
+              endpoints={endpointStore.endpoints}
+              users={userStore.users}
+              projects={projectStore.projects}
+              licence={licenceStore.licenceInfo}
+              isAdmin={Boolean(userStore.loggedUser && userStore.loggedUser.isAdmin)}
+              notificationItems={notificationStore.notificationItems}
+              notificationItemsLoading={notificationStore.loading}
+              endpointsLoading={endpointStore.loading}
+              migrationsLoading={migrationStore.loading}
+              projectsLoading={projectStore.projects.length === 0}
+              usersLoading={userStore.users.length === 0}
+              licenceLoading={licenceStore.loadingLicenceInfo}
+              replicasLoading={replicaStore.loading}
+              onNewReplicaClick={() => { this.props.history.push('/wizard/replica') }}
+              onNewEndpointClick={() => { this.handleNewEndpointClick() }}
+            />
+          )}
+          headerComponent={
+            <PageHeader
+              title="Dashboard"
+              onModalOpen={() => { this.handleModalOpen() }}
+              onModalClose={() => { this.handleModalClose() }}
+              onProjectChange={() => { this.handleProjectChange() }}
+              componentRef={ref => { this.pageHeaderRef = ref }}
+            />
+          }
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default ProjectsPage

+ 6 - 0
src/components/pages/DashboardPage/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "DashboardPage",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./DashboardPage.jsx"
+}

+ 1 - 1
src/components/pages/LoginPage/LoginPage.jsx

@@ -120,7 +120,7 @@ class LoginPage extends React.Component<Props, State> {
 
   render() {
     if (userStore.loggedIn) {
-      this.props.history.push('/replicas')
+      this.props.history.push('/')
     }
 
     return (

+ 6 - 4
src/components/styleUtils/StyleProps.js

@@ -18,10 +18,11 @@ import { css } from 'styled-components'
 
 const StyleProps = {
   fontWeights: {
-    light: '300',
-    regular: '400',
-    medium: '500',
-    bold: '600',
+    extraLight: 200,
+    light: 300,
+    regular: 400,
+    medium: 500,
+    bold: 600,
   },
 
   inputSizes: {
@@ -32,6 +33,7 @@ const StyleProps = {
 
   borderRadius: '4px',
   contentWidth: '928px',
+  boxShadow: 'box-shadow: rgba(0, 0, 0, 0.1) 0 0 6px 2px;',
 
   animations: {
     swift: '.45s cubic-bezier(0.3, 1, 0.4, 1) 0s',

+ 1 - 3
src/components/templates/MainTemplate/MainTemplate.jsx

@@ -39,9 +39,7 @@ const List = styled.div`
   flex-direction: column;
   min-height: 0;
 `
-const Header = styled.div`
-  min-width: 800px;
-`
+const Header = styled.div``
 const Footer = styled.div`
   flex-shrink: 0;
 `

+ 1 - 0
src/constants.js

@@ -31,6 +31,7 @@ export const servicesUrl = {
 }
 
 export const navigationMenu = [
+  { label: 'Dashboard', value: 'dashboard', hidden: true },
   { label: 'Replicas', value: 'replicas' },
   { label: 'Migrations', value: 'migrations' },
   { label: 'Cloud Endpoints', value: 'endpoints' },

+ 2 - 2
src/sources/LincenceSource.js

@@ -20,9 +20,9 @@ import { servicesUrl } from '../constants'
 import type { Licence } from '../types/Licence'
 
 class LicenceSource {
-  async loadLicenceInfo(): Promise<Licence> {
+  async loadLicenceInfo(skipLog?: ?boolean): Promise<Licence> {
     let url = `${servicesUrl.licence}/licence-status`
-    let response = await Api.send({ url, quietError: true })
+    let response = await Api.send({ url, quietError: true, skipLog })
     let root = response.data.licence_status
     return ({
       currentPeriodStart: new Date(root.current_period_start),

+ 1 - 1
src/sources/UserSource.js

@@ -159,7 +159,7 @@ class UserSource {
     let token = cookie.get('token')
     let clear = () => {
       cookie.remove('token')
-      window.location.href = '/'
+      window.location.href = '/login'
       Api.setDefaultHeader('X-Auth-Token', null)
     }
 

+ 3 - 3
src/stores/LicenceStore.js

@@ -39,10 +39,10 @@ class LicenceStore {
     return this.version || ''
   }
 
-  @action async loadLicenceInfo() {
-    this.loadingLicenceInfo = true
+  @action async loadLicenceInfo(opts?: { skipLog?: boolean, showLoading?: boolean }) {
+    if (opts && opts.showLoading) this.loadingLicenceInfo = true
     try {
-      let licence = await licenceSource.loadLicenceInfo()
+      let licence = await licenceSource.loadLicenceInfo(opts && opts.skipLog)
       runInAction(() => {
         this.licenceInfo = licence
         this.loadingLicenceInfo = false

+ 4 - 1
src/stores/NotificationStore.js

@@ -22,6 +22,7 @@ import NotificationSource from '../sources/NotificationSource'
 class NotificationStore {
   @observable alerts: AlertInfo[] = []
   @observable notificationItems: NotificationItemData[] = []
+  @observable loading: boolean = false
 
   visibleErrors: string[] = []
 
@@ -36,8 +37,10 @@ class NotificationStore {
     }
   }
 
-  @action async loadData() {
+  @action async loadData(showLoading?: boolean) {
+    this.loading = Boolean(showLoading)
     let data = await NotificationSource.loadData()
+    this.loading = false
     this.notificationItems = data
   }
 

+ 1 - 1
src/stores/UserStore.js

@@ -116,7 +116,7 @@ class UserStore {
       await this.isAdmin()
       runInAction(() => { this.loggedIn = true })
     } finally {
-      this.loading = false
+      runInAction(() => { this.loading = false })
     }
   }
 

+ 3 - 3
src/utils/ApiCaller.js

@@ -49,9 +49,9 @@ const addCancelable = (cancelable: Cancelable) => {
 
 const isOnLoginPage = (): boolean => {
   if (window.env.ENV === 'development') {
-    return window.location.hash === '#/' || window.location.hash === '#/login'
+    return window.location.hash === '#/login'
   }
-  return window.location.pathname === '/' || window.location.pathname === '/login'
+  return window.location.pathname === '/login'
 }
 
 class ApiCaller {
@@ -128,7 +128,7 @@ class ApiCaller {
           }
 
           if (error.response.status === 401 && !isOnLoginPage() && error.request.responseURL.indexOf('/proxy/') === -1) {
-            window.location.href = '/'
+            window.location.href = '/login'
           }
 
           logger.log({

+ 2 - 2
src/utils/ObjectUtils.js

@@ -55,7 +55,7 @@ class ObjectUtils {
     return result
   }
 
-  static async waitFor(predicate: () => boolean, timeoutMs?: number = 15000) {
+  static async waitFor(predicate: () => boolean, timeoutMs?: number = 15000, tryEvery?: number = 1000) {
     let wait = (ms: number) => new Promise(resolve => { setTimeout(() => { resolve() }, ms) })
     let startTime = new Date().getTime()
     let testLoop = async () => {
@@ -65,7 +65,7 @@ class ObjectUtils {
       if (new Date().getTime() - startTime > timeoutMs) {
         throw new Error(`Timeout: waiting for more than ${timeoutMs} ms`)
       }
-      await wait(1000)
+      await wait(tryEvery)
       await testLoop()
     }
     await testLoop()

Some files were not shown because too many files changed in this diff