Explorar el Código

Add Dashboard Page with data visualization modules

The Dashboard Page includes different data visualization modules and is
the app's new home page.

The charts are made from scratch in HTML canvas and CSS, so no 3rd party
library dependency is required.
Sergiu Miclea hace 6 años
padre
commit
9d4ff35446
Se han modificado 61 ficheros con 2377 adiciones y 148 borrados
  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:

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 0
src/components/atoms/EndpointLogos/images/aws-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 0
src/components/atoms/EndpointLogos/images/azure-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 5 - 0
src/components/atoms/EndpointLogos/images/hyperv-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
src/components/atoms/EndpointLogos/images/oci-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
src/components/atoms/EndpointLogos/images/opc-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 0
src/components/atoms/EndpointLogos/images/openstack-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
src/components/atoms/EndpointLogos/images/oraclevm-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 4 - 0
src/components/atoms/EndpointLogos/images/scvmm-32-white.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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()

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio